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.
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
Can the variables identified during the Power BI discovery phase help predict whether a crash will be fatal?
| Dataset | Period | Rows | Purpose |
|---|---|---|---|
| dbo.Crashes | 2019–2025 | 2,906,173 | Model training |
| dbo.Crashes_2026 | 2026 | 137,524 | Independent validation |
Fatal_Flag = 1 if NumberKilled > 0 Fatal_Flag = 0 if NumberKilled = 0 or NULL
| Dataset | Non-Fatal | Fatal | Fatal % |
|---|---|---|---|
| 2019–2025 | 2,879,803 | 26,370 | 0.91% |
| 2026 | 137,103 | 421 | 0.31% |
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.
| Step | Description |
|---|---|
| Source table | dbo.Crashes |
| Period | 2019–2025 |
| Validation method | Internal stratified train/test split |
| Target | Fatal_Flag |
| Model | Logistic Regression with balanced class weights |
PowerShell execution command:
python ml_fatal_crash_model_v1.py
# 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))
| Metric | Value |
|---|---|
| ROC AUC | 0.8309 |
| Fatal Recall | 0.7305 |
| Fatal Precision | 0.0272 |
| Accuracy | 0.7608 |
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.
LightingDescriptionCollision_Type_DescriptionPedestrianActionDescWeather_1City_NameCrash Time 2H BucketViolation Group
PowerShell execution command:
python ml_fatal_crash_model_v2_train_2019_2025_test_2026.py
# 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))
| Metric | Value | Interpretation |
|---|---|---|
| ROC AUC | 0.8516 | Strong separation between fatal and non-fatal crashes. |
| Fatal Recall | 76.72% | The model identified 323 of 421 fatal crashes in 2026. |
| Fatal Precision | 1.00% | Low due to extreme class imbalance and many false positives. |
| Accuracy | 76.69% | Not the primary metric for this problem. |
| Predicted Non-Fatal | Predicted Fatal | |
|---|---|---|
| Actual Non-Fatal | 105,148 | 31,955 |
| Actual Fatal | 98 | 323 |
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.
A Random Forest model was created to understand which encoded variables contributed most to the classification task. The purpose was explainability, not only prediction.
PowerShell execution command:
python ml_fatal_crash_model_v3_random_forest_importance.py
# 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")
| Rank | Feature | Importance |
|---|---|---|
| 1 | Collision_Type_Description_VEHICLE/PEDESTRIAN | 0.120879 |
| 2 | PedestrianActionDesc_NO PEDESTRIANS INVOLVED | 0.115784 |
| 3 | Collision_Type_Description_SIDE SWIPE | 0.101700 |
| 4 | Collision_Type_Description_REAR END | 0.095826 |
| 5 | PedestrianActionDesc_IN ROAD - INCLUDES SHOULDER | 0.051558 |
| 6 | PedestrianActionDesc_CROSSING - NOT IN CROSSWALK | 0.049133 |
| 7 | Violation_Group_DUI / Alcohol | 0.043638 |
| 8 | City_Name_Unincorporated | 0.042127 |
| 9 | LightingDescription_DARK-NO STREET LIGHTS | 0.041705 |
| 10 | LightingDescription_DAYLIGHT | 0.037810 |
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.
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 | Crashes | Killed | % of Total Killed | Fatality Rate |
|---|---|---|---|---|
| Unincorporated | 805,228 | 12,152 | 42.61% | 1.51% |
| Los Angeles | 241,866 | 2,247 | 7.88% | 0.93% |
| San Diego | 59,072 | 747 | 2.62% | 1.26% |
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.
| Collision Type | Crashes | Killed | Fatality Rate |
|---|---|---|---|
| HIT OBJECT | 222,318 | 3,490 | 1.57% |
| HEAD-ON | 28,105 | 2,163 | 7.70% |
| BROADSIDE | 120,127 | 1,738 | 1.45% |
| VEHICLE/PEDESTRIAN | 11,564 | 1,713 | 14.81% |
| OVERTURNED | 43,596 | 1,368 | 3.14% |
| Violation Group | Crashes | Killed | Fatality Rate |
|---|---|---|---|
| Other / Unclassified | 203,805 | 3,800 | 1.86% |
| DUI / Alcohol | 78,590 | 3,299 | 4.20% |
| Unsafe Turn / Lane Change | 217,825 | 3,030 | 1.39% |
| Unsafe Speed | 210,299 | 1,497 | 0.71% |
| Lighting | Crashes | Killed | Fatality Rate |
|---|---|---|---|
| DAYLIGHT | 501,504 | 5,551 | 1.11% |
| DARK-NO STREET LIGHTS | 161,553 | 4,542 | 2.81% |
| DARK-STREET LIGHTS | 107,947 | 1,423 | 1.32% |
| DUSK-DAWN | 32,542 | 606 | 1.86% |
| Time Bucket | Crashes | Killed | Fatality Rate |
|---|---|---|---|
| 00:00 - 02:00 | 52,615 | 1,339 | 2.54% |
| 02:00 - 04:00 | 28,808 | 723 | 2.51% |
| 22:00 - 00:00 | 48,141 | 1,095 | 2.27% |
| 20:00 - 22:00 | 62,213 | 1,317 | 2.12% |
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
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.
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.
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.
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
¿Las variables identificadas durante la fase de Power BI ayudan a predecir si un accidente será fatal?
| Dataset | Período | Filas | Propósito |
|---|---|---|---|
| dbo.Crashes | 2019–2025 | 2,906,173 | Entrenamiento |
| dbo.Crashes_2026 | 2026 | 137,524 | Validación independiente |
Fatal_Flag = 1 si NumberKilled > 0 Fatal_Flag = 0 si NumberKilled = 0 o NULL
| Dataset | No Fatal | Fatal | Fatal % |
|---|---|---|---|
| 2019–2025 | 2,879,803 | 26,370 | 0.91% |
| 2026 | 137,103 | 421 | 0.31% |
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.
| Paso | Descripción |
|---|---|
| Tabla fuente | dbo.Crashes |
| Período | 2019–2025 |
| Método de validación | División interna estratificada train/test |
| Variable objetivo | Fatal_Flag |
| Modelo | Logistic Regression con pesos balanceados |
Comando en PowerShell:
python ml_fatal_crash_model_v1.py
# 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))
| Métrica | Valor |
|---|---|
| ROC AUC | 0.8309 |
| Recall clase fatal | 0.7305 |
| Precision clase fatal | 0.0272 |
| Accuracy | 0.7608 |
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.
LightingDescriptionCollision_Type_DescriptionPedestrianActionDescWeather_1City_NameCrash Time 2H BucketViolation Group
PowerShell execution command:
python ml_fatal_crash_model_v2_train_2019_2025_test_2026.py
# 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))
| Métrica | Valor | Interpretación |
|---|---|---|
| ROC AUC | 0.8516 | Fuerte separación entre accidentes fatales y no fatales. |
| Recall Fatal | 76.72% | El modelo identificó 323 de 421 accidentes fatales en 2026. |
| Precision Fatal | 1.00% | Baja por el fuerte desbalance y los falsos positivos. |
| Accuracy | 76.69% | No es la métrica principal en este problema. |
| Predicho No Fatal | Predicho Fatal | |
|---|---|---|
| Real No Fatal | 105,148 | 31,955 |
| Real Fatal | 98 | 323 |
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.
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.
PowerShell execution command:
python ml_fatal_crash_model_v3_random_forest_importance.py
# 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")
| Rank | Feature | Importance |
|---|---|---|
| 1 | Collision_Type_Description_VEHICLE/PEDESTRIAN | 0.120879 |
| 2 | PedestrianActionDesc_NO PEDESTRIANS INVOLVED | 0.115784 |
| 3 | Collision_Type_Description_SIDE SWIPE | 0.101700 |
| 4 | Collision_Type_Description_REAR END | 0.095826 |
| 5 | PedestrianActionDesc_IN ROAD - INCLUDES SHOULDER | 0.051558 |
| 6 | PedestrianActionDesc_CROSSING - NOT IN CROSSWALK | 0.049133 |
| 7 | Violation_Group_DUI / Alcohol | 0.043638 |
| 8 | City_Name_Unincorporated | 0.042127 |
| 9 | LightingDescription_DARK-NO STREET LIGHTS | 0.041705 |
| 10 | LightingDescription_DAYLIGHT | 0.037810 |
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.
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.
| Ciudad | Accidentes | Fallecidos | % del Total de Fallecidos | Fatality Rate |
|---|---|---|---|---|
| Unincorporated | 805,228 | 12,152 | 42.61% | 1.51% |
| Los Angeles | 241,866 | 2,247 | 7.88% | 0.93% |
| San Diego | 59,072 | 747 | 2.62% | 1.26% |
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.
| Tipo de Colisión | Accidentes | Fallecidos | Fatality Rate |
|---|---|---|---|
| HIT OBJECT | 222,318 | 3,490 | 1.57% |
| HEAD-ON | 28,105 | 2,163 | 7.70% |
| BROADSIDE | 120,127 | 1,738 | 1.45% |
| VEHICLE/PEDESTRIAN | 11,564 | 1,713 | 14.81% |
| OVERTURNED | 43,596 | 1,368 | 3.14% |
| Violation Group | Accidentes | Fallecidos | Fatality Rate |
|---|---|---|---|
| Other / Unclassified | 203,805 | 3,800 | 1.86% |
| DUI / Alcohol | 78,590 | 3,299 | 4.20% |
| Unsafe Turn / Lane Change | 217,825 | 3,030 | 1.39% |
| Unsafe Speed | 210,299 | 1,497 | 0.71% |
| Iluminación | Accidentes | Fallecidos | Fatality Rate |
|---|---|---|---|
| DAYLIGHT | 501,504 | 5,551 | 1.11% |
| DARK-NO STREET LIGHTS | 161,553 | 4,542 | 2.81% |
| DARK-STREET LIGHTS | 107,947 | 1,423 | 1.32% |
| DUSK-DAWN | 32,542 | 606 | 1.86% |
| Horario | Accidentes | Fallecidos | Fatality Rate |
|---|---|---|---|
| 00:00 - 02:00 | 52,615 | 1,339 | 2.54% |
| 02:00 - 04:00 | 28,808 | 723 | 2.51% |
| 22:00 - 00:00 | 48,141 | 1,095 | 2.27% |
| 20:00 - 22:00 | 62,213 | 1,317 | 2.12% |
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
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.
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.