Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 49 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![Security & Quality](https://github.com/Sebithaz-dev/Model-Training-S3/actions/workflows/security.yml/badge.svg)](https://github.com/Sebithaz-dev/Model-Training-S3/actions/workflows/security.yml)
[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=Sebithaz-dev_Model-Training-S3&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Sebithaz-dev_Model-Training-S3)

Pipeline de entrenamiento automatizado para clasificar la duración de viajes de Citi Bike NYC en **corto** (< 15 min) o **largo** (≥ 15 min), utilizando datos curados desde AWS Glue y subiendo artefactos a S3.
Pipeline de entrenamiento automatizado para clasificar la duración de viajes de Citi Bike NYC en **corto** (≤ 15 min) o **largo** (> 15 min), utilizando datos curados desde AWS Glue y subiendo artefactos a S3.

---

Expand Down Expand Up @@ -131,7 +131,7 @@ Antes del feature engineering se valida el schema del Parquet contra el contrato
python -m ml.predict
```

Ejecuta ejemplos de prueba con un Passenger (`Customer`, 5pm → largo) vs un Suscriptor (`Subscriber`, 8am → corto).
Ejecuta ejemplos de prueba con un Passenger (`Customer`, 5pm → largo) vs un Suscriptor (`Subscriber`, 6am → corto).

### Health check del pipeline

Expand Down Expand Up @@ -178,47 +178,80 @@ Parquet con columnas:

### Feature Engineering

- `start_station_freq` — frequency encoding con `log1p`
- `usertype` / `gender` — `LabelEncoder` (fiteado sobre training set)
- Escalado con `StandardScaler`
- `start_station_freq` — frequency encoding con `log1p` (fiteado solo con train)
- `usertype` / `gender` — `LabelEncoder` (fiteado solo con train; categorías nuevas → `-1`)
- Features cíclicas: `hour_sin/cos`, `month_sin/cos`, `dayofweek_sin/cos`
- Flags horarios: `is_rush_hour` (7-9 y 17-19), `is_night` (22-5)
- Escalado con `StandardScaler` (fiteado solo con train)

### Target

- **1** (`largo`) si `tripduration / 60 > 15` (> 900 segundos)
- **0** (`corto`) en caso contrario
- Umbral de decisión del modelo: **0.65**
- El umbral de decisión se optimiza en validación maximizando **F1** y se persiste en `transformers.pkl` / `metricas.json` (el valor configurado en YAML queda como respaldo)

### Split

- **Temporal** por defecto: ordena por `starttime` y usa 70% train, 10% validación y 20% test.
- El modo `random` sigue disponible en `config/model.yaml` para comparar contra el baseline.

### Modelo

```python
LogisticRegression(random_state=42, max_iter=1000, class_weight="balanced")
HistGradientBoostingClassifier(
class_weight="balanced",
max_iter=300,
learning_rate=0.08,
max_leaf_nodes=31,
min_samples_leaf=20,
l2_regularization=0.1,
early_stopping=True,
random_state=42,
)
```

### Salidas (subidas a S3)

| Archivo | Contenido |
|---|---|
| `modelo.pkl` | Modelo entrenado (joblib) |
| `transformers.pkl` | LabelEncoders + Scaler + station_freq + feature_cols |
| `transformers.pkl` | LabelEncoders + Scaler + station_freq + feature_cols + umbral |
| `metricas.json` | Métricas de evaluación + umbral + features usadas |

---

## Métricas reportadas

Las métricas se calculan sobre el test set (80/20 split estratificado) y se guardan en `metricas.json`:
Las métricas se calculan sobre el test set temporal (70/10/20) y se guardan en `metricas.json`:

| Métrica | Descripción |
|---|---|
| `accuracy` | Exactitud global |
| `recall` | Recall clase positiva (`largo`) |
| `precision` | Precisión clase positiva |
| `f1` | F1-score clase positiva |
| `f1_score` | F1-score clase positiva |
| `roc_auc` | Área bajo la curva ROC |
| `pr_auc` | Área bajo la curva Precision-Recall |
| `gini` | Coeficiente Gini (`2 * roc_auc - 1`) |
| `confusion_matrix` | Matriz [[TN, FP], [FN, TP]] |
| `train_size` / `test_size` | Tamaño de cada split |
| `proportion` | Proporción de viajes largos en test |
| `matriz_confusion` | Matriz [[TN, FP], [FN, TP]] |
| `train_size` / `validation_size` / `test_size` | Tamaño de cada split |
| `proporcion_largos` | Proporción de viajes largos en test |
| `umbral_decision` | Umbral F1 elegido en validación |

### Últimas métricas (test temporal)

| Métrica | Valor |
|---|---|
| ROC AUC | 0.6505 |
| PR AUC | 0.4455 |
| Gini | 0.3009 |
| Accuracy | 0.5500 |
| Recall (largo) | 0.7035 |
| Precision (largo) | 0.3473 |
| F1 Score (largo) | 0.4650 |
| Umbral decisión | 0.46 |
| Train / Val / Test | 331,401 / 47,343 / 94,686 |
| Matriz de confusión | [[33,557, 34,806], [7,804, 18,519]] |

---

Expand Down Expand Up @@ -404,11 +437,14 @@ Reglas:
│ ├── __init__.py
│ ├── config.py # Carga de configuración YAML
│ ├── data_validation.py # Validación de schema y calidad de datos
│ ├── features.py # Features temporales compartidas train/predict
│ ├── health.py # Health check (config, artefactos, S3)
│ ├── train.py # Entrenamiento del modelo
│ ├── predict.py # Predicción local (CLI demo)
│ └── s3_utils.py # Subida/descarga desde S3
├── tests/ # Tests unitarios (pytest)
│ ├── test_features.py # Features temporales
│ ├── test_train.py # Split, umbral y pipeline
│ └── test_health.py # Tests del health check
├── pyproject.toml # Config de ruff y pytest
├── requirements.txt # Dependencias runtime
Expand Down
26 changes: 23 additions & 3 deletions config/model.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,31 @@
target:
# Umbral en segundos: viaje_largo = 1 si tripduration > trip_long_threshold_seconds
trip_long_threshold_seconds: 900
# Umbral de decision sobre la probabilidad del modelo
# Umbral de decision de respaldo; si tune_threshold=true se reemplaza por
# el umbral que maximiza threshold_metric en el set de validacion.
decision_threshold: 0.65
tune_threshold: true
threshold_metric: f1

split:
# temporal: ordena por starttime y entrena con el pasado, evalua con el futuro.
# random: split aleatorio estratificado (util para comparar contra el baseline).
strategy: temporal
test_size: 0.2
validation_size: 0.1
random_state: 42
stratify: true

model:
class: sklearn.linear_model.LogisticRegression
class: sklearn.ensemble.HistGradientBoostingClassifier
params:
class_weight: balanced
max_iter: 1000
max_iter: 300
learning_rate: 0.08
max_leaf_nodes: 31
min_samples_leaf: 20
l2_regularization: 0.1
early_stopping: true
random_state: 42

features:
Expand All @@ -28,6 +40,14 @@ features:
- is_weekend
- age
- start_station_freq
- hour_sin
- hour_cos
- month_sin
- month_cos
- dayofweek_sin
- dayofweek_cos
- is_rush_hour
- is_night
categorical:
- usertype
- gender
Expand Down
32 changes: 31 additions & 1 deletion ml/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
class ModelConfig:
trip_long_threshold_seconds: int
decision_threshold: float
tune_threshold: bool
threshold_metric: str
split_strategy: str
test_size: float
validation_size: float
random_state: int
stratify: bool
model_class: str
Expand Down Expand Up @@ -55,10 +59,14 @@ def load_config(path: str | os.PathLike | None = None) -> ModelConfig:
features = data["features"]
outliers = data["outliers"]

return ModelConfig(
cfg = ModelConfig(
trip_long_threshold_seconds=int(target["trip_long_threshold_seconds"]),
decision_threshold=float(target["decision_threshold"]),
tune_threshold=bool(target.get("tune_threshold", True)),
threshold_metric=str(target.get("threshold_metric", "f1")),
split_strategy=str(split.get("strategy", "temporal")),
test_size=float(split["test_size"]),
validation_size=float(split.get("validation_size", 0.1)),
random_state=int(split["random_state"]),
stratify=bool(split["stratify"]),
model_class=str(model["class"]),
Expand All @@ -68,3 +76,25 @@ def load_config(path: str | os.PathLike | None = None) -> ModelConfig:
age_min=int(outliers["age_min"]),
age_max=int(outliers["age_max"]),
)
_validate_config(cfg)
return cfg


def _ensure_fraction(value: float, name: str) -> None:
if value <= 0 or value >= 1:
raise ValueError(f"{name} debe estar entre 0 y 1")


def _validate_config(cfg: ModelConfig) -> None:
if cfg.decision_threshold < 0 or cfg.decision_threshold > 1:
raise ValueError("target.decision_threshold debe estar entre 0 y 1")
if cfg.threshold_metric not in {"f1", "recall", "precision"}:
raise ValueError(
"target.threshold_metric debe ser f1, recall o precision"
)
if cfg.split_strategy not in {"temporal", "random"}:
raise ValueError("split.strategy debe ser temporal o random")
_ensure_fraction(cfg.test_size, "split.test_size")
_ensure_fraction(cfg.validation_size, "split.validation_size")
if cfg.test_size + cfg.validation_size >= 1:
raise ValueError("test_size + validation_size debe ser menor que 1")
26 changes: 26 additions & 0 deletions ml/features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Ingenieria de features temporales compartida por entrenamiento e inferencia."""
from __future__ import annotations

import numpy as np
import pandas as pd


def add_time_features(df: pd.DataFrame) -> pd.DataFrame:
"""Agrega features ciclicas y flags de horario.

Las formulas deben coincidir exactamente con las usadas por el dashboard
(``app/services/ml_service.py``) para evitar skew entre train y serving.
"""
out = df.copy()
out["hour_sin"] = np.sin(2 * np.pi * out["hour"] / 24)
out["hour_cos"] = np.cos(2 * np.pi * out["hour"] / 24)
out["month_sin"] = np.sin(2 * np.pi * (out["month"] - 1) / 12)
out["month_cos"] = np.cos(2 * np.pi * (out["month"] - 1) / 12)
out["dayofweek_sin"] = np.sin(2 * np.pi * out["dayofweek"] / 7)
out["dayofweek_cos"] = np.cos(2 * np.pi * out["dayofweek"] / 7)
out["is_rush_hour"] = (
((out["hour"] >= 7) & (out["hour"] <= 9))
| ((out["hour"] >= 17) & (out["hour"] <= 19))
).astype(int)
out["is_night"] = ((out["hour"] <= 5) | (out["hour"] >= 22)).astype(int)
return out
9 changes: 7 additions & 2 deletions ml/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import numpy as np
import pandas as pd

from ml.features import add_time_features

warnings.filterwarnings("ignore")
RUTA_ARTEFACTOS = os.path.join(os.path.dirname(__file__), "artefactos")

Expand Down Expand Up @@ -38,6 +40,7 @@ def predecir(self, datos):
df["start_station_freq"] = np.log1p(
df["start_station_id"].map(self.station_freq).fillna(0)
)
df = add_time_features(df)
for col in ["usertype", "gender"]:
le = self.label_encoders[col]
df[col] = (
Expand All @@ -53,6 +56,7 @@ def predecir(self, datos):
"probabilidad_largo": round(float(prob), 4),
"prediccion": pred,
"etiqueta": "largo" if pred else "corto",
"umbral_decision": round(float(self.decision_threshold), 4),
}


Expand All @@ -71,6 +75,7 @@ def predecir(self, datos):
"probabilidad_largo": "float - Probabilidad de viaje largo (0.0 - 1.0)",
"prediccion": "int - 1 si viaje largo, 0 si corto",
"etiqueta": "str - 'largo' | 'corto'",
"umbral_decision": "float - Umbral usado para clasificar",
}


Expand All @@ -91,7 +96,7 @@ def main():
"start_station_id": 3160,
"usertype": "Subscriber",
"gender": "Male",
"hour": 8,
"hour": 6,
"month": 7,
"dayofweek": 3,
"is_weekend": 0,
Expand All @@ -100,7 +105,7 @@ def main():

print("\nEjemplo 1 (Customer, 5pm):")
print(json.dumps(predictor.predecir(ejemplo_1), indent=2))
print("\nEjemplo 2 (Subscriber, 8am):")
print("\nEjemplo 2 (Subscriber, 6am):")
print(json.dumps(predictor.predecir(ejemplo_2), indent=2))


Expand Down
Loading
Loading