En tancar el mòdul 6 vam deixar la caixa d'eines completa: sabem entrenar amb Keras, controlar l'entrenament amb callbacks, muntar pipelines amb tf.data i empaquetar models per a producció. Toca complir la primera promesa pendent des del mòdul 3: construir, de cap a cap, el classificador d'imatges de producte de TecnoMarket. En aquesta lliçó desenvoluparàs un projecte complet — exploració de dades, pipeline, model, entrenament, avaluació, anàlisi d'errors, millora iterativa i lliurament — seguint la metodologia del curs: prototip amb un dataset públic (CIFAR-10) i trasllat al cas fictici de TecnoMarket.

Contingut

  1. Enunciat del projecte i pla de treball
  2. Fase 1: exploració del dataset
  3. Fase 2: pipeline de dades amb tf.data i augment de dades
  4. Fase 3: el model — una mini-VGG amb BN i dropout
  5. Fase 4: entrenament amb callbacks i TensorBoard
  6. Fase 5: avaluació — matriu de confusió per classes
  7. Fase 6: anàlisi visual d'errors i millora iterativa
  8. Fase 7: lliurament — desat i connexió amb la cua de revisió

Enunciat del projecte i pla de treball

Context de negoci. Els venedors de TecnoMarket pugen fotos en donar d'alta un article i avui trien la categoria a mà; el 12 % de les altes acaba mal classificada i perjudica el cercador. Volem un model que proposi la categoria automàticament a partir de la foto, amb la salvaguarda que vam dissenyar a 03-04: si la confiança no supera un llindar, l'alta va a la cua de revisió humana.

Substitut. Com que (encara) no tenim el dataset real de TecnoMarket, prototipem amb CIFAR-10: 60 000 imatges 32×32 en 10 classes (avió, cotxe, ocell, gat, cérvol, gos, granota, cavall, vaixell, camió). Deu classes fan de substitut de deu categories de producte; tot el pipeline serà transplantable quan arribin les fotos reals.

Pla per fases (el mateix esquelet que farem servir en tots els projectes del mòdul):

Fase Lliurable
Exploració Conèixer mides, classes, balanç
Pipeline tf.data amb augment de dades integrat
Model CNN pròpia (mini-VGG de 03-03 + BN/dropout de 05-04)
Entrenament Callbacks (06-01), corbes, TensorBoard
Avaluació Exactitud en test + matriu de confusió interpretada
Millora Iteracions guiades pel diagnòstic
Lliurament Model+preprocessament desats (06-05) + llindar de confiança (03-04)

Fase 1: exploració del dataset

No entrenis mai sobre dades que no has mirat. Treballem a tecnomarket-dl/notebooks/07_01_classificador.ipynb (estructura de projecte de 06-04):

import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt

tf.random.set_seed(42)  # reproduïbilitat, com vam fixar a 06-04
np.random.seed(42)

(x_train, y_train), (x_test, y_test) = tf.keras.datasets.cifar10.load_data()

CLASSES = ["avio", "cotxe", "ocell", "gat", "cervol",
           "gos", "granota", "cavall", "vaixell", "camio"]

print(x_train.shape, x_test.shape)   # (50000, 32, 32, 3) (10000, 32, 32, 3)
print(np.bincount(y_train.flatten())) # 5000 per classe: dataset balancejat

Punts que verifiquem i per què importen:

  • Forma: 32×32×3 — imatges en color, molt petites. La nostra CNN ha d'estar dimensionada per a això (la fórmula de mides de sortida de 03-02 ens dirà quants blocs hi caben abans de quedar-nos sense píxels).
  • Balanç: 5 000 imatges per classe. Això legitima fer servir l'exactitud com a mètrica principal; a 07-03 veurem què passa quan no hi ha balanç.
  • Inspecció visual: pinta una graella de 25 imatges amb la seva etiqueta. Veuràs que a 32×32 fins i tot a un humà li costa distingir un gat d'un gos — calibra expectatives.
plt.figure(figsize=(8, 8))
for i in range(25):
    plt.subplot(5, 5, i + 1)
    plt.imshow(x_train[i])
    plt.title(CLASSES[int(y_train[i])], fontsize=8)
    plt.axis("off")
plt.tight_layout()

Fase 2: pipeline de dades amb tf.data i augment de dades

Apliquem el patró map → shuffle → batch → prefetch de 06-01, i hi afegim l'augment de dades amb capes de Keras que vam veure a 05-04. Reservem a més un conjunt de validació separat del test:

# Separar validació (últimes 5000 del train) — el test queda congelat (06-05)
x_val, y_val = x_train[45000:], y_train[45000:]
x_train, y_train = x_train[:45000], y_train[:45000]

BATCH = 64
AUTOTUNE = tf.data.AUTOTUNE

# Augment de dades com a capes (05-04): només actua en entrenament
augment = tf.keras.Sequential([
    tf.keras.layers.RandomFlip("horizontal"),
    tf.keras.layers.RandomTranslation(0.1, 0.1),
    tf.keras.layers.RandomZoom(0.1),
], name="augment")

def preparar(x, y):
    return tf.cast(x, tf.float32) / 255.0, y   # normalització a [0, 1]

train_ds = (tf.data.Dataset.from_tensor_slices((x_train, y_train))
            .map(preparar, num_parallel_calls=AUTOTUNE)
            .shuffle(10_000)
            .batch(BATCH)
            .map(lambda x, y: (augment(x, training=True), y),
                 num_parallel_calls=AUTOTUNE)
            .prefetch(AUTOTUNE))

val_ds = (tf.data.Dataset.from_tensor_slices((x_val, y_val))
          .map(preparar).batch(BATCH).prefetch(AUTOTUNE))

test_ds = (tf.data.Dataset.from_tensor_slices((x_test, y_test))
           .map(preparar).batch(BATCH).prefetch(AUTOTUNE))

Detalls que marquen la diferència:

  • L'augment va després del batch i amb training=True explícit: s'aplica a lots complets i només al pipeline d'entrenament. Validació i test veuen les imatges originals, com exigeix la validació honesta de 06-05.
  • Capgirament horitzontal sí, vertical no: un cotxe cap per avall no és una foto de catàleg realista. L'augment ha de generar variacions plausibles (criteri de 05-04).
  • La normalització (/255) viu a preparar; a la fase de lliurament la posarem dins del model perquè hi viatgi (06-05).

Fase 3: el model — una mini-VGG amb BN i dropout

Reprenem la mini-VGG que vam construir a 03-03 (blocs Conv-Conv-Pool amb filtres que es dupliquen) i hi incorporem les millores de 05-04: BatchNormalization després de cada convolució i Dropout creixent:

from tensorflow.keras import layers, models

def bloc_conv(x, filtres, dropout):
    for _ in range(2):
        x = layers.Conv2D(filtres, 3, padding="same", use_bias=False)(x)
        x = layers.BatchNormalization()(x)
        x = layers.Activation("relu")(x)
    x = layers.MaxPooling2D()(x)
    return layers.Dropout(dropout)(x)

entrades = layers.Input(shape=(32, 32, 3))
x = bloc_conv(entrades, 32, 0.2)      # 32x32 -> 16x16
x = bloc_conv(x, 64, 0.3)             # 16x16 -> 8x8
x = bloc_conv(x, 128, 0.4)            # 8x8   -> 4x4
x = layers.Flatten()(x)
x = layers.Dense(128, activation="relu")(x)
x = layers.Dropout(0.5)(x)
sortides = layers.Dense(10, activation="softmax")(x)

model = models.Model(entrades, sortides, name="tecnomarket_cnn_v1")
model.summary()   # ~1.2 M de paràmetres

Decisions explicades:

  • API funcional (introduïda a 03-03): ens permet factoritzar el bloc en una funció i facilita futures cirurgies (extreure embeddings com a 03-04, per exemple).
  • use_bias=False abans de la BN: la BatchNormalization ja aporta el seu propi desplaçament; el biaix de la convolució seria redundant.
  • Dropout creixent (0.2 → 0.5): les capes profundes, amb més paràmetres, necessiten més regularització (05-04).
  • Tres blocs de pooling porten de 32×32 a 4×4 — amb la fórmula de 03-02 es comprova que un quart bloc deixaria mapes de 2×2, massa pobres.

Fase 4: entrenament amb callbacks i TensorBoard

Compilem amb Adam i entropia creuada (02-04) i entrenem amb els callbacks de 06-01:

model.compile(optimizer=tf.keras.optimizers.Adam(1e-3),
              loss="sparse_categorical_crossentropy",
              metrics=["accuracy"])

callbacks = [
    tf.keras.callbacks.EarlyStopping(monitor="val_accuracy", patience=8,
                                     restore_best_weights=True),
    tf.keras.callbacks.ModelCheckpoint("models/cnn_v1_millor.keras",
                                       monitor="val_accuracy",
                                       save_best_only=True),
    tf.keras.callbacks.ReduceLROnPlateau(monitor="val_loss", factor=0.5,
                                         patience=3, min_lr=1e-5),
    tf.keras.callbacks.TensorBoard(log_dir="logs/cnn_v1"),
]

historial = model.fit(train_ds, validation_data=val_ds,
                      epochs=60, callbacks=callbacks)

Mentre entrena (en una GPU modesta, ~15-25 min; en CPU, compta amb més d'una hora), obre TensorBoard com a 06-04 (tensorboard --logdir logs) i vigila:

  • Corbes train/val juntes i pujant: gràcies a l'augment de dades i al dropout, la bretxa entre entrenament i validació es manté petita (sense ells, aquesta mateixa xarxa es dispara al 99 % en train i s'estanca en ~72 % en validació — sobreajust de llibre, 05-04).
  • Esglaons en baixar el learning rate: ReduceLROnPlateau sol provocar una millora visible d'1-2 punts quan actua.

Resultat típic honest: la validació s'estanca entre el 82 % i el 85 % cap a l'època 40-50. No esperis més d'una CNN pròpia entrenada des de zero sobre 32×32; ja està bé.

Fase 5: avaluació — matriu de confusió per classes

L'exactitud global no és suficient per a una decisió de negoci: hem de saber què confon el model.

from sklearn.metrics import confusion_matrix, classification_report

test_loss, test_acc = model.evaluate(test_ds)
print(f"Exactitud en test: {test_acc:.3f}")   # típic: 0.82 - 0.85

y_prob = model.predict(test_ds)
y_pred = np.argmax(y_prob, axis=1)

print(classification_report(y_test.flatten(), y_pred, target_names=CLASSES))
cm = confusion_matrix(y_test.flatten(), y_pred)

Interpretació típica de la matriu (els números exactes variaran):

Confusió freqüent Lectura Anàleg a TecnoMarket
gat ↔ gos Animals petits, textures semblants a 32×32 «auriculars» ↔ «cascs gaming»
avió ↔ vaixell Fons blaus dominen la imatge productes fotografiats sobre el mateix fons
cotxe ↔ camió Mateixa família semàntica «mòbil» ↔ «tauleta»
granota, vaixell Classes amb >90 % d'encert categories visualment distintives

La lliçó de negoci: els errors no es reparteixen uniformement. Les parelles confusibles són exactament on el llindar de confiança de 03-04 haurà de treballar més.

Fase 6: anàlisi visual d'errors i millora iterativa

Abans de tocar res, mira les errades amb els teus ulls:

errors = np.where(y_pred != y_test.flatten())[0]
plt.figure(figsize=(10, 6))
for i, idx in enumerate(errors[:15]):
    plt.subplot(3, 5, i + 1)
    plt.imshow(x_test[idx])
    plt.title(f"real: {CLASSES[int(y_test[idx])]}\npred: {CLASSES[y_pred[idx]]}"
              f" ({y_prob[idx].max():.2f})", fontsize=7)
    plt.axis("off")
plt.tight_layout()

Hi veuràs tres famílies d'errors: imatges genuïnament ambigües (ni tu l'encertes), errors amb confiança baixa (el llindar els atraparà) i errors amb confiança alta (els perillosos). Amb aquest diagnòstic, itera en aquest ordre — del més barat al més car:

  1. Més èpoques / paciència: si les corbes encara pujaven en aturar-se, és millora gratis.
  2. Ajustar l'augment de dades: si hi ha bretxa train-val, intensifica'l; si el train no arriba a bon nivell, suavitza'l (l'augment excessiu també fa mal, 05-04).
  3. Ajustar la regularització: moure el dropout ±0.1 segons la bretxa.
  4. Capacitat: un quart bloc convolucional o més filtres — només si el train s'ha quedat curt.
  5. Canviar d'arquitectura: la porta que obrirem a 07-05.

Regla d'or del mòdul: un canvi per iteració, amb llavor fixa (06-04), o no sabràs què va causar què.

Fase 7: lliurament — desat i connexió amb la cua de revisió

Seguint 06-05, empaquetem model + preprocessament com una unitat, perquè l'API no es pugui desincronitzar de l'entrenament:

# Model d'inferència: normalització dins + probabilitats fora
entrada = layers.Input(shape=(32, 32, 3), dtype=tf.uint8)
x = layers.Rescaling(1.0 / 255)(tf.cast(entrada, tf.float32))
prob = model(x, training=False)
model_servei = models.Model(entrada, prob)
model_servei.save("models/classificador_categories_v1.keras")

I apliquem la política de 03-04 amb el llindar de confiança:

LLINDAR = 0.80  # calibrat sobre validació, mai sobre test

def classificar_alta(imatge_uint8):
    p = model_servei.predict(imatge_uint8[np.newaxis, ...], verbose=0)[0]
    if p.max() >= LLINDAR:
        return {"categoria": CLASSES[int(p.argmax())], "auto": True}
    return {"categoria": None, "auto": False, "desti": "cua_revisio",
            "suggeriments": [CLASSES[i] for i in p.argsort()[-3:][::-1]]}

Amb el llindar a 0.80, un resultat típic és que el model resol automàticament al voltant del 70 % de les altes amb una exactitud sobre aquest subconjunt propera al 92-94 %, i deriva la resta a revisió humana amb les tres categories més probables com a suggeriment — humans i model col·laborant, com vam dissenyar a 03-04. El servei s'exposaria amb la mateixa plantilla FastAPI de 06-05.

Errors Comuns i Consells

  • Aplicar l'augment de dades també a validació/test: infla artificialment la dificultat i et fa prendre males decisions. L'augment només viu a train_ds.
  • Triar el llindar de confiança mirant el test: el test és el conjunt congelat (06-05); si el fas servir per calibrar, ja no mesura res. Llindar i ajustos, sempre amb validació.
  • Canviar tres coses alhora entre iteracions: no sabràs què va funcionar. Un canvi, una llavor fixa, una comparació.
  • Frustrar-se per no passar del 85 %: és el sostre raonable d'aquest enfocament amb aquest dataset. La millora gran no vindrà d'insistir, sinó de canviar d'estratègia (07-05).
  • Oblidar restore_best_weights=True: sense això, l'EarlyStopping et deixa els pesos de l'última època, no els millors.

Exercicis

  1. Afegeix un quart bloc convolucional de 256 filtres (ajustant el dropout) i compara exactitud i temps d'entrenament amb la versió de tres blocs. Surt a compte?
  2. Genera la llista de les 20 prediccions errònies amb més confiança. Quines parelles de classes hi apareixen? Proposa un canvi d'augment de dades dirigit a elles.
  3. Calcula, per a llindars 0.6, 0.7, 0.8 i 0.9 sobre el conjunt de validació, quin percentatge d'imatges s'automatitza i quina exactitud té aquest subconjunt. Presenta la taula i tria llindar justificant el compromís.

Solucions

  1. Substitueix l'última crida per x = bloc_conv(x, 256, 0.4) després del bloc de 128 (els mapes passen de 4×4 a 2×2, just al límit). Resultat típic: +0.5-1 punt d'exactitud a canvi de ~1.5× temps per època. Surt a compte només si aquest punt importa; documenta totes dues xifres.
  2. idx = errors[np.argsort(-y_prob[errors].max(axis=1))[:20]] i pinta com a la fase 6. Solen dominar gat↔gos i cotxe↔camió. Canvi dirigit: RandomContrast(0.2) o retalls aleatoris (RandomCrop amb padding) que forcin la xarxa a fixar-se en la forma i no en el fons.
  3. Per a cada llindar u: mask = p_val.max(axis=1) >= u; cobertura = mask.mean(); exactitud automatitzada = (pred_val[mask] == y_val_flat[mask]).mean(). Taula típica: 0.6 → 85 %/89 %; 0.7 → 78 %/91 %; 0.8 → 70 %/93 %; 0.9 → 55 %/96 %. La tria depèn del cost d'un error davant del cost de revisar: per a altes de catàleg, 0.8 sol ser un bon compromís.

Conclusió

Has lliurat el primer projecte complet de TecnoMarket: un classificador de categories amb pipeline tf.data, augment de dades, una mini-VGG regularitzada, entrenament controlat per callbacks, avaluació amb matriu de confusió, anàlisi d'errors i un empaquetament a punt per servir darrere d'un llindar de confiança amb cua de revisió. El resultat — 82-85 % en test — és honest per a una CNN entrenada des de zero, i a 07-05 el batrem amb transfer learning gairebé sense escriure més codi. Abans, canviem de modalitat: a la propera lliçó complim la promesa de 04-04 i construïm un generador de text caràcter a caràcter per als esborranys de descripcions de producte de TecnoMarket.

Curs de Deep Learning

Mòdul 1: Introducció al Deep Learning

Mòdul 2: Fonaments de Xarxes Neuronals

Mòdul 3: Xarxes Neuronals Convolucionals (CNN)

Mòdul 4: Xarxes Neuronals Recurrents (RNN)

Mòdul 5: Tècniques Avançades en Deep Learning

Mòdul 6: Eines i Frameworks

Mòdul 7: Projectes Pràctics

Mòdul 8: Consideracions Ètiques i Futur del Deep Learning

© Copyright 2026. Tots els drets reservats