Curso / fast.ai / Lección 2
fast.ai · 2 de 14

fast.ai · Practical Deep Learning for Coders · Lección 2

Deployment: de un dataset propio a una app desplegada

La lección que cumple la promesa de la Lección 1: armar tu propio dataset desde cero, entrenar, interpretar errores, limpiar datos con ayuda del propio modelo, y desplegar. El proceso completo, de punta a punta.

Antes de leerlección 1

🎯 Al terminar vas a poder

  • Desarmar la física de DataBlock: decodificación y normalización en CPU a tensores homogéneos [B,3,224,224] vs. matrices afines A∈R3×3 de data augmentation aceleradas en GPU.
  • Demostrar matemáticamente por qué la pérdida de Cross-Entropy (-logP) actúa como un detector automático de anomalías para identificar ruido de etiquetas mediante plot_top_losses.
  • Distinguir con rigor formal Covariate Shift (P(X) cambia), Concept Drift (P(Y|X) cambia) y bucles de retroalimentación (Model Collapse en LLMs), implementando rollouts en fase sombra.
  • Diseccionar qué serializa learn.export() (pesos, grafo, estadísticas de normalización μ,σ y vocabulario) y explicar por qué en inferencia con B=1 la CPU supera en latencia y costo a una GPU.

1. El pipeline completo: de la búsqueda de imágenes al modelo

El ejemplo del libro es un clasificador de osos (grizzly / negro / teddy), armado con imágenes buscadas y descargadas por código — no un dataset ya empaquetado y limpio como el de la Lección 1. El pipeline:

from fastai.vision.all import *
from fastbook import search_images_ddg      # pip install fastbook

bear_types = 'grizzly', 'black', 'teddy'
path = Path('bears')

# 1. Buscar y descargar (150 imágenes por clase alcanza), una carpeta por clase
for o in bear_types:
    dest = path/o
    dest.mkdir(exist_ok=True, parents=True)
    urls = search_images_ddg(f'{o} bear', max_images=150)
    download_images(dest, urls=urls)

# 2. Verificar y descartar archivos corruptos
fns = get_image_files(path)
failed = verify_images(fns)
failed.map(Path.unlink)
print(f'{len(failed)} archivos rotos borrados')

🟢 Actualización 2026: DuckDuckGo en vez de Bing

El libro impreso usa search_images_bing(key, 'grizzly bear') con una key de Azure Cognitive Services. Esa API fue retirada por Microsoft (el retiro de las Bing Search APIs se concretó en 2025), así que el código original directamente no corre. El curso vigente (2022) y la versión actual de los notebooks de fastbook usan DuckDuckGo, que no pide key: search_images_ddg del paquete fastbook, o directamente el paquete duckduckgo_search como en el notebook "Is it a bird?" de Kaggle:

from duckduckgo_search import DDGS      # pip install duckduckgo_search

def search_images(term, max_images=200):
    print(f"Buscando '{term}'")
    with DDGS() as ddgs:
        return L(ddgs.images(term, max_results=max_images)).itemgot('image')

urls = search_images('grizzly bear photos', max_images=150)

Ese paquete cambió de API más de una vez (la función ddg_images del notebook original fue reemplazada por DDGS().images, y más tarde el proyecto pasó a publicarse con el nombre ddgs). Si el import falla, mirá el README del paquete instalado: la idea es la misma, una lista de URLs de imágenes a partir de un término de búsqueda.

Con menos de 150 imágenes por clase, sin dataset curado de antemano, ya alcanza para entrenar algo útil — el punto es que armar tu propio dataset desde cero no es el cuello de botella que parece. Un consejo del propio libro que sigue vigente: buscá términos variados por clase ("grizzly bear", "grizzly bear shadow", "grizzly bear sun") para que el dataset tenga la variedad que después va a aparecer en producción.

2. La DataBlock API, pieza por pieza

DataBlock es la pieza central de fastai: una plantilla que obliga a responder cuatro preguntas explícitas sobre cualquier dataset, en vez de escribir un Dataset/DataLoader de PyTorch a mano cada vez:

bears = DataBlock(
    blocks=(ImageBlock, CategoryBlock),
    get_items=get_image_files,
    splitter=RandomSplitter(valid_pct=0.2, seed=42),
    get_y=parent_label,
    item_tfms=Resize(128))
dls = bears.dataloaders(path)
dls.valid.show_batch(max_n=4, nrows=1)
  • blocks: define los tipos tensoriales de la variable independiente y dependiente (aquí, ImageBlock produce tensores de imagen flotantes, CategoryBlock produce enteros codificados One-Hot o Cross-Entropy targets).
  • get_items: la función de pipeline para indexar los archivos en disco.
  • splitter: separación determinista de Train/Validation fijando una semilla (seed) para garantizar reproducibilidad exacta.
  • get_y: la función de extracción de etiquetas (por ejemplo, el nombre de la carpeta contenedora con parent_label).

La física del pipeline: CPU vs. GPU

El diseño de DataBlock separa estrictamente dos etapas de cómputo con características de hardware opuestas:

  1. Etapa CPU (Ítem por ítem — item_tfms):
    En el disco, los archivos JPEG/PNG son secuencias comprimidas de bytes con dimensiones heterogéneas (1024×768, 500×800, etc.). La GPU no puede procesar tensores con formas variables: su arquitectura SIMD (Single Instruction, Multiple Data) requiere que todos los elementos del lote compartan exactamente las mismas dimensiones de memoria. Por eso, la CPU decodifica cada archivo comprimido, lo convierte en un arreglo RGB y ejecuta Resize o RandomResizedCrop(224). Recién cuando todas las matrices tienen tamaño uniforme [3,224,224], se apilan en memoria RAM fijada (*pinned host memory*) para formar el tensor homogéneo [64,3,224,224].
  2. Etapa GPU (Lote completo — batch_tfms):
    Una vez transferido el tensor a la VRAM mediante DMA, se ejecuta el Data Augmentation (rotación, traslación, escala, perspectiva, brillo). En vez de procesar píxel por píxel con loops de CPU, la GPU formula cada transformación como una matriz afín tridimensional A∈R3×3. Mediante interpolación bilineal paralelizada en hardware, los núcleos tensoriales recalculan las coordenadas de los millones de píxeles del batch en una fracción de milisegundo. Por esta razón física, hacer data augmentation en GPU es computacionalmente gratuito y no frena el training loop.
bears = bears.new(
    item_tfms=RandomResizedCrop(224, min_scale=0.5),
    batch_tfms=aug_transforms())
dls = bears.dataloaders(path)
dls.train.show_batch(max_n=8, nrows=2, unique=True)   # la misma imagen, 8 augmentations
La DataBlock API como pipeline: de una carpeta de archivos a batches en la GPU get_items lista los archivos; el splitter separa train y validación con semilla; get_y etiqueta cada ítem por el nombre de su carpeta; los blocks declaran imagen como entrada y categoría como salida; item_tfms corre en CPU imagen por imagen y deja todo del mismo tamaño; después se apila el batch; batch_tfms corre en GPU sobre el tensor entero y aplica las augmentations. El resultado son los DataLoaders. LAS CUATRO PREGUNTAS get_items get_image_files(path) ¿qué ítems hay? splitter RandomSplitter(0.2, seed=42) ¿cuáles son de validación? get_y parent_label: la carpeta ¿cuál es la etiqueta? blocks (ImageBlock, CategoryBlock) ¿de qué tipo es cada lado? CPU · ÍTEM POR ÍTEM item_tfms Resize(128) · RandomResizedCrop(224) deja todas las imágenes del mismo tamaño apilar el batch [64, 3, 224, 224] GPU · EL BATCH ENTERO batch_tfms aug_transforms(): flip, rotación, warp, brillo y contraste, casi gratis DataLoaders dls.train · dls.valid bears.new(item_tfms=…, batch_tfms=…) devuelve una copia de la plantilla con los cambios: las cuatro preguntas no se tocan las augmentations cambian la imagen sin cambiar su significado: más diversidad efectiva sin más datos reales, y una imagen distinta en cada época la misma plantilla sirve para texto, tabular o segmentación: cambian los blocks, no la estructura
DataBlock es una plantilla que obliga a contestar cuatro preguntas y después arma el pipeline sola. El orden importa: las transformaciones por ítem corren en CPU para poder apilar un batch rectangular; las del batch corren en GPU sobre el tensor entero, por eso la augmentation casi no cuesta.

3. Limpiar datos usando el propio modelo

La secuencia del libro invierte el orden intuitivo: en vez de limpiar todos los datos a mano antes de entrenar, entrená un modelo simple primero y usalo para encontrar los problemas del dataset.

learn = vision_learner(dls, resnet18, metrics=error_rate)
learn.fine_tune(4)

interp = ClassificationInterpretation.from_learner(learn)
interp.plot_confusion_matrix()
interp.plot_top_losses(5, nrows=1)

from fastai.vision.widgets import *
cleaner = ImageClassifierCleaner(learn)
cleaner   # el widget: elegís categoría y split, marcás cada imagen

# después de marcar: aplicar lo decidido
for idx in cleaner.delete(): cleaner.fns[idx].unlink()
for idx, cat in cleaner.change(): shutil.move(str(cleaner.fns[idx]), path/cat)

La matemática del Loss como detector de anomalías

¿Por qué un modelo todavía imperfecto es el mejor inspector de datos del mundo? La respuesta está en la función de pérdida de Cross-Entropy Loss:

L=-logP(yreal)

Supongamos que en el dataset descargado de internet, una foto de un oso grizzly salvaje en un río fue etiquetada por error como teddy (oso de peluche). Durante el forward pass, el backbone convolucional extrae bordes de garras, textura de pelaje mojado y colmillos; la cabeza proyecta esos rasgos y el Softmax asigna a la clase real asignada una probabilidad ínfima: P(teddy)≈0,0002. Al calcular la pérdida:

L=-log(0,0002)≈8,51

Una imagen normal bien clasificada tiene P(yreal)≈0,98⟹L≈0,02. ¡El ejemplo mal etiquetado genera una pérdida 400 veces mayor! Como el optimizador busca minimizar el error promedio, el modelo no memoriza casos aislados contradictorios si la mayoría de los datos son coherentes; en cambio, los "escupe" con un loss descomunal. interp.plot_top_losses() simplemente ordena la matriz de validación por L descendente, haciendo que el ruido de etiquetas y las imágenes corruptas floten instantáneamente a la superficie.

4. Out-of-domain data, domain shift y feedback loops

Una sección entera del capítulo se llama "How to Avoid Disaster", y su argumento es una verdad física ineludible: ningún modelo generaliza a distribuciones que violan las hipótesis estadísticas de su entrenamiento. En estadística y machine learning existen tres patologías formales que debés saber distinguir:

  • Covariate Shift (Cambio en las covariables):
    Ocurre cuando la distribución de las entradas cambia, pero la función objetivo subyacente se mantiene: Ptrain(X)≠Pprod(X), pero P(Y∣X) es idéntico. Por ejemplo: entrenaste clasificando fotos de stock nítidas y centradas de día, y en producción la cámara de seguridad envía video infrarrojo granulado de noche. Un oso sigue siendo un oso, pero la variedad geométrica y los valores de píxeles caen fuera del soporte sobre el cual se calcularon las convoluciones.
  • Concept Drift (Deriva del concepto):
    La relación causal entre la entrada y la salida cambia con el tiempo: Ptrain(Y∣X)≠Pprod(Y∣X). Por ejemplo: un clasificador de transacciones fraudulentas o un modelo de scoring financiero. Los patrones que hace un año indicaban solvencia o fraude hoy son usados deliberadamente por actores adversarios para evadir la detección.
  • Feedback Loops (Bucles de retroalimentación autodestructivos):
    Cuando las salidas emitidas por el modelo en el tiempo t condicionan o contaminan los datos que recolectás en el tiempo t+1. En LLMs, esto se conoce hoy como Model Collapse: si entrenás un modelo usando texto generado sintéticamente por otros LLMs, la cola de la distribución de probabilidad se recorta, la varianza de la atención colapsa y el modelo degenera en textos repetitivos e incoherentes.

La receta de mitigación en producción es siempre el Rollout Gradual:

  1. Fase Sombra (Shadow Deployment): El modelo corre en paralelo sobre tráfico real pero sus predicciones no ejecutan acciones; solo se registran métricas.
  2. Inspección Humana (Human-in-the-loop): Un operador revisa las predicciones donde la confianza del Softmax sea limítrofe (P<0,80).
  3. Monitoreo de Deriva: Alarmas automáticas que miden la divergencia de Kullback-Leibler (KL) o la distancia de Wasserstein entre los embeddings de producción y los del dataset de validación.

🔧 En tu proyecto

Tu gate anti-alucinación para artículos financieros es un caso de libro de domain shift: el lenguaje de los artículos, los nombres de empresas y los eventos macroeconómicos cambian mes a mes. Un umbral de similitud o perplejidad calibrado en el primer trimestre queda desfasado al siguiente. El monitoreo continuo de la perplejidad promedio de entrada es el sensor que te avisa cuándo reentrenar.

5. Cómo se servía en 2020 — y cómo se sirve hoy

El libro exporta el modelo completo (arquitectura + pesos + la definición del DataLoaders, incluidas las transforms) a un único archivo con learn.export(), y para inferencia usa load_learner(...) + predict. Hasta ahí, el flujo sigue vigente sin cambios:

# en el notebook de entrenamiento
learn.export('export.pkl')     # pickle con modelo + DataLoaders vacíos + transforms

# en otro proceso, sin GPU, sin el dataset
from fastai.vision.all import *
learn_inf = load_learner('export.pkl')
learn_inf.predict('images/grizzly.jpg')
learn_inf.dls.vocab               # ['black', 'grizzly', 'teddy']
→ ('grizzly', tensor(1), tensor([1.0e-06, 1.0e+00, 1.5e-07]))

Qué empaca realmente learn.export()

El archivo export.pkl no es un simple volcado de pesos; es un paquete autocontenido que resuelve todo el contrato de inferencia:

  • State Dict de Parámetros: Las matrices de pesos W y sesgos b aprendidas durante el fine-tuning.
  • Grafo de la Arquitectura: La definición de capas de PyTorch (Backbone ResNet + Head lineal adaptado).
  • Estadísticas de Normalización: Los vectores de media y desviación estándar (μ,σ∈R3) con los que se normalizan los canales RGB: xnorm=(x-μ)/σ. Si un modelo recibiera píxeles en [0,255] sin esta normalización exacta, los logits saldrían completamente degradados.
  • Vocabulario (dls.vocab): La lista ordenada de strings que mapea el índice entero del argmax a la etiqueta legible.

La física de la inferencia: por qué CPU supera a GPU con batch=1

Existe la creencia errónea de que producción siempre requiere GPUs costosas. La física de hardware dicta lo contrario para servicios de usuario:

  • Entrenamiento (B=64): La GPU domina porque miles de núcleos ALUs multiplican matrices en paralelo saturando los TFLOPs de la tarjeta.
  • Inferencia individual (B=1): Cada llamada a la GPU paga un peaje de latencia fija:
    • Copia de memoria Host-to-Device (RAM a VRAM vía bus PCIe: ~1–3 ms).
    • Overhead de lanzamiento de kernels CUDA en el driver (~5–10 μs por cada una de las 50 capas).
    • Sincronización y copia Device-to-Host para devolver el resultado a Python.

Un procesador moderno (x86 con instrucciones AVX-512/AMX o ARM NEON) procesa un ResNet-18 para una sola imagen en 5 a 12 milisegundos directamente en memoria caché L2/L3, sin peaje PCIe ni reserva de VRAM. Para clasificaciones visuales o extracción de features con tráfico moderado, servir en CPU es órdenes de magnitud más económico y ofrece menor latencia de cola (P99).

🟢 De fastai a producción extrema: TorchScript y ONNX Runtime

En sistemas industriales con miles de requests por segundo, se elimina el runtime de Python por completo:

  • TorchScript (torch.jit.trace): Convierte el modelo de PyTorch a un grafo C++ estático sin dependencias de Python.
  • ONNX Runtime: Formato abierto de intercambio que compila las operaciones matriciales a instrucciones nativas de hardware optimizadas para cada CPU/NPU.

6. El app.py de Gradio para Hugging Face Spaces

Un Space de Gradio es un repositorio git con tres archivos: el modelo exportado, un requirements.txt y un app.py. Este es el app.py completo para el clasificador de la Lección 1 (gato/perro), con la función custom incluida por lo que dice el callout de arriba:

# app.py
from fastai.vision.all import *
import gradio as gr

def is_cat(x): return x[0].isupper()      # la misma función que usó el DataBlock al exportar

learn = load_learner('export.pkl')
labels = learn.dls.vocab                    # [False, True] → los mostramos con nombre
names = {False: 'perro', True: 'gato'}

def predict(img):
    pred, idx, probs = learn.predict(PILImage.create(img))
    return dict(zip([names[l] for l in labels], map(float, probs)))

demo = gr.Interface(
    fn=predict,
    inputs=gr.Image(type='pil'),
    outputs=gr.Label(num_top_classes=2),
    examples=['gato.jpg', 'perro.jpg'],
    title='¿Gato o perro?')

demo.launch()
# requirements.txt (gradio ya viene con el SDK del Space; fastai trae torch)
fastai

Tres detalles que explican por qué está escrito así. gr.Label espera un diccionario {etiqueta: probabilidad} con floats de Python — por eso el dict(zip(labels, map(float, probs))): probs es un tensor y hay que convertir cada elemento. gr.Image(type='pil') le entrega a tu función una imagen PIL, que PILImage.create convierte al tipo que fastai espera. Y demo.launch() en un Space levanta el servidor en el puerto que HF espera; en tu máquina abre http://127.0.0.1:7860. Para publicar: creá el Space (SDK: Gradio), cloná el repo, copiá export.pkl, app.py, requirements.txt y las imágenes de ejemplo, y hacé push — el Space instala y arranca solo. Si export.pkl pesa más de unos MB, activá git-lfs para ese archivo (git lfs track "*.pkl").

⚠️ Versiones

El pickle guarda referencias a clases de fastai y de PyTorch; si el Space instala una versión muy distinta de la que usaste al exportar, la carga puede fallar. Cuando pase, fijá en requirements.txt las versiones que imprimió fastai.__version__ / torch.__version__ en el notebook donde entrenaste. Y como el Space es CPU, el load_learner ya deja el modelo en CPU por defecto (cpu=True): no hace falta ningún .cuda(), y si lo agregás, rompe.

Lo que el libro proponía con ipywidgets — un botón de upload, una salida con la predicción — es exactamente lo que gr.Interface genera solo a partir de la firma de predict. La API de Gradio de esta página es la de las versiones 4 y 5 (gr.Image, gr.Label como componentes de primer nivel); si ves gr.inputs.Image(shape=...) en algún tutorial viejo, es la API anterior a 2023.

7. Errores comunes

Los problemas de esta lección casi nunca aparecen en el entrenamiento — aparecen en los datos, o recién cuando el modelo sale del notebook.

SíntomaCausa probableQué hacer
dataloaders() o la primera época explotan con UnidentifiedImageError / OSError: image file is truncated.Descargaste con download_images y no corriste verify_images: hay archivos vacíos, HTML disfrazado de jpg, o descargas cortadas.failed = verify_images(get_image_files(path)); failed.map(Path.unlink) siempre, justo después de descargar.
Accuracy alta pero la matriz de confusión muestra que una clase casi nunca se predice.Clases desbalanceadas: 150 grizzlies, 150 negros, 30 teddies. El modelo aprende que "casi nunca es teddy" y la accuracy global lo premia.Mirá dls.train.items por clase antes de entrenar. Bajá a cantidades parecidas, buscá más imágenes de la clase chica, o mirá precision/recall por clase con interp.plot_confusion_matrix() en vez de la accuracy global.
En el notebook anda perfecto; con fotos del celular de un usuario real se equivoca seguido.Out-of-domain data: entrenaste con fotos de stock bien iluminadas y centradas; producción trae ángulos raros, noche, fondo distinto.Variá los términos de búsqueda al armar el dataset, agregá augmentations, y sobre todo: rollout gradual con humano revisando (§4). Recolectá los fallos reales y sumalos al dataset.
load_learner falla con AttributeError: Can't get attribute 'is_cat'.Exportaste con una función de etiquetado custom y el script de inferencia no la define.Definí la función con el mismo nombre en el script que carga (ver §5). O usá parent_label y organizá el dataset por carpetas.
Un Space o servidor de inferencia con GPU que cuesta plata y está el 99% del tiempo ocioso.Reflejo de "deep learning = GPU". Para predecir una imagen a la vez, la GPU no suma nada.Serví en CPU. La GPU se justifica en entrenamiento (paralelismo sobre batches grandes) o en inferencia con mucho throughput, no para un demo.
El Space arranca y muere con un error de deserialización del pickle.Versión de fastai/torch en el Space distinta de la del notebook donde exportaste.Fijá versiones en requirements.txt (§6). Si el modelo es viejo, reexportalo desde un entorno actual.
Las augmentations tardan una eternidad por época.Pusiste aug_transforms() en item_tfms (CPU, imagen por imagen) en vez de batch_tfms (GPU, batch entero).item_tfms solo para llevar todo al mismo tamaño; las augmentations van en batch_tfms.

8. Resumen

  1. Armar tu propio dataset desde cero (buscar con DuckDuckGo, descargar, verificar) no es el cuello de botella que parece — 150 imágenes por clase ya alcanzan para un modelo útil. La API de Bing del libro ya no existe.
  2. DataBlock formaliza cuatro preguntas (blocks, get_items, splitter, get_y) en vez de escribir Dataset/DataLoader a mano; item_tfms unifica tamaños en CPU, batch_tfms hace augmentation en GPU.
  3. La secuencia correcta es entrenar primero, después usar plot_top_losses y el cleaner para encontrar problemas en los datos — no limpiar todo a ciegas antes.
  4. Out-of-domain data, domain shift y feedback loops son la razón por la que ningún modelo se despliega de golpe a producción completa — rollout gradual con supervisión humana.
  5. learn.export() + load_learner siguen siendo el flujo de inferencia; el pickle no guarda tus funciones custom, así que el proceso que carga tiene que definirlas.
  6. El stack de deployment del libro (Voilà/Binder) es historia; hoy es Hugging Face Spaces + Gradio con un app.py de 15 líneas, servido en CPU, exactamente lo que ya viste en el Capítulo 9 de HF.

9. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. ¿Cuáles son las cuatro preguntas que responde un DataBlock, y con qué argumento cada una?

Qué tipos tienen entrada y salida (blocks), cómo listar los ítems (get_items), cómo separar train/valid (splitter) y cómo etiquetar cada ítem (get_y). Las transforms (item_tfms, batch_tfms) son extra, no una de las cuatro preguntas. (§2)

2. ¿Por qué el libro entrena antes de limpiar el dataset?

Porque un modelo entrenado con datos mayormente correctos aprende lo común y deja con loss alto a los ejemplos mal etiquetados o fuera de lugar: plot_top_losses y el cleaner los muestran directamente. Limpiar a mano 450 imágenes sin esa ayuda es más lento y menos sistemático. (§3)

3. ¿Qué diferencia hay entre out-of-domain data y domain shift?

Out-of-domain: datos de un tipo que el modelo nunca vio (osos de noche, en video, de espaldas). Domain shift: la distribución de producción cambia con el tiempo y va dejando obsoleto el training set (los clientes de la aseguradora). Ninguno se detecta de antemano con certeza; por eso el rollout gradual. (§4)

4. Exportaste con get_y=is_cat. En un script nuevo hacés load_learner('export.pkl') sin definir nada más. ¿Qué pasa?

AttributeError: Can't get attribute 'is_cat': pickle guardó el nombre y módulo de la función, no su código. Hay que definir is_cat en el script que carga, con el mismo nombre. (§5)

5. ¿Por qué predict en el app.py devuelve dict(zip(labels, map(float, probs))) y no probs directamente?

Porque gr.Label espera un diccionario etiqueta → probabilidad con floats de Python, y probs es un tensor de PyTorch. El map(float, ...) convierte cada elemento; el zip con el vocab le pone nombre a cada probabilidad. (§6)

6. ¿Qué pasa si ponés aug_transforms() en item_tfms en vez de batch_tfms?

Funciona, pero corre en CPU imagen por imagen, mucho más lento. batch_tfms aplica las augmentations sobre el batch entero en GPU, que es lo que las hace casi gratis. (§2, §7)

10. Para practicar

✏️ Ejercicio 1 — Tu propio dataset de tres clases

Elegí tres cosas que se confundan de verdad (tres razas de perro parecidas, tres tipos de hoja, tres modelos de auto) y armá el pipeline completo de §1 a §3. Mirá la matriz de confusión y los top losses antes y después de limpiar. Necesita internet para la búsqueda; entrenar con resnet18 y Resize(128) es viable en CPU con ~100 imágenes por clase, aunque lento.

from fastai.vision.all import *
from duckduckgo_search import DDGS

def search_images(term, max_images=100):
    with DDGS() as ddgs:
        return L(ddgs.images(term, max_results=max_images)).itemgot('image')

path = Path('mi_dataset')
for o in ['husky', 'malamute', 'samoyed']:
    (path/o).mkdir(parents=True, exist_ok=True)
    download_images(path/o, urls=search_images(f'{o} dog photo'))
failed = verify_images(get_image_files(path)); failed.map(Path.unlink)

dls = DataBlock(blocks=(ImageBlock, CategoryBlock), get_items=get_image_files,
                splitter=RandomSplitter(0.2, seed=42), get_y=parent_label,
                item_tfms=RandomResizedCrop(128, min_scale=0.5),
                batch_tfms=aug_transforms()).dataloaders(path, bs=32)
print({c: len((path/c).ls()) for c in dls.vocab})   # balance de clases
learn = vision_learner(dls, resnet18, metrics=error_rate)
learn.fine_tune(3)
interp = ClassificationInterpretation.from_learner(learn)
interp.plot_confusion_matrix(); interp.plot_top_losses(6, nrows=2)
Pista / qué esperar

Error_rate bastante peor que en gatos/perros (clases parecidas + dataset chico + imágenes de búsqueda con ruido). En los top losses vas a encontrar imágenes que no son perros, o que son de otra raza: eso es el dataset, no el modelo.

✏️ Ejercicio 2 — Romper y arreglar load_learner

Exportá cualquier learner entrenado con una función custom de etiquetado y cargalo desde un script separado, primero sin definir la función y después definiéndola. Corre en CPU en segundos.

# en el notebook, después de entrenar con get_y=is_cat (Lección 1)
learn.export('export.pkl')

# cargar.py — versión 1: sin is_cat
from fastai.vision.all import *
learn = load_learner('export.pkl')     # → AttributeError: Can't get attribute 'is_cat'

# cargar.py — versión 2: con is_cat definida antes
from fastai.vision.all import *
def is_cat(x): return x[0].isupper()
learn = load_learner('export.pkl')
print(learn.dls.vocab, next(learn.model.parameters()).device)
print(learn.predict('gato.jpg'))
Pista / qué esperar

La versión 2 imprime el vocab, device(type='cpu') (aunque hayas entrenado en GPU) y la predicción. Ese es el script que después se convierte en app.py.

✏️ Ejercicio 3 — El app.py en local antes que en el Space

Con pip install gradio, guardá el app.py de §6 junto a tu export.pkl y corrélo con python app.py. Abrí la URL local, subí una foto, y después probá una imagen claramente fuera de dominio (un dibujo, una captura de pantalla). Recién cuando funcione en local, subilo a un Space.

Pista / qué esperar

La interfaz muestra las dos barras de probabilidad. Con la imagen fuera de dominio vas a ver que el modelo igual se decide, a veces con alta confianza — ese es el argumento de §4 hecho visible, y una buena razón para agregar un umbral de confianza en predict antes de mostrar el resultado.

11. Fuentes y para seguir