Python Topic #6 — Learning by Doing

CCRS California Crashes — Machine Learning Validation & Post-Model BI Investigation (ES/EN)

Executive Summary

This report documents the next stage after the Business Intelligence Risk Discovery Report. The BI phase identified strong risk patterns across collision type, pedestrian behavior, time of day, weather, violation groups, and location. The goal of this topic is to test whether those BI-discovered variables have real predictive signal.

The model was trained using historical crash records from 2019–2025 and validated against a separate real-world dataset from 2026. This creates an out-of-time validation structure rather than testing against the same historical distribution.

2,906,173
Training crashes
2019–2025
137,524
Validation crashes
2026
0.8516
ROC AUC
2026 validation
76.72%
Fatal crash recall
2026 validation

Research Logic

The analytical sequence followed a practical data science workflow:

SQL ServerPower BIBusiness HypothesesMachine Learning2026 ValidationPost-Model BI Investigation

SQL analysis
↓
Power BI risk discovery
↓
Hypothesis selection
↓
Machine learning validation
↓
Feature importance
↓
Return to BI to explain model findings

Hypothesis #1 — BI-discovered risk variables can predict future fatal crashes

Analytical Question

Can the variables identified during the Power BI discovery phase help predict whether a crash will be fatal?

Training and Validation Design

DatasetPeriodRowsPurpose
dbo.Crashes2019–20252,906,173Model training
dbo.Crashes_20262026137,524Independent validation

Target Variable

Fatal_Flag = 1 if NumberKilled > 0
Fatal_Flag = 0 if NumberKilled = 0 or NULL

Class Distribution

DatasetNon-FatalFatalFatal %
2019–20252,879,80326,3700.91%
2026137,1034210.31%
Analytical Assessment: This is a highly imbalanced classification problem. Accuracy alone is not useful because a model predicting every crash as non-fatal would appear highly accurate while failing the public safety objective.

Model V1 — Baseline Logistic Regression Internal Validation

Purpose

The first model was created as a baseline experiment to confirm that Python could read the SQL Server dataset, engineer the initial variables, train a classifier, and detect predictive signal before using the 2026 table as an independent validation set.

Validation Design

StepDescription
Source tabledbo.Crashes
Period2019–2025
Validation methodInternal stratified train/test split
TargetFatal_Flag
ModelLogistic Regression with balanced class weights

How Model V1 Was Run

PowerShell execution command:

python ml_fatal_crash_model_v1.py
Show / hide Python script
# File: ml_fatal_crash_model_v1.py
# Purpose: Baseline model using an internal split from dbo.Crashes.

import pandas as pd
from sqlalchemy import create_engine

from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)

engine = create_engine(connection_string)

query = """
SELECT
    Source_Year,
    LightingDescription,
    Collision_Type_Description,
    PedestrianActionDesc,
    Weather_1,
    City_Name,
    Crash_Date_Time,
    Primary_Collision_Factor_Violation,
    CASE
        WHEN TRY_CAST(NumberKilled AS INT) > 0 THEN 1
        ELSE 0
    END AS Fatal_Flag
FROM dbo.Crashes
WHERE Crash_Date_Time IS NOT NULL;
"""

print("Reading data from SQL Server...")
df = pd.read_sql(query, engine)
print("Rows loaded:", len(df))
print(df["Fatal_Flag"].value_counts())

df["Crash_Date_Time"] = pd.to_datetime(df["Crash_Date_Time"], errors="coerce")
df = df.dropna(subset=["Crash_Date_Time"])
df["Crash_Hour"] = df["Crash_Date_Time"].dt.hour
df["Crash_Time_2H_Bucket"] = (df["Crash_Hour"] // 2) * 2

def violation_group(v):
    v = str(v).upper()
    if "22350" in v: return "Unsafe Speed"
    if "23152" in v: return "DUI / Alcohol"
    if "22107" in v: return "Unsafe Turn / Lane Change"
    if "21658" in v: return "Lane Violation"
    if "21802" in v: return "Stop Sign / Right of Way"
    if "21804" in v: return "Failure to Yield"
    return "Other / Unclassified"

df["Violation_Group"] = df["Primary_Collision_Factor_Violation"].apply(violation_group)

features = [
    "Source_Year",
    "LightingDescription",
    "Collision_Type_Description",
    "PedestrianActionDesc",
    "Weather_1",
    "City_Name",
    "Crash_Time_2H_Bucket",
    "Violation_Group"
]

target = "Fatal_Flag"
df_model = df[features + [target]].fillna("UNKNOWN")
X = df_model[features]
y = df_model[target]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y
)

preprocessor = ColumnTransformer(
    transformers=[("cat", OneHotEncoder(handle_unknown="ignore"), features)]
)

model = LogisticRegression(max_iter=1000, class_weight="balanced", n_jobs=-1)

pipeline = Pipeline(steps=[("preprocessor", preprocessor), ("model", model)])

print("Training model...")
pipeline.fit(X_train, y_train)

print("Predicting...")
y_pred = pipeline.predict(X_test)
y_prob = pipeline.predict_proba(X_test)[:, 1]

print("CONFUSION MATRIX")
print(confusion_matrix(y_test, y_pred))
print("CLASSIFICATION REPORT")
print(classification_report(y_test, y_pred, digits=4))
print("ROC AUC")
print(roc_auc_score(y_test, y_prob))

Observed Results

MetricValue
ROC AUC0.8309
Fatal Recall0.7305
Fatal Precision0.0272
Accuracy0.7608

Analytical Assessment

Model V1 confirmed that the BI-selected variables contained real predictive signal. However, it still used an internal split from the same historical table, so it was treated as a baseline rather than the final validation design.

Model V2 — Logistic Regression Validation Against 2026

Features Used

LightingDescriptionCollision_Type_DescriptionPedestrianActionDescWeather_1City_NameCrash Time 2H BucketViolation Group

How Model V2 Was Run

PowerShell execution command:

python ml_fatal_crash_model_v2_train_2019_2025_test_2026.py
Show / hide Python script
# File: ml_fatal_crash_model_v2_train_2019_2025_test_2026.py
# Purpose: Train on dbo.Crashes (2019-2025) and validate on dbo.Crashes_2026.

import pandas as pd
from sqlalchemy import create_engine
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)
engine = create_engine(connection_string)

def violation_group(v):
    v = str(v).upper()
    if "22350" in v: return "Unsafe Speed"
    if "23152" in v: return "DUI / Alcohol"
    if "22107" in v: return "Unsafe Turn / Lane Change"
    if "21658" in v: return "Lane Violation"
    if "21802" in v: return "Stop Sign / Right of Way"
    if "21804" in v: return "Failure to Yield"
    return "Other / Unclassified"

def prepare_df(df):
    df["Crash_Date_Time"] = pd.to_datetime(
        df["Crash_Date_Time"],
        format="%m/%d/%Y %I:%M:%S %p",
        errors="coerce"
    )
    df = df.dropna(subset=["Crash_Date_Time"])
    df["Crash_Hour"] = df["Crash_Date_Time"].dt.hour
    df["Crash_Time_2H_Bucket"] = (df["Crash_Hour"] // 2) * 2
    df["Violation_Group"] = df["Primary_Collision_Factor_Violation"].apply(violation_group)
    return df

features = [
    "LightingDescription",
    "Collision_Type_Description",
    "PedestrianActionDesc",
    "Weather_1",
    "City_Name",
    "Crash_Time_2H_Bucket",
    "Violation_Group"
]
target = "Fatal_Flag"

query_train = """
SELECT
    LightingDescription,
    Collision_Type_Description,
    PedestrianActionDesc,
    Weather_1,
    City_Name,
    Crash_Date_Time,
    Primary_Collision_Factor_Violation,
    CASE WHEN TRY_CAST(NumberKilled AS INT) > 0 THEN 1 ELSE 0 END AS Fatal_Flag
FROM dbo.Crashes
WHERE Crash_Date_Time IS NOT NULL;
"""

query_test = """
SELECT
    LightingDescription,
    Collision_Type_Description,
    PedestrianActionDesc,
    Weather_1,
    City_Name,
    Crash_Date_Time,
    Primary_Collision_Factor_Violation,
    CASE WHEN TRY_CAST(NumberKilled AS INT) > 0 THEN 1 ELSE 0 END AS Fatal_Flag
FROM dbo.Crashes_2026
WHERE Crash_Date_Time IS NOT NULL;
"""

print("Reading TRAIN data 2019-2025...")
train_df = pd.read_sql(query_train, engine)
print("Reading TEST data 2026...")
test_df = pd.read_sql(query_test, engine)

train_df = prepare_df(train_df)
test_df = prepare_df(test_df)

train_model = train_df[features + [target]].fillna("UNKNOWN")
test_model = test_df[features + [target]].fillna("UNKNOWN")

X_train = train_model[features]
y_train = train_model[target]
X_test = test_model[features]
y_test = test_model[target]

preprocessor = ColumnTransformer(
    transformers=[("cat", OneHotEncoder(handle_unknown="ignore"), features)]
)

model = LogisticRegression(
    max_iter=1000,
    class_weight="balanced",
    n_jobs=-1
)

pipeline = Pipeline(steps=[("preprocessor", preprocessor), ("model", model)])

print("Training on 2019-2025...")
pipeline.fit(X_train, y_train)

print("Predicting 2026...")
y_pred = pipeline.predict(X_test)
y_prob = pipeline.predict_proba(X_test)[:, 1]

print("CONFUSION MATRIX")
print(confusion_matrix(y_test, y_pred))
print("CLASSIFICATION REPORT")
print(classification_report(y_test, y_pred, digits=4))
print("ROC AUC")
print(roc_auc_score(y_test, y_prob))

Results

MetricValueInterpretation
ROC AUC0.8516Strong separation between fatal and non-fatal crashes.
Fatal Recall76.72%The model identified 323 of 421 fatal crashes in 2026.
Fatal Precision1.00%Low due to extreme class imbalance and many false positives.
Accuracy76.69%Not the primary metric for this problem.

Confusion Matrix

Predicted Non-FatalPredicted Fatal
Actual Non-Fatal105,14831,955
Actual Fatal98323

Findings

  • The model successfully captured a large portion of fatal crashes in a future-year validation dataset.
  • The low precision indicates that this model is better interpreted as a risk-screening model, not as a final decision engine.
  • The ROC AUC indicates that the BI-selected variables contain real predictive signal.

Value Assessment

The most important result is not simply that the model produced a score. The important result is that variables discovered through BI were able to generalize into 2026, suggesting that the BI phase uncovered patterns that were not random artifacts of the historical data.

Model V3 — Random Forest Feature Importance

A Random Forest model was created to understand which encoded variables contributed most to the classification task. The purpose was explainability, not only prediction.

How Model V3 Was Run

PowerShell execution command:

python ml_fatal_crash_model_v3_random_forest_importance.py
Show / hide Python script
# File: ml_fatal_crash_model_v3_random_forest_importance.py
# Purpose: Train Random Forest and export feature importance for explainability.

import pandas as pd
from sqlalchemy import create_engine
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)
engine = create_engine(connection_string)

def violation_group(v):
    v = str(v).upper()
    if "22350" in v: return "Unsafe Speed"
    if "23152" in v: return "DUI / Alcohol"
    if "22107" in v: return "Unsafe Turn / Lane Change"
    if "21658" in v: return "Lane Violation"
    if "21802" in v: return "Stop Sign / Right of Way"
    if "21804" in v: return "Failure to Yield"
    return "Other / Unclassified"

def prepare_df(df):
    df["Crash_Date_Time"] = pd.to_datetime(
        df["Crash_Date_Time"],
        format="%m/%d/%Y %I:%M:%S %p",
        errors="coerce"
    )
    df = df.dropna(subset=["Crash_Date_Time"])
    df["Crash_Hour"] = df["Crash_Date_Time"].dt.hour
    df["Crash_Time_2H_Bucket"] = (df["Crash_Hour"] // 2) * 2
    df["Violation_Group"] = df["Primary_Collision_Factor_Violation"].apply(violation_group)
    return df

features = [
    "LightingDescription",
    "Collision_Type_Description",
    "PedestrianActionDesc",
    "Weather_1",
    "City_Name",
    "Crash_Time_2H_Bucket",
    "Violation_Group"
]
target = "Fatal_Flag"

base_query = """
SELECT
    LightingDescription,
    Collision_Type_Description,
    PedestrianActionDesc,
    Weather_1,
    City_Name,
    Crash_Date_Time,
    Primary_Collision_Factor_Violation,
    CASE WHEN TRY_CAST(NumberKilled AS INT) > 0 THEN 1 ELSE 0 END AS Fatal_Flag
FROM {table}
WHERE Crash_Date_Time IS NOT NULL;
"""

train_df = pd.read_sql(base_query.format(table="dbo.Crashes"), engine)
test_df = pd.read_sql(base_query.format(table="dbo.Crashes_2026"), engine)

train_df = prepare_df(train_df)
test_df = prepare_df(test_df)

train_model = train_df[features + [target]].fillna("UNKNOWN")
test_model = test_df[features + [target]].fillna("UNKNOWN")

X_train = train_model[features]
y_train = train_model[target]
X_test = test_model[features]
y_test = test_model[target]

preprocessor = ColumnTransformer(
    transformers=[("cat", OneHotEncoder(handle_unknown="ignore"), features)]
)

rf = RandomForestClassifier(
    n_estimators=120,
    max_depth=18,
    min_samples_leaf=50,
    class_weight="balanced",
    random_state=42,
    n_jobs=-1
)

pipeline = Pipeline(steps=[("preprocessor", preprocessor), ("model", rf)])

print("Training Random Forest on 2019-2025...")
pipeline.fit(X_train, y_train)

print("Predicting 2026...")
y_pred = pipeline.predict(X_test)
y_prob = pipeline.predict_proba(X_test)[:, 1]

print("CONFUSION MATRIX")
print(confusion_matrix(y_test, y_pred))
print("CLASSIFICATION REPORT")
print(classification_report(y_test, y_pred, digits=4))
print("ROC AUC")
print(roc_auc_score(y_test, y_prob))

print("Extracting feature importance...")
ohe = pipeline.named_steps["preprocessor"].named_transformers_["cat"]
feature_names = ohe.get_feature_names_out(features)
importances = pipeline.named_steps["model"].feature_importances_

importance_df = pd.DataFrame({"Feature": feature_names, "Importance": importances}) \
    .sort_values("Importance", ascending=False)

print("TOP 40 FEATURE IMPORTANCE")
print(importance_df.head(40).to_string(index=False))
importance_df.to_excel("rf_feature_importance_v3.xlsx", index=False)
print("DONE - Feature importance exported to rf_feature_importance_v3.xlsx")

Top Feature Importance Results

RankFeatureImportance
1Collision_Type_Description_VEHICLE/PEDESTRIAN0.120879
2PedestrianActionDesc_NO PEDESTRIANS INVOLVED0.115784
3Collision_Type_Description_SIDE SWIPE0.101700
4Collision_Type_Description_REAR END0.095826
5PedestrianActionDesc_IN ROAD - INCLUDES SHOULDER0.051558
6PedestrianActionDesc_CROSSING - NOT IN CROSSWALK0.049133
7Violation_Group_DUI / Alcohol0.043638
8City_Name_Unincorporated0.042127
9LightingDescription_DARK-NO STREET LIGHTS0.041705
10LightingDescription_DAYLIGHT0.037810

Findings

  • The #1 feature matched a major BI finding: Vehicle/Pedestrian collisions were the most severe crash type.
  • Pedestrian behavior variables appeared multiple times in the top ranking.
  • DUI / Alcohol appeared as a top feature, confirming its role as a severity indicator.
  • Lighting conditions appeared in the top 10, especially Dark-No Street Lights.
  • City_Name_Unincorporated unexpectedly emerged as a high-importance feature, opening a new line of investigation.

Analytical Assessment

The Random Forest did not replace BI. Instead, it validated and extended it. Most of the strongest model features were already discovered during the Power BI analysis. The unexpected appearance of City_Name_Unincorporated demonstrated the value of using machine learning to surface new investigative questions.

Hypothesis #2 — Unincorporated Areas Represent a Distinct Risk Ecosystem

Why This Hypothesis Emerged

The Random Forest ranked City_Name_Unincorporated among the top 10 features. This was not one of the original primary hypotheses, so the analysis returned to Power BI to investigate why the model considered it important.

City-Level Baseline

CityCrashesKilled% of Total KilledFatality Rate
Unincorporated805,22812,15242.61%1.51%
Los Angeles241,8662,2477.88%0.93%
San Diego59,0727472.62%1.26%

Findings

  • Unincorporated areas represented 27.71% of crashes but 42.61% of fatalities.
  • The fatality rate was 1.51%, compared with 0.98% statewide.
  • This supports the model’s decision to assign high importance to the Unincorporated category.

Value Assessment

Unincorporated appears to function as a proxy for a broader geographic and operational environment: less urbanized roads, county roads, higher-speed corridors, reduced lighting, and more severe crash mechanisms.

Post-ML BI Deep Dive — Unincorporated Crash Profile

Collision Type inside Unincorporated

Collision TypeCrashesKilledFatality Rate
HIT OBJECT222,3183,4901.57%
HEAD-ON28,1052,1637.70%
BROADSIDE120,1271,7381.45%
VEHICLE/PEDESTRIAN11,5641,71314.81%
OVERTURNED43,5961,3683.14%

Violation Group inside Unincorporated

Violation GroupCrashesKilledFatality Rate
Other / Unclassified203,8053,8001.86%
DUI / Alcohol78,5903,2994.20%
Unsafe Turn / Lane Change217,8253,0301.39%
Unsafe Speed210,2991,4970.71%

Lighting inside Unincorporated

LightingCrashesKilledFatality Rate
DAYLIGHT501,5045,5511.11%
DARK-NO STREET LIGHTS161,5534,5422.81%
DARK-STREET LIGHTS107,9471,4231.32%
DUSK-DAWN32,5426061.86%

Time Pattern inside Unincorporated

Time BucketCrashesKilledFatality Rate
00:00 - 02:0052,6151,3392.54%
02:00 - 04:0028,8087232.51%
22:00 - 00:0048,1411,0952.27%
20:00 - 22:0062,2131,3172.12%

Findings

  • Hit Object is the largest fatality contributor inside Unincorporated areas.
  • Head-On crashes show a very high fatality rate of 7.70%.
  • Vehicle/Pedestrian crashes remain extremely severe at 14.81%.
  • DUI / Alcohol rises to 4.20%, compared with 2.48% statewide for DUI.
  • Dark-No Street Lights reaches 2.81%, nearly three times the statewide overall fatality rate.
  • Late night and early morning buckets remain high-risk.

Master Finding — Risk Ecosystems

The most important discovery from this phase is that fatal crash risk is not explained by one variable alone. It emerges from combinations of context, behavior, environment, and geography.

Unincorporated
+
Dark-No Street Lights
+
DUI / Alcohol
+
Night / Early Morning
+
Head-On or Hit Object
=
High-risk crash ecosystem

Analytical Assessment

The model did not simply identify a city label. It surfaced a geographic risk proxy. Power BI then helped interpret that signal by revealing the underlying conditions associated with Unincorporated crash severity.

Conclusion

This topic demonstrates a complete analytical loop: Business Intelligence identified strong risk patterns, Machine Learning validated the predictive value of those patterns using a future-year test set, and feature importance created a new question that was investigated again through BI.

The most important result is methodological: BI and ML are not competing approaches. They reinforce each other.

BI discovers patterns.
ML tests predictive signal.
Feature importance raises new questions.
BI explains the model.

Resumen Ejecutivo

Este informe documenta la etapa posterior al Business Intelligence Risk Discovery Report. La fase de BI identificó patrones fuertes de riesgo asociados con tipo de colisión, comportamiento peatonal, horario, clima, grupos de infracción y ubicación. El objetivo de este tópico es evaluar si esas variables descubiertas con BI tienen señal predictiva real.

El modelo fue entrenado con registros históricos de 2019–2025 y validado contra un conjunto independiente de datos reales de 2026. Esto crea una validación temporal real, no una simple partición aleatoria del mismo período histórico.

2,906,173
Accidentes de entrenamiento
2019–2025
137,524
Accidentes de validación
2026
0.8516
ROC AUC
Validación 2026
76.72%
Recall de accidentes fatales
Validación 2026

Lógica de Investigación

La secuencia analítica siguió un flujo práctico de ciencia de datos:

SQL ServerPower BIHipótesis de NegocioMachine LearningValidación 2026Investigación BI Post-Modelo

Análisis SQL
↓
Descubrimiento de riesgo en Power BI
↓
Selección de hipótesis
↓
Validación con Machine Learning
↓
Importancia de variables
↓
Regreso a BI para explicar los hallazgos del modelo

Hipótesis #1 — Las variables descubiertas con BI pueden predecir accidentes fatales futuros

Pregunta Analítica

¿Las variables identificadas durante la fase de Power BI ayudan a predecir si un accidente será fatal?

Diseño de Entrenamiento y Validación

DatasetPeríodoFilasPropósito
dbo.Crashes2019–20252,906,173Entrenamiento
dbo.Crashes_20262026137,524Validación independiente

Variable Objetivo

Fatal_Flag = 1 si NumberKilled > 0
Fatal_Flag = 0 si NumberKilled = 0 o NULL

Distribución de Clases

DatasetNo FatalFatalFatal %
2019–20252,879,80326,3700.91%
2026137,1034210.31%
Análisis Valorativo: Este es un problema altamente desbalanceado. La accuracy por sí sola no es una métrica adecuada, porque un modelo que prediga todo como no fatal parecería muy preciso, pero fallaría el objetivo de seguridad pública.

Modelo V1 — Validación Interna con Logistic Regression

Propósito

El primer modelo se creó como experimento base para confirmar que Python podía leer los datos desde SQL Server, preparar variables iniciales, entrenar un clasificador y detectar señal predictiva antes de utilizar la tabla 2026 como validación independiente.

Diseño de Validación

PasoDescripción
Tabla fuentedbo.Crashes
Período2019–2025
Método de validaciónDivisión interna estratificada train/test
Variable objetivoFatal_Flag
ModeloLogistic Regression con pesos balanceados

Cómo se ejecutó el Modelo V1

Comando en PowerShell:

python ml_fatal_crash_model_v1.py
Mostrar / ocultar script Python
# File: ml_fatal_crash_model_v1.py
# Purpose: Baseline model using an internal split from dbo.Crashes.

import pandas as pd
from sqlalchemy import create_engine

from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)

engine = create_engine(connection_string)

query = """
SELECT
    Source_Year,
    LightingDescription,
    Collision_Type_Description,
    PedestrianActionDesc,
    Weather_1,
    City_Name,
    Crash_Date_Time,
    Primary_Collision_Factor_Violation,
    CASE
        WHEN TRY_CAST(NumberKilled AS INT) > 0 THEN 1
        ELSE 0
    END AS Fatal_Flag
FROM dbo.Crashes
WHERE Crash_Date_Time IS NOT NULL;
"""

print("Reading data from SQL Server...")
df = pd.read_sql(query, engine)
print("Rows loaded:", len(df))
print(df["Fatal_Flag"].value_counts())

df["Crash_Date_Time"] = pd.to_datetime(df["Crash_Date_Time"], errors="coerce")
df = df.dropna(subset=["Crash_Date_Time"])
df["Crash_Hour"] = df["Crash_Date_Time"].dt.hour
df["Crash_Time_2H_Bucket"] = (df["Crash_Hour"] // 2) * 2

def violation_group(v):
    v = str(v).upper()
    if "22350" in v: return "Unsafe Speed"
    if "23152" in v: return "DUI / Alcohol"
    if "22107" in v: return "Unsafe Turn / Lane Change"
    if "21658" in v: return "Lane Violation"
    if "21802" in v: return "Stop Sign / Right of Way"
    if "21804" in v: return "Failure to Yield"
    return "Other / Unclassified"

df["Violation_Group"] = df["Primary_Collision_Factor_Violation"].apply(violation_group)

features = [
    "Source_Year",
    "LightingDescription",
    "Collision_Type_Description",
    "PedestrianActionDesc",
    "Weather_1",
    "City_Name",
    "Crash_Time_2H_Bucket",
    "Violation_Group"
]

target = "Fatal_Flag"
df_model = df[features + [target]].fillna("UNKNOWN")
X = df_model[features]
y = df_model[target]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y
)

preprocessor = ColumnTransformer(
    transformers=[("cat", OneHotEncoder(handle_unknown="ignore"), features)]
)

model = LogisticRegression(max_iter=1000, class_weight="balanced", n_jobs=-1)

pipeline = Pipeline(steps=[("preprocessor", preprocessor), ("model", model)])

print("Training model...")
pipeline.fit(X_train, y_train)

print("Predicting...")
y_pred = pipeline.predict(X_test)
y_prob = pipeline.predict_proba(X_test)[:, 1]

print("CONFUSION MATRIX")
print(confusion_matrix(y_test, y_pred))
print("CLASSIFICATION REPORT")
print(classification_report(y_test, y_pred, digits=4))
print("ROC AUC")
print(roc_auc_score(y_test, y_prob))

Resultados Observados

MétricaValor
ROC AUC0.8309
Recall clase fatal0.7305
Precision clase fatal0.0272
Accuracy0.7608

Análisis Valorativo

El Modelo V1 confirmó que las variables seleccionadas mediante BI contenían señal predictiva real. Sin embargo, todavía utilizaba una división interna de la misma tabla histórica, por lo que fue tratado como modelo base y no como validación final.

Modelo V2 — Validación Logistic Regression contra 2026

Variables Usadas

LightingDescriptionCollision_Type_DescriptionPedestrianActionDescWeather_1City_NameCrash Time 2H BucketViolation Group

Cómo se ejecutó el Modelo V2

PowerShell execution command:

python ml_fatal_crash_model_v2_train_2019_2025_test_2026.py
Show / hide Python script
# File: ml_fatal_crash_model_v2_train_2019_2025_test_2026.py
# Purpose: Train on dbo.Crashes (2019-2025) and validate on dbo.Crashes_2026.

import pandas as pd
from sqlalchemy import create_engine
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)
engine = create_engine(connection_string)

def violation_group(v):
    v = str(v).upper()
    if "22350" in v: return "Unsafe Speed"
    if "23152" in v: return "DUI / Alcohol"
    if "22107" in v: return "Unsafe Turn / Lane Change"
    if "21658" in v: return "Lane Violation"
    if "21802" in v: return "Stop Sign / Right of Way"
    if "21804" in v: return "Failure to Yield"
    return "Other / Unclassified"

def prepare_df(df):
    df["Crash_Date_Time"] = pd.to_datetime(
        df["Crash_Date_Time"],
        format="%m/%d/%Y %I:%M:%S %p",
        errors="coerce"
    )
    df = df.dropna(subset=["Crash_Date_Time"])
    df["Crash_Hour"] = df["Crash_Date_Time"].dt.hour
    df["Crash_Time_2H_Bucket"] = (df["Crash_Hour"] // 2) * 2
    df["Violation_Group"] = df["Primary_Collision_Factor_Violation"].apply(violation_group)
    return df

features = [
    "LightingDescription",
    "Collision_Type_Description",
    "PedestrianActionDesc",
    "Weather_1",
    "City_Name",
    "Crash_Time_2H_Bucket",
    "Violation_Group"
]
target = "Fatal_Flag"

query_train = """
SELECT
    LightingDescription,
    Collision_Type_Description,
    PedestrianActionDesc,
    Weather_1,
    City_Name,
    Crash_Date_Time,
    Primary_Collision_Factor_Violation,
    CASE WHEN TRY_CAST(NumberKilled AS INT) > 0 THEN 1 ELSE 0 END AS Fatal_Flag
FROM dbo.Crashes
WHERE Crash_Date_Time IS NOT NULL;
"""

query_test = """
SELECT
    LightingDescription,
    Collision_Type_Description,
    PedestrianActionDesc,
    Weather_1,
    City_Name,
    Crash_Date_Time,
    Primary_Collision_Factor_Violation,
    CASE WHEN TRY_CAST(NumberKilled AS INT) > 0 THEN 1 ELSE 0 END AS Fatal_Flag
FROM dbo.Crashes_2026
WHERE Crash_Date_Time IS NOT NULL;
"""

print("Reading TRAIN data 2019-2025...")
train_df = pd.read_sql(query_train, engine)
print("Reading TEST data 2026...")
test_df = pd.read_sql(query_test, engine)

train_df = prepare_df(train_df)
test_df = prepare_df(test_df)

train_model = train_df[features + [target]].fillna("UNKNOWN")
test_model = test_df[features + [target]].fillna("UNKNOWN")

X_train = train_model[features]
y_train = train_model[target]
X_test = test_model[features]
y_test = test_model[target]

preprocessor = ColumnTransformer(
    transformers=[("cat", OneHotEncoder(handle_unknown="ignore"), features)]
)

model = LogisticRegression(
    max_iter=1000,
    class_weight="balanced",
    n_jobs=-1
)

pipeline = Pipeline(steps=[("preprocessor", preprocessor), ("model", model)])

print("Training on 2019-2025...")
pipeline.fit(X_train, y_train)

print("Predicting 2026...")
y_pred = pipeline.predict(X_test)
y_prob = pipeline.predict_proba(X_test)[:, 1]

print("CONFUSION MATRIX")
print(confusion_matrix(y_test, y_pred))
print("CLASSIFICATION REPORT")
print(classification_report(y_test, y_pred, digits=4))
print("ROC AUC")
print(roc_auc_score(y_test, y_prob))

Resultados

MétricaValorInterpretación
ROC AUC0.8516Fuerte separación entre accidentes fatales y no fatales.
Recall Fatal76.72%El modelo identificó 323 de 421 accidentes fatales en 2026.
Precision Fatal1.00%Baja por el fuerte desbalance y los falsos positivos.
Accuracy76.69%No es la métrica principal en este problema.

Matriz de Confusión

Predicho No FatalPredicho Fatal
Real No Fatal105,14831,955
Real Fatal98323

Hallazgos

  • El modelo capturó una proporción importante de accidentes fatales en un año futuro.
  • La baja precisión indica que debe interpretarse como modelo de screening de riesgo, no como decisión final.
  • El ROC AUC confirma que las variables seleccionadas desde BI contienen señal predictiva real.

Análisis Valorativo

El resultado importante no es solo que el modelo produjo un score. Lo importante es que las variables descubiertas mediante BI lograron generalizar hacia 2026, lo cual sugiere que los patrones encontrados no eran simples accidentes del período histórico.

Modelo V3 — Random Forest Feature Importance

Se creó un Random Forest para entender qué variables codificadas aportaban más a la clasificación. El objetivo fue interpretabilidad, no solamente predicción.

Cómo se ejecutó el Modelo V3

PowerShell execution command:

python ml_fatal_crash_model_v3_random_forest_importance.py
Show / hide Python script
# File: ml_fatal_crash_model_v3_random_forest_importance.py
# Purpose: Train Random Forest and export feature importance for explainability.

import pandas as pd
from sqlalchemy import create_engine
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)
engine = create_engine(connection_string)

def violation_group(v):
    v = str(v).upper()
    if "22350" in v: return "Unsafe Speed"
    if "23152" in v: return "DUI / Alcohol"
    if "22107" in v: return "Unsafe Turn / Lane Change"
    if "21658" in v: return "Lane Violation"
    if "21802" in v: return "Stop Sign / Right of Way"
    if "21804" in v: return "Failure to Yield"
    return "Other / Unclassified"

def prepare_df(df):
    df["Crash_Date_Time"] = pd.to_datetime(
        df["Crash_Date_Time"],
        format="%m/%d/%Y %I:%M:%S %p",
        errors="coerce"
    )
    df = df.dropna(subset=["Crash_Date_Time"])
    df["Crash_Hour"] = df["Crash_Date_Time"].dt.hour
    df["Crash_Time_2H_Bucket"] = (df["Crash_Hour"] // 2) * 2
    df["Violation_Group"] = df["Primary_Collision_Factor_Violation"].apply(violation_group)
    return df

features = [
    "LightingDescription",
    "Collision_Type_Description",
    "PedestrianActionDesc",
    "Weather_1",
    "City_Name",
    "Crash_Time_2H_Bucket",
    "Violation_Group"
]
target = "Fatal_Flag"

base_query = """
SELECT
    LightingDescription,
    Collision_Type_Description,
    PedestrianActionDesc,
    Weather_1,
    City_Name,
    Crash_Date_Time,
    Primary_Collision_Factor_Violation,
    CASE WHEN TRY_CAST(NumberKilled AS INT) > 0 THEN 1 ELSE 0 END AS Fatal_Flag
FROM {table}
WHERE Crash_Date_Time IS NOT NULL;
"""

train_df = pd.read_sql(base_query.format(table="dbo.Crashes"), engine)
test_df = pd.read_sql(base_query.format(table="dbo.Crashes_2026"), engine)

train_df = prepare_df(train_df)
test_df = prepare_df(test_df)

train_model = train_df[features + [target]].fillna("UNKNOWN")
test_model = test_df[features + [target]].fillna("UNKNOWN")

X_train = train_model[features]
y_train = train_model[target]
X_test = test_model[features]
y_test = test_model[target]

preprocessor = ColumnTransformer(
    transformers=[("cat", OneHotEncoder(handle_unknown="ignore"), features)]
)

rf = RandomForestClassifier(
    n_estimators=120,
    max_depth=18,
    min_samples_leaf=50,
    class_weight="balanced",
    random_state=42,
    n_jobs=-1
)

pipeline = Pipeline(steps=[("preprocessor", preprocessor), ("model", rf)])

print("Training Random Forest on 2019-2025...")
pipeline.fit(X_train, y_train)

print("Predicting 2026...")
y_pred = pipeline.predict(X_test)
y_prob = pipeline.predict_proba(X_test)[:, 1]

print("CONFUSION MATRIX")
print(confusion_matrix(y_test, y_pred))
print("CLASSIFICATION REPORT")
print(classification_report(y_test, y_pred, digits=4))
print("ROC AUC")
print(roc_auc_score(y_test, y_prob))

print("Extracting feature importance...")
ohe = pipeline.named_steps["preprocessor"].named_transformers_["cat"]
feature_names = ohe.get_feature_names_out(features)
importances = pipeline.named_steps["model"].feature_importances_

importance_df = pd.DataFrame({"Feature": feature_names, "Importance": importances}) \
    .sort_values("Importance", ascending=False)

print("TOP 40 FEATURE IMPORTANCE")
print(importance_df.head(40).to_string(index=False))
importance_df.to_excel("rf_feature_importance_v3.xlsx", index=False)
print("DONE - Feature importance exported to rf_feature_importance_v3.xlsx")

Top Feature Importance

RankFeatureImportance
1Collision_Type_Description_VEHICLE/PEDESTRIAN0.120879
2PedestrianActionDesc_NO PEDESTRIANS INVOLVED0.115784
3Collision_Type_Description_SIDE SWIPE0.101700
4Collision_Type_Description_REAR END0.095826
5PedestrianActionDesc_IN ROAD - INCLUDES SHOULDER0.051558
6PedestrianActionDesc_CROSSING - NOT IN CROSSWALK0.049133
7Violation_Group_DUI / Alcohol0.043638
8City_Name_Unincorporated0.042127
9LightingDescription_DARK-NO STREET LIGHTS0.041705
10LightingDescription_DAYLIGHT0.037810

Hallazgos

  • La variable más importante coincide con un hallazgo central de BI: Vehicle/Pedestrian.
  • Las variables de comportamiento peatonal aparecen varias veces en el ranking.
  • DUI / Alcohol aparece como feature importante, confirmando su relación con severidad.
  • Lighting aparece en el Top 10, especialmente Dark-No Street Lights.
  • City_Name_Unincorporated aparece inesperadamente como variable importante, abriendo una nueva línea de investigación.

Análisis Valorativo

Random Forest no reemplazó al BI. Lo validó y lo extendió. La mayoría de las variables importantes ya habían sido descubiertas en Power BI. La aparición inesperada de Unincorporated mostró cómo el Machine Learning puede devolver nuevas preguntas al análisis de negocio.

Hipótesis #2 — Las zonas Unincorporated representan un ecosistema de riesgo distinto

Origen de la Hipótesis

Random Forest ubicó City_Name_Unincorporated entre las variables más importantes. Como no era una hipótesis inicial, regresamos a Power BI para explicar qué estaba viendo el modelo.

Línea Base por Ciudad

CiudadAccidentesFallecidos% del Total de FallecidosFatality Rate
Unincorporated805,22812,15242.61%1.51%
Los Angeles241,8662,2477.88%0.93%
San Diego59,0727472.62%1.26%

Hallazgos

  • Unincorporated representa 27.71% de los accidentes pero 42.61% de las muertes.
  • Su Fatality Rate es 1.51%, frente a 0.98% estatal.
  • Esto justifica que el modelo le asignara importancia elevada.

Análisis Valorativo

Unincorporated parece funcionar como proxy de un entorno geográfico y operacional: vías menos urbanizadas, carreteras del condado, corredores de mayor velocidad, menor iluminación y mecanismos de colisión más severos.

Investigación BI Post-Modelo — Perfil de Unincorporated

Tipo de Colisión dentro de Unincorporated

Tipo de ColisiónAccidentesFallecidosFatality Rate
HIT OBJECT222,3183,4901.57%
HEAD-ON28,1052,1637.70%
BROADSIDE120,1271,7381.45%
VEHICLE/PEDESTRIAN11,5641,71314.81%
OVERTURNED43,5961,3683.14%

Violation Group dentro de Unincorporated

Violation GroupAccidentesFallecidosFatality Rate
Other / Unclassified203,8053,8001.86%
DUI / Alcohol78,5903,2994.20%
Unsafe Turn / Lane Change217,8253,0301.39%
Unsafe Speed210,2991,4970.71%

Iluminación dentro de Unincorporated

IluminaciónAccidentesFallecidosFatality Rate
DAYLIGHT501,5045,5511.11%
DARK-NO STREET LIGHTS161,5534,5422.81%
DARK-STREET LIGHTS107,9471,4231.32%
DUSK-DAWN32,5426061.86%

Patrón Horario dentro de Unincorporated

HorarioAccidentesFallecidosFatality Rate
00:00 - 02:0052,6151,3392.54%
02:00 - 04:0028,8087232.51%
22:00 - 00:0048,1411,0952.27%
20:00 - 22:0062,2131,3172.12%

Hallazgos

  • Hit Object es el mayor contribuyente de muertes dentro de Unincorporated.
  • Head-On tiene una tasa muy alta de 7.70%.
  • Vehicle/Pedestrian mantiene una severidad extrema de 14.81%.
  • DUI / Alcohol sube a 4.20%, frente a 2.48% estatal para DUI.
  • Dark-No Street Lights alcanza 2.81%, casi tres veces el promedio estatal general.
  • La noche y madrugada mantienen alto riesgo.

Hallazgo Maestro — Ecosistemas de Riesgo

El descubrimiento más importante de esta fase es que el riesgo fatal no se explica por una sola variable. Emerge de combinaciones de contexto, conducta, ambiente y geografía.

Unincorporated
+
Dark-No Street Lights
+
DUI / Alcohol
+
Night / Early Morning
+
Head-On or Hit Object
=
Ecosistema de alto riesgo

Análisis Valorativo

El modelo no identificó simplemente una etiqueta de ciudad. Identificó un proxy geográfico de riesgo. Power BI permitió interpretar esa señal revelando las condiciones subyacentes asociadas a la severidad en zonas Unincorporated.

Conclusión

Este tópico demuestra un ciclo analítico completo: Business Intelligence identificó patrones fuertes, Machine Learning validó su capacidad predictiva contra un año futuro, y la importancia de variables generó una nueva pregunta que fue investigada nuevamente con BI.

El resultado metodológico más importante es claro: BI y ML no compiten. Se refuerzan mutuamente.

BI descubre patrones.
ML prueba señal predictiva.
Feature importance genera nuevas preguntas.
BI explica el modelo.