Phase 02 - Lesson 17

Manejo de datos desequilibrados

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Cuando el 99% de sus datos son "normales", la precisión es una mentira.

Tipo: Construcción Idioma: Python Requisitos previos: Fase 2, Lecciones 01-09 (especialmente métricas de evaluación) Tiempo: ~90 minutos

Objetivos de aprendizaje

  • Implemente SMOTE desde cero y explique en qué se diferencia el sobremuestreo sintético de la duplicación aleatoria.
  • Evalúe clasificadores desequilibrados utilizando F1, AUPRC y el coeficiente de correlación de Matthews en lugar de precisión
  • Compare estrategias de ponderación de clase, ajuste de umbral y remuestreo y seleccione el enfoque correcto para una relación de desequilibrio determinada
  • Cree una canalización de datos desequilibrada completa que combine SMOTE, ponderaciones de clases y optimización de umbrales

El problema

Usted construye un modelo de detección de fraude. Obtiene una precisión del 99,9%. Lo celebras. Entonces te das cuenta de que predice "no fraude" para cada transacción.

Esto no es un error. Es lo racional cuando sólo el 0,1% de las transacciones son fraudulentas. El modelo aprende que adivinar siempre la clase mayoritaria minimiza el error general. Es técnicamente correcto y completamente inútil.

Esto sucede en todos los lugares donde la clasificación real importa. Diagnóstico de enfermedades: tasa positiva del 1%. Intrusión en la red: 0,01% ataques. Defectos de fabricación: 0,5% defectuosos. Filtrado de spam: 20% spam. Predicción de abandono: 5% de abandonos. Cuanto más importante es la clase minoritaria, más rara tiende a ser.

La precisión falla porque trata todas las predicciones correctas por igual. Etiquetar correctamente una transacción legítima y detectar correctamente el fraude cuentan como un punto de precisión. Pero detectar el fraude es la única razón por la que existe el modelo. Necesitamos métricas, técnicas y estrategias de entrenamiento que obliguen al modelo a prestar atención a la clase poco común pero importante.

El concepto

Por qué falla la precisión

Considere un conjunto de datos con 1000 muestras: 990 negativas, 10 positivas. Un modelo que siempre predice negativo:

Positivo previsto Negativo previsto
Realmente Positivo 0 (TP) 10 (FN)
Realmente Negativo 0 (FP) 990 (TN)

Precisión = (0 + 990) / 1000 = 99,0%

El modelo detecta cero fraude. Enfermedad cero. Cero defectos. Pero la precisión dice 99%. Por eso la precisión es peligrosa en problemas de desequilibrio.

Mejores métricas

Precisión = TP / (TP + FP). De todo lo señalado como positivo, ¿cuántos realmente lo son? Una alta precisión significa pocas falsas alarmas.

Recordar = TP / (TP + FN). De todo lo realmente positivo, ¿cuántos detectamos? Un alto recuerdo significa que se pasan por alto pocos aspectos positivos.

F1 Puntuación = 2 * precisión * recuperación / (precisión + recuperación). La media armónica. Penaliza el desequilibrio extremo entre precisión y recuperación más de lo que lo haría la media aritmética.

Puntuación F-beta = (1 + beta^2) * precisión * recuperación / (beta^2 * precisión + recuperación). Cuando beta > 1, la recuperación importa más. Cuando beta <1, la precisión importa más. F2 es común en la detección de fraude (no detectar un fraude es peor que una falsa alarma).

AUPRC (Área bajo curva de recuperación de precisión). Como AUC-ROC pero más informativo para datos desequilibrados. Un clasificador aleatorio tiene AUPRC igual a la tasa de clase positiva (no 0,5 como ROC). Esto hace que las mejoras sean más fáciles de ver.

Coeficiente de correlación de Matthews = (TP * TN - FP * FN) / sqrt((TP+FP)(TP+FN)(TN+FP)(TN+FN)). Varía de -1 a +1. Sólo otorga una puntuación alta cuando al modelo le va bien en ambas clases. Equilibrado incluso cuando las clases son de muy diferentes tamaños.

Para el modelo anterior "predecir siempre negativo": precisión = 0/0 (indefinida, a menudo establecida en 0), recuperación = 0/10 = 0, F1 = 0, MCC = 0. Estas métricas identifican correctamente el modelo como sin valor.

Los datos desequilibrados Pipeline

flowchart TD
    A[Imbalanced Dataset] --> B{Imbalance Ratio?}
    B -->|Mild: 80/20| C[Class Weights]
    B -->|Moderate: 95/5| D[SMOTE + Threshold Tuning]
    B -->|Severe: 99/1| E[SMOTE + Class Weights + Threshold]
    C --> F[Train Model]
    D --> F
    E --> F
    F --> G[Evaluate with F1 / AUPRC / MCC]
    G --> H{Good Enough?}
    H -->|No| I[Try Different Strategy]
    H -->|Yes| J[Deploy with Monitoring]
    I --> B

SMOTE: Técnica de sobremuestreo de minoría sintética

El sobremuestreo aleatorio duplica muestras minoritarias existentes. Esto funciona, pero corre el riesgo de sobreajustarse porque el modelo ve puntos idénticos repetidamente.

SMOTE crea nuevas muestras minoritarias sintéticas que son plausibles pero no copias. El algoritmo:

  1. Para cada muestra minoritaria x, encuentre sus k vecinos más cercanos entre otras muestras minoritarias
  2. Elige un vecino al azar
  3. Cree una nueva muestra en el segmento de línea entre x y ese vecino.

La fórmula: new_sample = x + random(0, 1) * (neighbor - x)

Esto interpola entre puntos minoritarios reales, creando muestras en la misma región del espacio de características sin simplemente copiar los datos existentes.

flowchart LR
    subgraph Original["Original Minority Points"]
        P1["x1 (1.0, 2.0)"]
        P2["x2 (1.5, 2.5)"]
        P3["x3 (2.0, 1.5)"]
    end
    subgraph SMOTE["SMOTE Generation"]
        direction TB
        S1["Pick x1, neighbor x2"]
        S2["random t = 0.4"]
        S3["new = x1 + 0.4*(x2-x1)"]
        S4["new = (1.2, 2.2)"]
        S1 --> S2 --> S3 --> S4
    end
    Original --> SMOTE
    subgraph Result["Augmented Set"]
        R1["x1 (1.0, 2.0)"]
        R2["x2 (1.5, 2.5)"]
        R3["x3 (2.0, 1.5)"]
        R4["synthetic (1.2, 2.2)"]
    end
    SMOTE --> Result

Estrategias de muestreo comparadas

Sobremuestreo aleatorio: duplicar muestras minoritarias para que coincidan con el recuento mayoritario.

  • Ventajas: simple, sin pérdida de inentrenamiento
  • Contras: los duplicados exactos causan sobreajuste, aumentan el tiempo de entrenamiento

Submuestreo aleatorio: elimine las muestras mayoritarias para que coincidan con el recuento de las minorías.

  • Ventajas: entrenamiento rápido, sencillo.
  • Contras: descarta datos mayoritarios potencialmente útiles, mayor variación

SMOTE: crea muestras minoritarias sintéticas mediante interpolación.

  • Ventajas: genera nuevos puntos de datos, reduce el sobreajuste en comparación con el sobremuestreo aleatorio
  • Contras: puede crear muestras ruidosas cerca del límite de decisión, no tiene en cuenta la distribución de clases mayoritaria
Estrategia Datos modificados Riesgo Cuándo utilizar
Sobremuestreo Minoría duplicada Sobreajuste Conjuntos de datos pequeños, desequilibrio moderado
Submuestreo Mayoría eliminada Pérdida de inentrenamiento Grandes conjuntos de datos, quieren entrenamiento rápido
SMOTE Se añadió minoría sintética Ruido límite Desequilibrio moderado, suficientes muestras minoritarias para k-NN

Pesos de clase

En lugar de cambiar los datos, cambie la forma en que el modelo trata los errores. Asignar mayor peso a la clasificación errónea de la clase minoritaria.

Para un problema binario con 950 muestras negativas y 50 positivas:

  • Peso para clase negativa = n_muestras / (2 * n_negativo) = 1000 / (2 * 950) = 0,526
  • Peso para clase positiva = n_muestras / (2 * n_positivo) = 1000 / (2 * 50) = 10,0

La clase positiva obtiene 19 veces más peso. Clasificar erróneamente una muestra positiva cuesta tanto como clasificar erróneamente 19 muestras negativas. El modelo se ve obligado a prestar atención a la clase minoritaria.

En regresión logística, esto modifica la función de pérdida:

weighted_loss = -sum(w_i * [y_i * log(p_i) + (1-y_i) * log(1-p_i)])

donde w_i depende de la clase de muestra i.

Las ponderaciones de clase son matemáticamente equivalentes al sobremuestreo esperado, pero sin crear nuevos puntos de datos. Esto los hace más rápidos y evita el riesgo de sobreajuste de muestras duplicadas.

Ajuste de umbral

La mayoría de los clasificadores generan una probabilidad. El umbral predeterminado es 0,5: si P(positivo) >= 0,5, predice positivo. Pero 0,5 es arbitrario. Cuando las clases están desequilibradas, el umbral óptimo suele ser mucho más bajo.

El proceso:

  1. Entrena un modelo
  2. Obtenga probabilidades previstas en el conjunto de validación.
  3. Umbrales de barrido de 0,0 a 1,0
  4. Calcule F1 (o la métrica elegida) en cada umbral
  5. Elija el umbral que maximice su métrica
flowchart LR
    A[Model] --> B[Predict Probabilities]
    B --> C[Sweep Thresholds 0.0 to 1.0]
    C --> D[Compute F1 at Each]
    D --> E[Pick Best Threshold]
    E --> F[Use in Production]

Un modelo podría generar P(fraude) = 0,15 para una transacción fraudulenta. En el umbral de 0,5, esto se clasifica como no fraude. En el umbral 0,10, se detecta correctamente. La calibración de la probabilidad importa menos que la clasificación: siempre que el fraude tenga mayores probabilidades que el no fraude, existe un umbral que los separa.

Aprendizaje sensible a los costos

Generalización de pesos de clase. En lugar de costos uniformes, asigne costos de clasificación errónea específicos:

Predecir Positivo Predecir negativo
Realmente Positivo 0 (correcto) C_FN = 100
Realmente Negativo C_FP = 1 0 (correcto)

Pasar por alto una transacción fraudulenta (FN) cuesta 100 veces más que una falsa alarma (FP). El modelo optimiza el costo total, no el recuento total de errores.

Este es el enfoque más basado en principios cuando se pueden estimar los costos del mundo real. Un diagnóstico de cáncer omitido tiene un costo muy diferente al de una falsa alarma que conduce a una biopsia adicional. Hacer explícitos estos costos obliga a realizar las compensaciones correctas.

Diagrama de flujo de decisión

flowchart TD
    A[Start: Imbalanced Dataset] --> B{How imbalanced?}
    B -->|"< 70/30"| C["Mild: try class weights first"]
    B -->|"70/30 to 95/5"| D["Moderate: SMOTE + class weights"]
    B -->|"> 95/5"| E["Severe: combine multiple strategies"]
    C --> F{Enough data?}
    D --> F
    E --> F
    F -->|"< 1000 samples"| G["Oversample or SMOTE, avoid undersampling"]
    F -->|"1000-10000"| H["SMOTE + threshold tuning"]
    F -->|"> 10000"| I["Undersampling OK, or class weights"]
    G --> J[Train + Evaluate with F1/AUPRC]
    H --> J
    I --> J
    J --> K{Recall high enough?}
    K -->|No| L[Lower threshold]
    K -->|Yes| M{Precision acceptable?}
    M -->|No| N[Raise threshold or add features]
    M -->|Yes| O[Ship it]

Constrúyelo

Paso 1: generar un conjunto de datos desequilibrado

import numpy as np


def make_imbalanced_data(n_majority=950, n_minority=50, seed=42):
    rng = np.random.RandomState(seed)

    X_maj = rng.randn(n_majority, 2) * 1.0 + np.array([0.0, 0.0])
    X_min = rng.randn(n_minority, 2) * 0.8 + np.array([2.5, 2.5])

    X = np.vstack([X_maj, X_min])
    y = np.concatenate([np.zeros(n_majority), np.ones(n_minority)])

    shuffle_idx = rng.permutation(len(y))
    return X[shuffle_idx], y[shuffle_idx]

Paso 2: SMOTE desde cero

def euclidean_distance(a, b):
    return np.sqrt(np.sum((a - b) ** 2))


def find_k_neighbors(X, idx, k):
    distances = []
    for i in range(len(X)):
        if i == idx:
            continue
        d = euclidean_distance(X[idx], X[i])
        distances.append((i, d))
    distances.sort(key=lambda x: x[1])
    return [d[0] for d in distances[:k]]


def smote(X_minority, k=5, n_synthetic=100, seed=42):
    rng = np.random.RandomState(seed)
    n_samples = len(X_minority)
    k = min(k, n_samples - 1)
    synthetic = []

    for _ in range(n_synthetic):
        idx = rng.randint(0, n_samples)
        neighbors = find_k_neighbors(X_minority, idx, k)
        neighbor_idx = neighbors[rng.randint(0, len(neighbors))]
        t = rng.random()
        new_point = X_minority[idx] + t * (X_minority[neighbor_idx] - X_minority[idx])
        synthetic.append(new_point)

    return np.array(synthetic)

Paso 3: sobremuestreo y submuestreo aleatorio

def random_oversample(X, y, seed=42):
    rng = np.random.RandomState(seed)
    classes, counts = np.unique(y, return_counts=True)
    max_count = counts.max()

    X_resampled = list(X)
    y_resampled = list(y)

    for cls, count in zip(classes, counts):
        if count < max_count:
            cls_indices = np.where(y == cls)[0]
            n_needed = max_count - count
            chosen = rng.choice(cls_indices, size=n_needed, replace=True)
            X_resampled.extend(X[chosen])
            y_resampled.extend(y[chosen])

    X_out = np.array(X_resampled)
    y_out = np.array(y_resampled)
    shuffle = rng.permutation(len(y_out))
    return X_out[shuffle], y_out[shuffle]


def random_undersample(X, y, seed=42):
    rng = np.random.RandomState(seed)
    classes, counts = np.unique(y, return_counts=True)
    min_count = counts.min()

    X_resampled = []
    y_resampled = []

    for cls in classes:
        cls_indices = np.where(y == cls)[0]
        chosen = rng.choice(cls_indices, size=min_count, replace=False)
        X_resampled.extend(X[chosen])
        y_resampled.extend(y[chosen])

    X_out = np.array(X_resampled)
    y_out = np.array(y_resampled)
    shuffle = rng.permutation(len(y_out))
    return X_out[shuffle], y_out[shuffle]

Paso 4: Regresión logística con ponderaciones de clases

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))


def logistic_regression_weighted(X, y, weights, lr=0.01, epochs=200):
    n_samples, n_features = X.shape
    w = np.zeros(n_features)
    b = 0.0

    for _ in range(epochs):
        z = X @ w + b
        pred = sigmoid(z)
        error = pred - y
        weighted_error = error * weights

        gradient_w = (X.T @ weighted_error) / n_samples
        gradient_b = np.mean(weighted_error)

        w -= lr * gradient_w
        b -= lr * gradient_b

    return w, b


def compute_class_weights(y):
    classes, counts = np.unique(y, return_counts=True)
    n_samples = len(y)
    n_classes = len(classes)
    weight_map = {}
    for cls, count in zip(classes, counts):
        weight_map[cls] = n_samples / (n_classes * count)
    return np.array([weight_map[yi] for yi in y])

Paso 5: Ajuste del umbral

def find_optimal_threshold(y_true, y_probs, metric="f1"):
    best_threshold = 0.5
    best_score = -1.0

    for threshold in np.arange(0.05, 0.96, 0.01):
        y_pred = (y_probs >= threshold).astype(int)
        tp = np.sum((y_pred == 1) & (y_true == 1))
        fp = np.sum((y_pred == 1) & (y_true == 0))
        fn = np.sum((y_pred == 0) & (y_true == 1))

        if metric == "f1":
            precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
            recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
            score = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
        elif metric == "recall":
            score = tp / (tp + fn) if (tp + fn) > 0 else 0.0
        elif metric == "precision":
            score = tp / (tp + fp) if (tp + fp) > 0 else 0.0

        if score > best_score:
            best_score = score
            best_threshold = threshold

    return best_threshold, best_score

Paso 6: Funciones de evaluación

def confusion_matrix_values(y_true, y_pred):
    tp = np.sum((y_pred == 1) & (y_true == 1))
    tn = np.sum((y_pred == 0) & (y_true == 0))
    fp = np.sum((y_pred == 1) & (y_true == 0))
    fn = np.sum((y_pred == 0) & (y_true == 1))
    return tp, tn, fp, fn


def compute_metrics(y_true, y_pred):
    tp, tn, fp, fn = confusion_matrix_values(y_true, y_pred)
    accuracy = (tp + tn) / (tp + tn + fp + fn)
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0

    denom = np.sqrt(float((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)))
    mcc = (tp * tn - fp * fn) / denom if denom > 0 else 0.0

    return {
        "accuracy": accuracy,
        "precision": precision,
        "recall": recall,
        "f1": f1,
        "mcc": mcc,
    }

Paso 7: comparar todos los enfoques

X, y = make_imbalanced_data(950, 50, seed=42)
split = int(0.8 * len(y))
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]

# Baseline: no treatment
w_base, b_base = logistic_regression_weighted(
    X_train, y_train, np.ones(len(y_train)), lr=0.1, epochs=300
)
probs_base = sigmoid(X_test @ w_base + b_base)
preds_base = (probs_base >= 0.5).astype(int)

# Oversampled
X_over, y_over = random_oversample(X_train, y_train)
w_over, b_over = logistic_regression_weighted(
    X_over, y_over, np.ones(len(y_over)), lr=0.1, epochs=300
)
preds_over = (sigmoid(X_test @ w_over + b_over) >= 0.5).astype(int)

# SMOTE
minority_mask = y_train == 1
X_minority = X_train[minority_mask]
synthetic = smote(X_minority, k=5, n_synthetic=len(y_train) - 2 * int(minority_mask.sum()))
X_smote = np.vstack([X_train, synthetic])
y_smote = np.concatenate([y_train, np.ones(len(synthetic))])
w_sm, b_sm = logistic_regression_weighted(
    X_smote, y_smote, np.ones(len(y_smote)), lr=0.1, epochs=300
)
preds_smote = (sigmoid(X_test @ w_sm + b_sm) >= 0.5).astype(int)

# Class weights
sample_weights = compute_class_weights(y_train)
w_cw, b_cw = logistic_regression_weighted(
    X_train, y_train, sample_weights, lr=0.1, epochs=300
)
probs_cw = sigmoid(X_test @ w_cw + b_cw)
preds_cw = (probs_cw >= 0.5).astype(int)

# Threshold tuning (tune on held-out validation set, not test set)
probs_val = sigmoid(X_val @ w_cw + b_cw)
best_thresh, best_f1 = find_optimal_threshold(y_val, probs_val, metric="f1")
preds_thresh = (probs_cw >= best_thresh).astype(int)

El archivo de código ejecuta todo esto en un único script e imprime los resultados.

Úsalo

Con scikit-learn y aprendizaje desequilibrado, estas técnicas son breves:

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, f1_score
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
from imblearn.pipeline import Pipeline

X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y)

model_weighted = LogisticRegression(class_weight="balanced")
model_weighted.fit(X_train, y_train)
print(classification_report(y_test, model_weighted.predict(X_test)))

smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)
model_smote = LogisticRegression()
model_smote.fit(X_resampled, y_resampled)
print(classification_report(y_test, model_smote.predict(X_test)))

pipeline = Pipeline([
    ("smote", SMOTE()),
    ("model", LogisticRegression(class_weight="balanced")),
])
pipeline.fit(X_train, y_train)
print(classification_report(y_test, pipeline.predict(X_test)))

Las implementaciones desde cero muestran exactamente lo que hace cada técnica. SMOTE es solo una interpolación k-NN en la clase minoritaria. Los pesos de clase multiplican la pérdida. El ajuste de umbral es un bucle for sobre límites. Sin magia.

Envíalo

Esta lección produce:

  • outputs/skill-imbalanced-data.md -- una lista de verificación de decisiones para manejar problemas de clasificación desequilibrados

Ejercicios

  1. Borderline-SMOTE: modifique la implementación SMOTE para generar solo muestras sintéticas para puntos minoritarios que están cerca del límite de decisión (aquellos cuyos k vecinos más cercanos incluyen muestras de clase mayoritaria). Compare los resultados con el estándar SMOTE en un conjunto de datos donde las clases se superponen.

  2. Optimización de la matriz de costos: implemente un aprendizaje sensible a los costos donde la matriz de costos sea un parámetro. Cree una función que tome una matriz de costos y devuelva predicciones óptimas que minimicen el costo esperado. Pruebe con diferentes proporciones de costos (1:10, 1:100, 1:1000) y trace cómo cambia la relación precisión-recuperación.

  3. Calibración de umbral: implementar la escala de Platt (ajustar una regresión logística a los resultados brutos del modelo para producir probabilidades calibradas). Compare la curva de recuperación de precisión antes y después de la calibración. Demuestre que la calibración no cambia la clasificación (AUC permanece igual) pero hace que las probabilidades sean más significativas.

  4. Conjunto con embolsado equilibrado: entrene varios modelos, cada uno en una muestra de arranque equilibrada (todos minoritarios + subconjunto aleatorio de la mayoría). Promedia sus predicciones. Compare este enfoque con un solo modelo con SMOTE. Mida tanto el rendimiento como la variación entre ejecuciones.

  5. Experimento de relación de desequilibrio: tome un conjunto de datos equilibrado y aumente progresivamente la relación de desequilibrio (50/50, 70/30, 90/10, 95/5, 99/1). Para cada proporción, entrena con y sin SMOTE. Trazar F1 vs relación de desequilibrio para ambos enfoques. ¿En qué proporción SMOTE comienza a marcar una diferencia significativa?

Términos clave

Término Lo que dice la gente Lo que realmente significa
Desequilibrio de clases "Una clase tiene muchas más muestras" La distribución de clases en el conjunto de datos está significativamente sesgada, lo que hace que los modelos favorezcan a la clase mayoritaria.
SMOTE "Sobremuestreo sintético" Crea nuevas muestras minoritarias interpolando entre muestras minoritarias existentes y sus k vecinos minoritarios más cercanos
Pesos de clase "Encarecer los errores en clases raras" Multiplicar la función de pérdida por ponderaciones específicas de clase para que el modelo penalice más fuertemente la clasificación errónea de las minorías
Ajuste de umbral "Mover el límite de decisión" Cambiar el límite de probabilidad para la clasificación del valor predeterminado 0,5 a un valor que optimice la métrica deseada
Compensación precisión-recuperación "No se pueden tener ambas cosas" Reducir el umbral detecta más positivos (mayor recuerdo), pero también señala más falsos positivos (menor precisión), y viceversa.
AUPRC "Área bajo la curva PR" Resume la curva de recuperación de precisión en un solo número; más informativo que AUC-ROC cuando las clases están muy desequilibradas
Coeficiente de correlación de Matthews "La métrica equilibrada" Una correlación entre las etiquetas previstas y reales que produce una puntuación alta sólo cuando el modelo funciona bien en ambas clases
Aprendizaje sensible a los costos "Diferentes errores cuestan cantidades diferentes" Incorporar costos de clasificación errónea del mundo real en el objetivo de entrenamiento para que el modelo optimice el costo total, no el recuento de errores.
Sobremuestreo aleatorio "Duplicar a la minoría" Repetir muestras de clases minoritarias para equilibrar el recuento de clases; simple pero corre el riesgo de sobreajustarse a puntos duplicados

Lectura adicional

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).