Un modelo que nadie entiende no se usa. Y esto no es filosofía: es lo que pasa cuando el equipo comercial recibe una lista de 200 nombres y nadie sabe decir por qué están esos y no otros 🤷♀️
Una pregunta para ti: ¿te ha tocado defender un resultado que no sabías explicar del todo? Es de las sensaciones más incómodas de este trabajo, y se arregla antes de la reunión, no durante 😬
En este capítulo abrimos la caja. Y de paso vamos a encontrar una columna que medimos en doce puntos en el capítulo 4 y que al modelo no le aporta nada, lo cual tiene una explicación bonita.
El punto de partida
import numpy as np import pandas as pd from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.pipeline import Pipeline from sklearn.preprocessing import OneHotEncoder, StandardScaler URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv' def carga_limpia(url): v = pd.read_csv(url).drop_duplicates() v['ciudad'] = (v['ciudad'].str.strip().str.lower() .str.normalize('NFKD') .str.encode('ascii', 'ignore').str.decode('utf-8')) v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.')) for col in ['fecha', 'fecha_ultima_compra']: f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce') falta = f.isna() & v[col].notna() f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y', errors='coerce') v[col] = f return v def prepara(v): v = v.sort_values(['cliente_id', 'fecha']).copy() v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int) v['sin_descuento'] = v['descuento'].isna().astype(int) v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int) v['precio_unitario'] = v['monto'] / v['unidades'] v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1 return v NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario', 'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero'] CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria'] pre = ColumnTransformer([ ('num', Pipeline([('r', SimpleImputer(strategy='median')), ('e', StandardScaler())]), NUMERICAS), ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')), ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS), ]) datos = prepara(carga_limpia(URL)) X = datos[NUMERICAS + CATEGORICAS] y = datos['compro'] X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y) modelo = Pipeline([('pre', pre), ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(X_tr, y_tr) print('AUC:', round(modelo.score(X_te, y_te), 4))
AUC: 0.6733
Los coeficientes, que es lo que la logística regala
Una regresión logística le pone un número a cada columna. Ese número es la explicación, no una aproximación de la explicación.
nombres = modelo.named_steps['pre'].get_feature_names_out() pesos = modelo.named_steps['mod'].coef_[0] for i in np.argsort(np.abs(pesos))[::-1][:8]: print(f'{nombres[i]:32} {pesos[i]:+.4f}')
cat__segmento_Bodega -0.6832 cat__segmento_Mayorista +0.4725 cat__canal_Marketplace -0.4651 num__satisfaccion +0.4152 num__sin_compra_previa -0.3747 cat__canal_WhatsApp +0.3265 cat__segmento_Horeca +0.3248 cat__canal_Tienda +0.2874
Se leen así: positivo empuja hacia el sí, negativo hacia el no, y el tamaño dice cuánto.
Ser Bodega es lo que más resta (-0,68) y ser Mayorista lo que más suma (+0,47). Marketplace resta, WhatsApp suma. Todo eso ya lo habíamos visto a mano en el capítulo 1, y ahora sale solo y con todas las columnas a la vez.
Eso último es la gracia: en el capítulo 1 miramos el segmento ignorando el resto. El coeficiente dice cuánto aporta el segmento una vez descontado todo lo demás 🎯
Convertirlos a algo que se pueda decir en voz alta
la misma fórmula al revés: cada peso dice cuánto mueve el logaritmo de la ventaja, y por eso un peso de 0,7 se lee como multiplicar la ventaja por dos
Los coeficientes están en log-odds, que no se explican en una reunión. Se arregla con una exponencial:
for i in np.argsort(np.abs(pesos))[::-1][:6]: print(f'{nombres[i]:32} multiplica las probabilidades por {np.exp(pesos[i]):.2f}')
cat__segmento_Bodega multiplica las probabilidades por 0.51 cat__segmento_Mayorista multiplica las probabilidades por 1.60 cat__canal_Marketplace multiplica las probabilidades por 0.63 num__satisfaccion multiplica las probabilidades por 1.51 num__sin_compra_previa multiplica las probabilidades por 0.69 cat__canal_WhatsApp multiplica las probabilidades por 1.39
Ahora sí: ser Mayorista multiplica por 1,6 las probabilidades a favor de cerrar; ser Bodega las multiplica por 0,5, o sea las corta a la mitad.
Ojo con una trampa de vocabulario que se comete siempre: eso son odds, no probabilidades. "Multiplica por 1,6 las probabilidades" es incorrecto en sentido estricto; lo correcto es "la razón de probabilidades". En una reunión yo digo lo primero y lo aclaro si alguien pregunta, y en un informe escrito lo pongo bien 📝
Y un aviso más importante: los coeficientes de las numéricas están
escalados por el StandardScaler, así que +0,4152 en
satisfacción significa "por cada desviación típica de satisfacción", no "por cada
punto". Se puede desescalar, y casi siempre no hace falta: para comparar
importancia entre columnas, escalado es justo lo que quieres.
La forma que funciona con cualquier modelo
Los coeficientes solo existen en los modelos lineales. Para un bosque o un boosting hace falta otra cosa, y la buena se llama importancia por permutación.
La idea es preciosa de tan simple: revuelve una columna al azar y mira cuánto empeora el modelo. Si empeora mucho, esa columna importaba. Si no cambia nada, no servía.
from sklearn.inspection import permutation_importance r = permutation_importance(modelo, X_te, y_te, n_repeats=10, random_state=42, scoring='roc_auc', n_jobs=-1) for i in r.importances_mean.argsort()[::-1]: print(f'{X_te.columns[i]:20} {r.importances_mean[i]:+.4f} ± {r.importances_std[i]:.4f}')
segmento +0.0909 ± 0.0117 satisfaccion +0.0464 ± 0.0076 sin_compra_previa +0.0361 ± 0.0082 canal +0.0334 ± 0.0064 monto +0.0067 ± 0.0019 precio_unitario +0.0060 ± 0.0031 descuento +0.0050 ± 0.0022 visita_numero +0.0008 ± 0.0014 unidades +0.0006 ± 0.0014 sin_descuento -0.0002 ± 0.0003 categoria -0.0002 ± 0.0011 sin_satisfaccion -0.0008 ± 0.0002 ciudad -0.0017 ± 0.0011
Ahí está el ranking honesto, medido en puntos de AUC perdidos:
- 🥇 segmento: 0,0909. Si lo revuelves, el modelo pierde nueve puntos. Es la columna del libro.
- 🥈 satisfacción: 0,0464.
- 🥉 sin_compra_previa: 0,0361, la bandera que construimos en el capítulo 9 con una línea.
- 4️⃣ canal: 0,0334.
Y de ahí para abajo, todo por debajo de 0,007. Cuatro columnas hacen el modelo y las otras nueve están de adorno.
Fíjate en tres ventajas enormes de esta técnica: funciona con cualquier modelo, se mide en la unidad que te importa (aquí AUC) y viene con desviación, así que sabes cuándo dos columnas están empatadas 📐
La columna que valía doce puntos y no aporta nada
Mira sin_descuento en esa lista: -0,0002. Cero.
Y en el capítulo 4 medimos que sin descuento se cierra el 48,1% y con descuento el 60,2%. Doce puntos de diferencia. ¿Cómo puede no aportar nada?
print('la bandera sola:') print(datos.groupby('sin_descuento')['compro'].mean().round(4)) print() print('el descuento imputado, por bandera:') imputado = datos['descuento'].fillna(datos['descuento'].median()) print(pd.DataFrame({'bandera': datos['sin_descuento'], 'descuento': imputado}).groupby('bandera')['descuento'].describe()[['min', 'max']].round(3))
la bandera sola:
sin_descuento
0 0.6017
1 0.4807
Name: compro, dtype: float64
el descuento imputado, por bandera:
min max
bandera
0 0.000 0.250
1 0.127 0.127
Ahí está la explicación: cuando la bandera vale 1, el imputador rellenó el descuento con la mediana, y ese valor exacto (0,127) prácticamente solo aparece en esas filas.
O sea que la columna descuento ya lleva dentro la
información de la bandera. Son redundantes, y al revolver una la otra
sigue diciendo lo mismo.
Esa es la trampa grande de la importancia por permutación y hay que saberla: con dos columnas que dicen lo mismo, las dos salen sin importancia, porque el modelo se apoya en la que quede. No significa que ninguna importe: significa que sobra una 🔍
La forma de comprobarlo es quitarlas de verdad, que es el ejercicio 4.
Explicar UNA predicción
Lo de arriba explica el modelo. Pero en la reunión te van a preguntar por una fila: ¿por qué a este cliente le pusiste 0,85?
fila = X_te.iloc[[3]] prob = modelo.predict_proba(fila)[0, 1] transformada = modelo.named_steps['pre'].transform(fila)[0] aporte = transformada * pesos print('probabilidad:', round(prob, 4)) print() for i in np.argsort(np.abs(aporte))[::-1][:6]: print(f'{nombres[i]:32} {aporte[i]:+.4f}')
probabilidad: 0.6595 cat__canal_WhatsApp +0.3265 num__satisfaccion -0.3075 num__sin_compra_previa +0.1758 cat__categoria_Abarrotes +0.1027 cat__segmento_Minimarket +0.0666 num__visita_numero +0.0579
Eso es una explicación completa de esa fila: cada número dice cuánto empujó cada característica de este cliente.
Y sumado da exactamente el resultado, se puede comprobar:
total = aporte.sum() + modelo.named_steps['mod'].intercept_[0] print('suma de aportes + intercepto:', round(total, 4)) print('probabilidad reconstruida :', round(1 / (1 + np.exp(-total)), 4)) print('la que dio el modelo :', round(prob, 4))
suma de aportes + intercepto: 0.6613 probabilidad reconstruida : 0.6595 la que dio el modelo : 0.6595
Idénticas 🎯 Eso es lo que hace que un modelo lineal sea auditable de verdad: no hay nada escondido, la predicción es la suma.
El coeficiente que cambia de signo
Todo lo de arriba tiene una letra pequeña que hay que leer antes de contarle a nadie qué columna importa 🔬
En estos datos hay dos columnas que dicen casi lo mismo:
print('correlación monto / monto_final_facturado:', round(datos['monto'].corr(datos['monto_final_facturado']), 4))
correlación monto / monto_final_facturado: 0.7401
0,74. No son la misma columna, pero se parecen mucho. Ahora mira qué le pasa al coeficiente del monto según esté sola o acompañada:
from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler def coef_de(cols): p = Pipeline([ ('p', ColumnTransformer([('n', Pipeline([ ('i', SimpleImputer(strategy='median')), ('s', StandardScaler()), ]), cols)])), ('m', LogisticRegression(max_iter=1000, random_state=42)), ]) p.fit(datos[cols], datos['compro']) return dict(zip(cols, p.named_steps['m'].coef_[0].round(3))) print('sola :', coef_de(['monto', 'unidades', 'satisfaccion'])) print('acompañada:', coef_de(['monto', 'monto_final_facturado', 'unidades', 'satisfaccion']))
sola : {'monto': np.float64(0.461), 'unidades': np.float64(0.028), 'satisfaccion': np.float64(0.376)}
acompañada: {'monto': np.float64(-2.029), 'monto_final_facturado': np.float64(15.325), 'unidades': np.float64(0.089), 'satisfaccion': np.float64(0.231)}
El coeficiente del monto pasa de +0,461 a −2,029 😱
Los mismos datos, la misma columna, el mismo modelo. Lo único que cambió es que al lado hay otra columna parecida. Y no cambió de tamaño: cambió de signo.
Con el primer modelo dirías "cuanto más grande la venta, más probable que compre". Con el segundo dirías exactamente lo contrario. Las dos frases salen de mirar un coeficiente, y las dos serían igual de defendibles delante de alguien que no sepa esto.
Por qué pasa
Un coeficiente no significa "el efecto de esta columna". Significa "el efecto de esta columna manteniendo las demás quietas", y esa diferencia lo es todo 🔑
Cuando dos columnas se mueven juntas, "mantener la otra quieta" es una
situación que casi no existe en los datos. El modelo tiene que inventarse cómo
repartir el crédito entre las dos, y le vale cualquier reparto que dé el mismo
resultado: +15,3 a una y −2,0 a la otra suma lo mismo
que un reparto más razonable.
Se llama multicolinealidad, y aquí tienes su síntoma en pantalla: coeficientes enormes, de signos opuestos, en columnas que se parecen.
Qué hacer con esto
- 🔎 Mira las correlaciones antes de leer coeficientes. Si dos columnas pasan de 0,7, no leas sus coeficientes por separado.
- ✂️ Quita una de las dos. Si dicen casi lo mismo, la segunda no está aportando información nueva, solo confusión.
- 📊 Usa permutación en vez de coeficientes. Lo de arriba mide cuánto empeora el modelo al estropear una columna, y eso no se reparte de forma arbitraria.
Y un aviso sobre esta columna en concreto, para que no la copies:
monto_final_facturado es además una fuga. Es lo que
se facturó, o sea que existe después de que la venta pasara, y un modelo que la
usa está mirando el futuro. Lo desarrollo en el capítulo
12.
La he usado aquí porque es el ejemplo más limpio que tengo de dos columnas correlacionadas, y porque las dos cosas van juntas más veces de lo que parece: una columna que se parece demasiado a la respuesta suele ser sospechosa por los dos motivos a la vez 🕵️
Sobre SHAP, que es lo que te van a preguntar
SHAP es la técnica estándar para esto con modelos complicados. Reparte la predicción entre las columnas de una forma que viene de teoría de juegos y tiene propiedades matemáticas bonitas.
Vive en una librería aparte (pip install shap) y no la uso en
este libro por dos razones honestas:
- 📦 No está instalada en el entorno donde se ejecutan estos capítulos, y este libro no publica código que no corre.
- ➕ Para un modelo lineal, los aportes de SHAP son exactamente lo que acabamos de calcular a mano. Con tres líneas y sin dependencias.
Cuándo sí vale la pena instalarlo: cuando el modelo que ganó es un bosque o un boosting y necesitas explicar filas concretas. Para eso no hay atajo y SHAP es lo mejor que hay 💛
Ejercicios
Siete. Intenta antes de abrir 💛
1. Los coeficientes del segmento, ordenados
Saca solo los cuatro segmentos y ordénalos.
seg = [(nombres[i], pesos[i]) for i in range(len(nombres)) if nombres[i].startswith('cat__segmento')] for n, p in sorted(seg, key=lambda x: -x[1]): print(f'{n:32} {p:+.4f}')
cat__segmento_Mayorista +0.4725 cat__segmento_Horeca +0.3248 cat__segmento_Minimarket +0.0666 cat__segmento_Bodega -0.6832
Mayorista, Horeca, Minimarket, Bodega, de más a menos. El mismo orden que las tasas del capítulo 1 (72,3%, 63,2%, 56,9%, 37,6%), y eso es una comprobación de sanidad muy útil.
Si el orden del modelo no coincidiera con el de los datos crudos, habría algo raro y valdría la pena mirarlo 🔍
2. Desescalar un coeficiente
Traduce el peso de la satisfacción a "por cada punto de satisfacción".
escalador = modelo.named_steps['pre'].named_transformers_['num'].named_steps['e'] i_sat = NUMERICAS.index('satisfaccion') desviacion = escalador.scale_[i_sat] peso_escalado = pesos[list(nombres).index('num__satisfaccion')] print('desviación típica de satisfacción:', round(desviacion, 4)) print('por desviación:', round(peso_escalado, 4)) print('por punto :', round(peso_escalado / desviacion, 4)) print('odds por punto:', round(np.exp(peso_escalado / desviacion), 4))
desviación típica de satisfacción: 1.3641 por desviación: 0.4152 por punto : 0.3043 odds por punto: 1.3557
Cada punto de satisfacción multiplica las odds por 1,34. Eso sí se puede decir en una reunión, y encima es accionable: si el equipo de atención sube un punto la satisfacción media, esto es lo que pasa 📈
3. La importancia según el bosque, comparada
Pon al lado la importancia del bosque y la de permutación, y mira si coinciden.
from sklearn.ensemble import RandomForestClassifier bosque = Pipeline([('pre', pre), ('mod', RandomForestClassifier(n_estimators=300, max_depth=5, random_state=42, n_jobs=-1))]).fit(X_tr, y_tr) rb = permutation_importance(bosque, X_te, y_te, n_repeats=10, random_state=42, scoring='roc_auc', n_jobs=-1) for i in rb.importances_mean.argsort()[::-1][:5]: print(f'{X_te.columns[i]:20} {rb.importances_mean[i]:+.4f}')
segmento +0.0365 satisfaccion +0.0270 monto +0.0257 sin_compra_previa +0.0213 canal +0.0206
El bosque, medido por permutación, da un ranking parecido al de la logística: segmento arriba del todo.
Y compáralo con lo que decía feature_importances_ en el capítulo
13, donde el monto salía primero. Dos técnicas sobre el mismo modelo
entrenado dan rankings distintos, y la de permutación es la que hay que
creer, porque mide lo que te importa (AUC perdido) en vez de contar cortes 🔍
4. Quitar de verdad las columnas que no aportan
Entrena solo con las cuatro columnas que salieron arriba y compara.
CUATRO_NUM = ['satisfaccion', 'sin_compra_previa'] CUATRO_CAT = ['segmento', 'canal'] pre4 = ColumnTransformer([ ('num', Pipeline([('r', SimpleImputer(strategy='median')), ('e', StandardScaler())]), CUATRO_NUM), ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')), ('c', OneHotEncoder(handle_unknown='ignore'))]), CUATRO_CAT), ]) m4 = Pipeline([('pre', pre4), ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit( X_tr[CUATRO_NUM + CUATRO_CAT], y_tr) from sklearn.metrics import roc_auc_score print('con 4 columnas :', round(roc_auc_score(y_te, m4.predict_proba(X_te[CUATRO_NUM + CUATRO_CAT])[:, 1]), 4)) print('con 13 columnas:', round(roc_auc_score(y_te, modelo.predict_proba(X_te)[:, 1]), 4))
con 4 columnas : 0.7193 con 13 columnas: 0.7214
0,7193 con cuatro columnas contra 0,7214 con trece. Dos milésimas de diferencia, menos que la desviación de la validación cruzada del capítulo 16.
O sea que las otras nueve columnas no hacen nada. Y un modelo de cuatro columnas es más fácil de explicar, más barato de mantener y menos frágil, así que en producción yo me quedaría con este 🧹
5. La fila que el modelo se equivoca
Busca un caso donde el modelo estaba muy seguro y falló, y explícalo.
prob_te = modelo.predict_proba(X_te)[:, 1] error = np.abs(prob_te - y_te.values) peor = error.argmax() print('el modelo dijo:', round(prob_te[peor], 4)) print('la verdad era :', int(y_te.iloc[peor])) print() print(X_te.iloc[peor][['segmento', 'canal', 'satisfaccion', 'monto']])
el modelo dijo: 0.9032 la verdad era : 0 segmento Mayorista canal Tienda satisfaccion 5.0 monto 1796.06 Name: 135, dtype: object
Un caso con todo a favor que no cerró. Y no hay nada que arreglar: pasa.
Mirar los peores errores uno por uno es de las cosas más útiles que existen y casi nadie las hace. A veces encuentras un patrón (todos son de la misma sucursal, todos del mismo mes) y ahí sí hay algo que corregir 🕵️♀️
6. El error de leer los nombres al revés
Intenta emparejar coeficientes con las columnas originales en vez de con las transformadas.
pd.Series(pesos, index=X.columns)
ValueError: Length of values (28) does not match length of index (13)
Trece nombres para veintiocho pesos. Y menos mal que da error, porque si hubiera coincidido el número, habrías publicado una tabla de importancias completamente equivocada y nada te habría avisado 😳
Después de un OneHotEncoder los nombres se piden siempre con
get_feature_names_out(), nunca se asumen.
7. La tabla que entregas con el modelo
Junta coeficiente, odds e importancia en una sola tabla, lista para el informe.
filas = [] for i, col in enumerate(X_te.columns): filas.append({'columna': col, 'importancia': round(r.importances_mean[i], 4), 'desviacion': round(r.importances_std[i], 4)}) tabla = pd.DataFrame(filas).sort_values('importancia', ascending=False) print(tabla.head(6).to_string(index=False))
columna importancia desviacion
segmento 0.0909 0.0117
satisfaccion 0.0464 0.0076
sin_compra_previa 0.0361 0.0082
canal 0.0334 0.0064
monto 0.0067 0.0019
precio_unitario 0.0060 0.0031
Seis filas y una columna de desviación al lado, que es lo que dice cuáles están empatadas.
Con esa tabla, cualquiera puede discutir tu modelo sin creerte, y eso es justamente lo que hace que te crean 💛
Comprueba que lo tienes
Una columna tenía doce puntos de diferencia entre grupos y el modelo no la usa. ¿Qué pasó?
- Que otra columna ya traía esa información
- Que el modelo está mal entrenado
- Que la diferencia de doce puntos era mentira
- Que hay que forzar al modelo a usarla
El ranking que ordena otra cosa
La trampa
Ya tienes el modelo entrenado y te piden qué variable pesa más. Ordenas los coeficientes por tamaño y sale la respuesta.
coef = pd.Series(modelo.coef_[0], index=X.columns) print(coef.abs().sort_values( ascending=False).head(3)) # satisfaccion 0.2745 # unidades 0.0049 # monto 0.0006
Qué está mal
Ese ranking no ordena importancia: ordena unidades de medida 📏 La satisfacción va de 1 a 5 y el monto llega a 4.236, así que el coeficiente del monto es pequeño porque su columna es grande, no porque no importe. Multiplica el monto por mil y su coeficiente se divide por mil sin que el modelo cambie en nada.
Los coeficientes solo se pueden comparar entre ellos si las columnas están en la misma escala, y por eso este capítulo los lee sobre datos escalados. Si no lo están, lo que se compara es coeficiente x desviación de la columna, que sí es comparable.
Y hay una segunda capa: un coeficiente grande con un intervalo que cruza el cero no es grande, es desconocido. Eso es el capítulo 22.
Lo que te llevas
- 📊 En una logística, los coeficientes son la explicación. Positivo empuja al sí, negativo al no.
- 🗣️ Se dicen en voz alta con
exp(): ser Mayorista multiplica las odds por 1,6 y ser Bodega las corta a la mitad. - 🔀 La importancia por permutación funciona con cualquier modelo, se mide en la unidad que te importa y trae desviación.
- 🥇 Cuatro columnas hacen el modelo: segmento, satisfacción, sin_compra_previa y canal. Las otras nueve están de adorno.
- 🔍 Dos columnas que dicen lo mismo salen las dos sin importancia. No sobra información: sobra una columna.
- 🧾 Una predicción concreta se explica sumando aportes, y la suma reconstruye la probabilidad exacta.
- 📦 Para un modelo lineal, SHAP da lo mismo que esas tres líneas. Instálalo cuando el modelo sea un bosque o un boosting.
Y si de todo el capítulo te llevas una sola frase, que sea esta:
Un modelo que nadie sabe explicar no se usa. Y uno que no se usa no existe.
Explicar un modelo a quien no es técnico es media profesión. De eso va el artículo de cómo ser analista de datos 🗣️
En el capítulo 24 vamos a lo que el modelo aprendió sin que nadie se lo pidiera: sesgo, grupos que salen peor parados y cuánta confianza merece cada predicción.
Que tengas lindo día! 🌸
Practica este capítulo 📓
Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.
¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.