Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import os | |
| import warnings | |
| from pathlib import Path | |
| from typing import Dict, Tuple | |
| import joblib | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| from sklearn.base import clone | |
| from sklearn.compose import ColumnTransformer | |
| from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier | |
| from sklearn.impute import SimpleImputer | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.metrics import ( | |
| accuracy_score, | |
| precision_score, | |
| recall_score, | |
| f1_score, | |
| roc_auc_score, | |
| confusion_matrix, | |
| RocCurveDisplay, | |
| ) | |
| from sklearn.model_selection import train_test_split | |
| from sklearn.pipeline import Pipeline | |
| from sklearn.preprocessing import OneHotEncoder, StandardScaler | |
| from sklearn.inspection import permutation_importance | |
| warnings.filterwarnings("ignore") | |
| RANDOM_STATE = 42 | |
| DATA_DIR = Path("/app/data") | |
| OUTPUT_DIR = Path("outputs") | |
| OUTPUT_DIR.mkdir(exist_ok=True) | |
| def create_demo_dataset(n: int = 8000) -> pd.DataFrame: | |
| """Crea un dataset demo si no existen los CSV reales de Kaggle.""" | |
| rng = np.random.default_rng(RANDOM_STATE) | |
| airlines = np.array(["AA", "DL", "UA", "WN", "B6", "AS", "NK", "F9"]) | |
| airports = np.array(["ATL", "LAX", "ORD", "DFW", "JFK", "SFO", "MIA", "DEN", "SEA", "BOS"]) | |
| month = rng.integers(1, 13, n) | |
| day = rng.integers(1, 29, n) | |
| day_of_week = rng.integers(1, 8, n) | |
| airline = rng.choice(airlines, n) | |
| origin = rng.choice(airports, n) | |
| dest = rng.choice(airports, n) | |
| sched_hour = rng.integers(5, 24, n) | |
| distance = rng.integers(150, 2700, n) | |
| # Probabilidad con patrones realistas: tarde, diciembre/verano, aeropuertos congestionados. | |
| base = 0.12 | |
| p = ( | |
| base | |
| + 0.012 * (sched_hour >= 17) | |
| + 0.035 * np.isin(month, [6, 7, 12]) | |
| + 0.03 * np.isin(origin, ["ORD", "JFK", "LAX"]) | |
| + 0.02 * np.isin(airline, ["WN", "B6", "F9"]) | |
| + 0.01 * (day_of_week >= 5) | |
| ) | |
| delayed = rng.binomial(1, np.clip(p, 0.02, 0.6)) | |
| dep_delay = np.where(delayed == 1, rng.gamma(2.2, 18, n) + 16, rng.normal(0, 7, n)) | |
| dep_delay = np.round(dep_delay).astype(int) | |
| scheduled_departure = sched_hour * 100 + rng.choice([0, 5, 10, 15, 20, 30, 45, 50], n) | |
| actual_departure = scheduled_departure + dep_delay | |
| df = pd.DataFrame( | |
| { | |
| "YEAR": 2015, | |
| "MONTH": month, | |
| "DAY": day, | |
| "DAY_OF_WEEK": day_of_week, | |
| "AIRLINE": airline, | |
| "FLIGHT_NUMBER": rng.integers(1, 9000, n), | |
| "ORIGIN_AIRPORT": origin, | |
| "DESTINATION_AIRPORT": dest, | |
| "SCHEDULED_DEPARTURE": scheduled_departure, | |
| "DEPARTURE_TIME": actual_departure, | |
| "DEPARTURE_DELAY": dep_delay, | |
| "SCHEDULED_ARRIVAL": scheduled_departure + rng.integers(60, 320, n), | |
| "ARRIVAL_DELAY": dep_delay + rng.normal(0, 12, n).round().astype(int), | |
| "DISTANCE": distance, | |
| } | |
| ) | |
| return df | |
| def load_data(sample_size: int = 80_000) -> pd.DataFrame: | |
| flights_path = DATA_DIR / "flights.csv" | |
| if flights_path.exists(): | |
| print("Cargando data/flights.csv...") | |
| # Leer columnas necesarias para ahorrar memoria. | |
| usecols = [ | |
| "YEAR", "MONTH", "DAY", "DAY_OF_WEEK", "AIRLINE", "FLIGHT_NUMBER", | |
| "ORIGIN_AIRPORT", "DESTINATION_AIRPORT", "SCHEDULED_DEPARTURE", | |
| "DEPARTURE_TIME", "DEPARTURE_DELAY", "SCHEDULED_ARRIVAL", | |
| "ARRIVAL_DELAY", "DISTANCE", "CANCELLED", "DIVERTED" | |
| ] | |
| df = pd.read_csv(flights_path, usecols=lambda c: c in usecols, low_memory=False) | |
| # Dataset completo es grande. Para clase/proyecto, muestra estratificada simple. | |
| if len(df) > sample_size: | |
| df = df.sample(sample_size, random_state=RANDOM_STATE) | |
| else: | |
| print("No se encontr贸 data/flights.csv. Usando dataset demo.") | |
| df = create_demo_dataset() | |
| return df | |
| def understand_data(df: pd.DataFrame) -> None: | |
| summary = pd.DataFrame({ | |
| "variable": df.columns, | |
| "tipo": [str(df[c].dtype) for c in df.columns], | |
| "valores_faltantes": [df[c].isna().sum() for c in df.columns], | |
| "porcentaje_faltante": [df[c].isna().mean() * 100 for c in df.columns], | |
| "valores_unicos": [df[c].nunique(dropna=True) for c in df.columns], | |
| }) | |
| summary.to_csv(OUTPUT_DIR / "data_understanding_summary.csv", index=False) | |
| print("Filas:", len(df), "Columnas:", df.shape[1]) | |
| print(summary) | |
| def clean_data(df: pd.DataFrame) -> pd.DataFrame: | |
| df = df.copy() | |
| initial_rows = len(df) | |
| df = df.drop_duplicates() | |
| if "CANCELLED" in df.columns: | |
| df = df[df["CANCELLED"] == 0] | |
| if "DIVERTED" in df.columns: | |
| df = df[df["DIVERTED"] == 0] | |
| # Crear fecha real. | |
| df["FLIGHT_DATE"] = pd.to_datetime( | |
| dict(year=df["YEAR"], month=df["MONTH"], day=df["DAY"]), errors="coerce" | |
| ) | |
| # Convertir horas HHMM a hora. | |
| for col in ["SCHEDULED_DEPARTURE", "DEPARTURE_TIME", "SCHEDULED_ARRIVAL"]: | |
| if col in df.columns: | |
| df[col] = pd.to_numeric(df[col], errors="coerce") | |
| df[col + "_HOUR"] = (df[col] // 100).clip(0, 23) | |
| df[col + "_MINUTE"] = (df[col] % 100).clip(0, 59) | |
| # Corregir retrasos extremos que pueden ser errores de captura. | |
| if "DEPARTURE_DELAY" in df.columns: | |
| df["DEPARTURE_DELAY"] = pd.to_numeric(df["DEPARTURE_DELAY"], errors="coerce") | |
| df = df[df["DEPARTURE_DELAY"].between(-60, 600) | df["DEPARTURE_DELAY"].isna()] | |
| # Columnas categ贸ricas a texto. | |
| for col in ["AIRLINE", "ORIGIN_AIRPORT", "DESTINATION_AIRPORT"]: | |
| df[col] = df[col].astype(str).str.upper().str.strip() | |
| df.loc[df[col].isin(["NAN", "NONE", ""]), col] = np.nan | |
| quality = pd.DataFrame({ | |
| "metrica": ["filas_iniciales", "filas_finales", "duplicados_eliminados_o_cancelados"], | |
| "valor": [initial_rows, len(df), initial_rows - len(df)], | |
| }) | |
| quality.to_csv(OUTPUT_DIR / "data_quality_summary.csv", index=False) | |
| df.to_csv(OUTPUT_DIR / "clean_flights.csv", index=False) | |
| return df | |
| def add_basic_features(df: pd.DataFrame) -> pd.DataFrame: | |
| """Crea variables base sin estad铆sticas agregadas (evita fuga de datos en el modelo).""" | |
| df = df.copy() | |
| df["DELAYED"] = (df["DEPARTURE_DELAY"] > 15).astype(int) | |
| if "SCHEDULED_DEPARTURE_HOUR" in df.columns: | |
| dep_hour = df["SCHEDULED_DEPARTURE_HOUR"] | |
| else: | |
| dep_hour = (pd.to_numeric(df["SCHEDULED_DEPARTURE"], errors="coerce") // 100).clip(0, 23) | |
| df["DEP_HOUR"] = dep_hour.fillna(0).astype(int) | |
| df["IS_WEEKEND"] = df["DAY_OF_WEEK"].isin([6, 7]).astype(int) | |
| df["ROUTE"] = df["ORIGIN_AIRPORT"].astype(str) + "_" + df["DESTINATION_AIRPORT"].astype(str) | |
| return df | |
| def compute_aggregate_stats(df: pd.DataFrame) -> Dict[str, pd.Series | float]: | |
| """Calcula estad铆sticas hist贸ricas solo a partir del conjunto de referencia (train).""" | |
| return { | |
| "route_freq": df.groupby("ROUTE").size(), | |
| "airline_avg_delay": df.groupby("AIRLINE")["DEPARTURE_DELAY"].mean(), | |
| "origin_avg_delay": df.groupby("ORIGIN_AIRPORT")["DEPARTURE_DELAY"].mean(), | |
| "dest_avg_delay": df.groupby("DESTINATION_AIRPORT")["DEPARTURE_DELAY"].mean(), | |
| "distance_median": float(df["DISTANCE"].median()) if "DISTANCE" in df.columns else 700.0, | |
| } | |
| def apply_aggregate_features(df: pd.DataFrame, stats: Dict[str, pd.Series | float]) -> pd.DataFrame: | |
| df = df.copy() | |
| df["ROUTE_FREQUENCY"] = df["ROUTE"].map(stats["route_freq"]).fillna(1) | |
| df["AIRLINE_AVG_DELAY"] = df["AIRLINE"].map(stats["airline_avg_delay"]).fillna(0) | |
| df["ORIGIN_AVG_DELAY"] = df["ORIGIN_AIRPORT"].map(stats["origin_avg_delay"]).fillna(0) | |
| df["DEST_AVG_DELAY"] = df["DESTINATION_AIRPORT"].map(stats["dest_avg_delay"]).fillna(0) | |
| return df | |
| def add_features(df: pd.DataFrame) -> pd.DataFrame: | |
| """Dataset para EDA/dashboard. Los agregados usan todo el hist贸rico (solo visualizaci贸n).""" | |
| df = add_basic_features(df) | |
| stats = compute_aggregate_stats(df) | |
| df = apply_aggregate_features(df, stats) | |
| df.to_csv(OUTPUT_DIR / "model_ready_flights.csv", index=False) | |
| return df | |
| def delay_rate(group: pd.DataFrame, col: str, top: int | None = None) -> pd.DataFrame: | |
| out = group.groupby(col).agg( | |
| flights=("DELAYED", "size"), | |
| delay_rate=("DELAYED", "mean"), | |
| avg_delay=("DEPARTURE_DELAY", "mean"), | |
| ).reset_index() | |
| out = out.sort_values("delay_rate", ascending=False) | |
| if top: | |
| out = out.head(top) | |
| return out | |
| def plot_bar(df: pd.DataFrame, x: str, y: str, title: str, file_name: str, rotation: int = 45) -> None: | |
| plt.figure(figsize=(10, 5)) | |
| plt.bar(df[x].astype(str), df[y]) | |
| plt.title(title) | |
| plt.xlabel(x) | |
| plt.ylabel(y) | |
| plt.xticks(rotation=rotation, ha="right") | |
| plt.tight_layout() | |
| plt.savefig(OUTPUT_DIR / file_name, dpi=150) | |
| plt.close() | |
| def exploratory_analysis(df: pd.DataFrame) -> None: | |
| airline = delay_rate(df, "AIRLINE") | |
| airport = delay_rate(df, "ORIGIN_AIRPORT", top=20) | |
| month = delay_rate(df, "MONTH") | |
| hour = delay_rate(df, "DEP_HOUR") | |
| dow = delay_rate(df, "DAY_OF_WEEK") | |
| airline.to_csv(OUTPUT_DIR / "eda_delay_rate_by_airline.csv", index=False) | |
| airport.to_csv(OUTPUT_DIR / "eda_delay_rate_by_airport.csv", index=False) | |
| month.to_csv(OUTPUT_DIR / "eda_delay_rate_by_month.csv", index=False) | |
| hour.to_csv(OUTPUT_DIR / "eda_delay_rate_by_departure_hour.csv", index=False) | |
| dow.to_csv(OUTPUT_DIR / "eda_delay_rate_by_day_of_week.csv", index=False) | |
| plot_bar(airline, "AIRLINE", "delay_rate", "Tasa de retrasos por aerol铆nea", "delay_rate_airline.png") | |
| plot_bar(airport, "ORIGIN_AIRPORT", "delay_rate", "Tasa de retrasos por aeropuerto origen", "delay_rate_airport.png") | |
| plot_bar(month, "MONTH", "delay_rate", "Tasa de retrasos por mes", "delay_rate_month.png", 0) | |
| plot_bar(hour, "DEP_HOUR", "delay_rate", "Tasa de retrasos por hora de salida", "delay_rate_hour.png", 0) | |
| plt.figure(figsize=(10, 5)) | |
| plt.hist(df["DEPARTURE_DELAY"].dropna(), bins=60) | |
| plt.title("Distribuci贸n de minutos de retraso en salida") | |
| plt.xlabel("Minutos de retraso") | |
| plt.ylabel("Cantidad de vuelos") | |
| plt.tight_layout() | |
| plt.savefig(OUTPUT_DIR / "delay_distribution.png", dpi=150) | |
| plt.close() | |
| def build_preprocessor(X: pd.DataFrame) -> Tuple[ColumnTransformer, list, list]: | |
| categorical_features = ["AIRLINE", "ORIGIN_AIRPORT", "DESTINATION_AIRPORT", "ROUTE"] | |
| numeric_features = [ | |
| "MONTH", "DAY_OF_WEEK", "DEP_HOUR", "DISTANCE", "IS_WEEKEND", | |
| "ROUTE_FREQUENCY", "AIRLINE_AVG_DELAY", "ORIGIN_AVG_DELAY", "DEST_AVG_DELAY" | |
| ] | |
| categorical_features = [c for c in categorical_features if c in X.columns] | |
| numeric_features = [c for c in numeric_features if c in X.columns] | |
| try: | |
| encoder = OneHotEncoder(handle_unknown="ignore", sparse_output=False) | |
| except TypeError: | |
| encoder = OneHotEncoder(handle_unknown="ignore", sparse=False) | |
| preprocessor = ColumnTransformer( | |
| transformers=[ | |
| ("num", Pipeline([("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler())]), numeric_features), | |
| ("cat", Pipeline([("imputer", SimpleImputer(strategy="most_frequent")), ("onehot", encoder)]), categorical_features), | |
| ], | |
| remainder="drop", | |
| ) | |
| return preprocessor, numeric_features, categorical_features | |
| def train_and_evaluate(df: pd.DataFrame) -> Dict[str, Pipeline]: | |
| features = [ | |
| "AIRLINE", "ORIGIN_AIRPORT", "DESTINATION_AIRPORT", "ROUTE", | |
| "MONTH", "DAY_OF_WEEK", "DEP_HOUR", "DISTANCE", "IS_WEEKEND", | |
| "ROUTE_FREQUENCY", "AIRLINE_AVG_DELAY", "ORIGIN_AVG_DELAY", "DEST_AVG_DELAY" | |
| ] | |
| base_df = add_basic_features(df) | |
| base_df = base_df.dropna(subset=["DELAYED", "AIRLINE", "ORIGIN_AIRPORT", "DESTINATION_AIRPORT"]) | |
| if base_df["DELAYED"].nunique() < 2: | |
| raise ValueError("La variable objetivo DELAYED debe tener al menos dos clases.") | |
| train_idx, test_idx = train_test_split( | |
| base_df.index, | |
| test_size=0.25, | |
| random_state=RANDOM_STATE, | |
| stratify=base_df["DELAYED"], | |
| ) | |
| train_base = base_df.loc[train_idx] | |
| test_base = base_df.loc[test_idx] | |
| stats = compute_aggregate_stats(train_base) | |
| train_df = apply_aggregate_features(train_base, stats) | |
| test_df = apply_aggregate_features(test_base, stats) | |
| features = [c for c in features if c in train_df.columns] | |
| X_train = train_df[features] | |
| X_test = test_df[features] | |
| y_train = train_df["DELAYED"] | |
| y_test = test_df["DELAYED"] | |
| preprocessor, _, _ = build_preprocessor(X_train) | |
| models = { | |
| "Regresion Logistica": LogisticRegression(max_iter=1000, class_weight="balanced"), | |
| "Bosque Aleatorio": RandomForestClassifier( | |
| n_estimators=80, max_depth=10, random_state=RANDOM_STATE, | |
| class_weight="balanced", n_jobs=1, | |
| ), | |
| "Gradient Boosting": GradientBoostingClassifier(random_state=RANDOM_STATE), | |
| } | |
| trained_models: Dict[str, Pipeline] = {} | |
| rows = [] | |
| roc_data = {} | |
| for name, clf in models.items(): | |
| pipe = Pipeline([("preprocess", clone(preprocessor)), ("model", clf)]) | |
| pipe.fit(X_train, y_train) | |
| y_pred = pipe.predict(X_test) | |
| y_prob = pipe.predict_proba(X_test)[:, 1] | |
| rows.append({ | |
| "model": name, | |
| "accuracy": accuracy_score(y_test, y_pred), | |
| "precision": precision_score(y_test, y_pred, zero_division=0), | |
| "recall": recall_score(y_test, y_pred, zero_division=0), | |
| "f1": f1_score(y_test, y_pred, zero_division=0), | |
| "roc_auc": roc_auc_score(y_test, y_prob), | |
| }) | |
| trained_models[name] = pipe | |
| roc_data[name] = (y_pred, y_prob) | |
| print(name, rows[-1]) | |
| metrics = pd.DataFrame(rows).sort_values("roc_auc", ascending=False) | |
| metrics.to_csv(OUTPUT_DIR / "model_metrics.csv", index=False) | |
| best_name = metrics.iloc[0]["model"] | |
| best_model = trained_models[best_name] | |
| y_pred, y_prob = roc_data[best_name] | |
| cm = confusion_matrix(y_test, y_pred) | |
| pd.DataFrame( | |
| cm, | |
| index=["Real_No_Retraso", "Real_Retraso"], | |
| columns=["Pred_No_Retraso", "Pred_Retraso"], | |
| ).to_csv(OUTPUT_DIR / "confusion_matrix.csv") | |
| plt.figure(figsize=(6, 5)) | |
| plt.imshow(cm) | |
| plt.title(f"Matriz de confusi贸n - {best_name}") | |
| plt.xlabel("Predicci贸n") | |
| plt.ylabel("Real") | |
| for i in range(cm.shape[0]): | |
| for j in range(cm.shape[1]): | |
| plt.text(j, i, cm[i, j], ha="center", va="center") | |
| plt.tight_layout() | |
| plt.savefig(OUTPUT_DIR / "confusion_matrix.png", dpi=150) | |
| plt.close() | |
| plt.figure(figsize=(7, 5)) | |
| for name, (_, prob) in roc_data.items(): | |
| RocCurveDisplay.from_predictions(y_test, prob, name=name) | |
| plt.title("Curvas ROC por modelo") | |
| plt.tight_layout() | |
| plt.savefig(OUTPUT_DIR / "roc_curve.png", dpi=150) | |
| plt.close() | |
| result = permutation_importance( | |
| best_model, X_test, y_test, n_repeats=3, random_state=RANDOM_STATE, | |
| scoring="roc_auc", n_jobs=1, | |
| ) | |
| imp = pd.DataFrame({ | |
| "feature": X_test.columns, | |
| "importance": result.importances_mean, | |
| }).sort_values("importance", ascending=False) | |
| imp.to_csv(OUTPUT_DIR / "feature_importance.csv", index=False) | |
| plot_bar(imp.head(10), "feature", "importance", "Top 10 variables importantes", "feature_importance.png") | |
| joblib.dump(best_model, OUTPUT_DIR / "best_model.joblib") | |
| joblib.dump(features, OUTPUT_DIR / "feature_columns.joblib") | |
| joblib.dump(stats, OUTPUT_DIR / "inference_stats.joblib") | |
| X_test.to_csv(OUTPUT_DIR / "X_test.csv", index=False) | |
| y_test.to_csv(OUTPUT_DIR / "y_test.csv", index=False) | |
| return trained_models | |
| def write_recommendations(df: pd.DataFrame) -> None: | |
| top_airline = delay_rate(df, "AIRLINE").head(3) | |
| top_airport = delay_rate(df, "ORIGIN_AIRPORT").head(3) | |
| top_month = delay_rate(df, "MONTH").head(3) | |
| top_hour = delay_rate(df, "DEP_HOUR").head(3) | |
| text = f""" | |
| RECOMENDACIONES EMPRESARIALES | |
| Para aerol铆neas: | |
| - Revisar procesos operacionales en las aerol铆neas con mayor tasa de retraso: {', '.join(top_airline['AIRLINE'].astype(str))}. | |
| - Optimizar rotaci贸n de tripulaci贸n, mantenimiento preventivo y tiempos de conexi贸n en rutas frecuentes. | |
| - Usar alertas tempranas para vuelos en horarios de mayor riesgo. | |
| Para aeropuertos: | |
| - Aumentar personal y recursos en los aeropuertos de origen con mayor tasa de retraso: {', '.join(top_airport['ORIGIN_AIRPORT'].astype(str))}. | |
| - Reforzar ramp agents, gates, seguridad y coordinaci贸n ATC en los meses cr铆ticos: {', '.join(top_month['MONTH'].astype(str))}. | |
| - Priorizar recursos en las horas con mayor riesgo: {', '.join(top_hour['DEP_HOUR'].astype(str))}. | |
| Para viajeros: | |
| - Preferir vuelos en la ma帽ana cuando sea posible. | |
| - Evitar conexiones cortas en aeropuertos con alta tasa de retrasos. | |
| - En meses de alta demanda, elegir itinerarios con m谩s tiempo de conexi贸n. | |
| """ | |
| (OUTPUT_DIR / "business_recommendations.txt").write_text(text, encoding="utf-8") | |
| def main() -> None: | |
| raw = load_data() | |
| understand_data(raw) | |
| clean = clean_data(raw) | |
| feat = add_features(clean) | |
| exploratory_analysis(feat) | |
| print("Distribuci贸n de clases:") | |
| print(feat["DELAYED"].value_counts(normalize=True).rename("proportion")) | |
| feat["DELAYED"].value_counts().to_csv(OUTPUT_DIR / "class_distribution.csv") | |
| train_and_evaluate(clean) | |
| write_recommendations(feat) | |
| print("Proyecto completado. Revisa la carpeta outputs/.") | |
| if __name__ == "__main__": | |
| main() | |