593b94c120
pytest / Unit Tests (push) Has been cancelled
pytest / Integration (integration_tests_a) (push) Has been cancelled
pytest / Integration (integration_tests_b) (push) Has been cancelled
pytest / Integration (integration_tests_c) (push) Has been cancelled
pytest / Integration (integration_tests_d) (push) Has been cancelled
pytest / Integration (integration_tests_e) (push) Has been cancelled
pytest / Integration (integration_tests_f) (push) Has been cancelled
pytest / Integration (integration_tests_g) (push) Has been cancelled
pytest / Integration (integration_tests_h) (push) Has been cancelled
pytest / Integration (integration_tests_i) (push) Has been cancelled
pytest / Integration (integration_tests_j) (push) Has been cancelled
pytest / Distributed (distributed_a) (push) Has been cancelled
pytest / Distributed (distributed_b) (push) Has been cancelled
pytest / Distributed (distributed_c) (push) Has been cancelled
pytest / Distributed (distributed_d) (push) Has been cancelled
pytest / Distributed (distributed_e) (push) Has been cancelled
pytest / Distributed (distributed_f) (push) Has been cancelled
pytest / Minimal Install (push) Has been cancelled
pytest / Event File (push) Has been cancelled
pytest (slow) / py-slow (push) Has been cancelled
Publish JSON Schema / publish-schema (push) Has been cancelled
340 lines
10 KiB
Python
340 lines
10 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Uncertainty Quantification with Ludwig: MC Dropout and Temperature Scaling.
|
|
|
|
Trains three models on the UCI Wine Quality dataset and compares:
|
|
1. Baseline — no calibration
|
|
2. Temperature Scaling — post-hoc calibration via a learned temperature scalar
|
|
3. MC Dropout — per-sample uncertainty estimates via stochastic inference
|
|
|
|
Usage:
|
|
python train.py
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from ludwig.api import LudwigModel
|
|
|
|
logging.basicConfig(level=logging.WARNING)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dataset
|
|
# ---------------------------------------------------------------------------
|
|
|
|
WINE_URL = "https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-red.csv"
|
|
|
|
WINE_FEATURES = [
|
|
"fixed_acidity",
|
|
"volatile_acidity",
|
|
"citric_acid",
|
|
"residual_sugar",
|
|
"chlorides",
|
|
"free_sulfur_dioxide",
|
|
"total_sulfur_dioxide",
|
|
"density",
|
|
"pH",
|
|
"sulphates",
|
|
"alcohol",
|
|
]
|
|
|
|
|
|
def load_dataset() -> pd.DataFrame:
|
|
"""Download and prepare the wine quality dataset."""
|
|
print("Downloading wine quality dataset...")
|
|
df = pd.read_csv(WINE_URL, sep=";")
|
|
# Rename columns: replace spaces with underscores
|
|
df.columns = [c.replace(" ", "_") for c in df.columns]
|
|
# Binarise: quality >= 7 is "good" (True), otherwise "bad" (False)
|
|
df["quality"] = (df["quality"] >= 7).astype(int)
|
|
print(f" {len(df)} rows | positive class (quality>=7): {df['quality'].mean():.1%}")
|
|
return df
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ludwig configs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _input_features() -> list:
|
|
return [{"name": feat, "type": "number", "preprocessing": {"normalization": "zscore"}} for feat in WINE_FEATURES]
|
|
|
|
|
|
BASE_CONFIG = {
|
|
"model_type": "ecd",
|
|
"input_features": _input_features(),
|
|
"output_features": [
|
|
{
|
|
"name": "quality",
|
|
"type": "binary",
|
|
"decoder": {
|
|
"type": "mlp_classifier",
|
|
"num_fc_layers": 1,
|
|
"output_size": 64,
|
|
"dropout": 0.1,
|
|
},
|
|
"loss": {"type": "binary_weighted_cross_entropy"},
|
|
}
|
|
],
|
|
"combiner": {
|
|
"type": "concat",
|
|
"num_fc_layers": 2,
|
|
"output_size": 128,
|
|
"dropout": 0.1,
|
|
},
|
|
"trainer": {"epochs": 30, "learning_rate": 0.001, "batch_size": 128},
|
|
}
|
|
|
|
CALIBRATED_CONFIG = {
|
|
**BASE_CONFIG,
|
|
"output_features": [
|
|
{
|
|
**BASE_CONFIG["output_features"][0],
|
|
"decoder": {
|
|
**BASE_CONFIG["output_features"][0]["decoder"],
|
|
"calibration": "temperature_scaling",
|
|
},
|
|
}
|
|
],
|
|
}
|
|
|
|
MC_DROPOUT_CONFIG = {
|
|
**BASE_CONFIG,
|
|
"output_features": [
|
|
{
|
|
**BASE_CONFIG["output_features"][0],
|
|
"decoder": {
|
|
**BASE_CONFIG["output_features"][0]["decoder"],
|
|
"dropout": 0.3,
|
|
"mc_dropout_samples": 20,
|
|
},
|
|
}
|
|
],
|
|
"combiner": {
|
|
**BASE_CONFIG["combiner"],
|
|
"dropout": 0.2,
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calibration metrics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def expected_calibration_error(
|
|
probabilities: np.ndarray,
|
|
labels: np.ndarray,
|
|
n_bins: int = 10,
|
|
) -> float:
|
|
"""Compute Expected Calibration Error (ECE).
|
|
|
|
Args:
|
|
probabilities: Predicted probability for the positive class, shape (N,).
|
|
labels: Ground-truth binary labels, shape (N,).
|
|
n_bins: Number of equally-spaced confidence bins.
|
|
|
|
Returns:
|
|
ECE as a float in [0, 1].
|
|
"""
|
|
bins = np.linspace(0.0, 1.0, n_bins + 1)
|
|
ece = 0.0
|
|
n = len(probabilities)
|
|
for lo, hi in zip(bins[:-1], bins[1:]):
|
|
mask = (probabilities >= lo) & (probabilities < hi)
|
|
if mask.sum() == 0:
|
|
continue
|
|
conf = probabilities[mask].mean()
|
|
acc = labels[mask].mean()
|
|
ece += mask.sum() / n * abs(conf - acc)
|
|
return float(ece)
|
|
|
|
|
|
def reliability_diagram(
|
|
probabilities_dict: dict,
|
|
labels: np.ndarray,
|
|
n_bins: int = 10,
|
|
output_path: str | None = None,
|
|
) -> None:
|
|
"""Plot reliability diagrams for one or more models.
|
|
|
|
Args:
|
|
probabilities_dict: Mapping from model name to probability arrays.
|
|
labels: Ground-truth binary labels.
|
|
n_bins: Number of confidence bins.
|
|
output_path: If provided, save the figure to this path.
|
|
"""
|
|
bins = np.linspace(0.0, 1.0, n_bins + 1)
|
|
bin_centers = 0.5 * (bins[:-1] + bins[1:])
|
|
|
|
fig, ax = plt.subplots(figsize=(6, 6))
|
|
ax.plot([0, 1], [0, 1], "k--", label="Perfect calibration", linewidth=1.5)
|
|
|
|
colors = ["tab:red", "tab:blue", "tab:green"]
|
|
for (name, probs), color in zip(probabilities_dict.items(), colors):
|
|
accs = []
|
|
for lo, hi in zip(bins[:-1], bins[1:]):
|
|
mask = (probs >= lo) & (probs < hi)
|
|
if mask.sum() == 0:
|
|
accs.append(float("nan"))
|
|
else:
|
|
accs.append(labels[mask].mean())
|
|
ece = expected_calibration_error(probs, labels, n_bins)
|
|
ax.plot(
|
|
bin_centers,
|
|
accs,
|
|
marker="o",
|
|
label=f"{name} (ECE={ece:.3f})",
|
|
color=color,
|
|
)
|
|
|
|
ax.set_xlabel("Mean predicted probability")
|
|
ax.set_ylabel("Fraction of positives")
|
|
ax.set_title("Reliability Diagram")
|
|
ax.legend(loc="upper left")
|
|
ax.set_xlim(0, 1)
|
|
ax.set_ylim(0, 1)
|
|
plt.tight_layout()
|
|
|
|
if output_path:
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
plt.savefig(output_path, dpi=150)
|
|
print(f" Saved reliability diagram to {output_path}")
|
|
else:
|
|
plt.show()
|
|
plt.close(fig)
|
|
|
|
|
|
def uncertainty_histogram(
|
|
uncertainty: np.ndarray,
|
|
output_path: str | None = None,
|
|
) -> None:
|
|
"""Plot distribution of MC Dropout uncertainty estimates."""
|
|
fig, ax = plt.subplots(figsize=(6, 4))
|
|
ax.hist(uncertainty, bins=40, edgecolor="white", color="tab:green")
|
|
ax.set_xlabel("Uncertainty (variance across MC samples)")
|
|
ax.set_ylabel("Count")
|
|
ax.set_title("MC Dropout Uncertainty Distribution")
|
|
plt.tight_layout()
|
|
if output_path:
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
plt.savefig(output_path, dpi=150)
|
|
print(f" Saved uncertainty histogram to {output_path}")
|
|
else:
|
|
plt.show()
|
|
plt.close(fig)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Training helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def train_and_evaluate(
|
|
name: str,
|
|
config: dict,
|
|
dataset: pd.DataFrame,
|
|
output_dir: str,
|
|
) -> tuple[LudwigModel, pd.DataFrame, np.ndarray]:
|
|
"""Train a Ludwig model and return predictions on the test split.
|
|
|
|
Returns:
|
|
(model, predictions_df, labels)
|
|
"""
|
|
result_dir = os.path.join(output_dir, name)
|
|
shutil.rmtree(result_dir, ignore_errors=True)
|
|
|
|
print(f"\n--- Training: {name} ---")
|
|
model = LudwigModel(config=config, logging_level=logging.WARNING)
|
|
model.train(
|
|
dataset=dataset,
|
|
experiment_name="uncertainty",
|
|
model_name=name,
|
|
output_directory=result_dir,
|
|
)
|
|
|
|
print(f" Evaluating {name}...")
|
|
_, predictions, _ = model.predict(dataset=dataset)
|
|
return model, predictions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main():
|
|
output_dir = "./results"
|
|
viz_dir = "./visualizations"
|
|
os.makedirs(viz_dir, exist_ok=True)
|
|
|
|
# Load dataset
|
|
df = load_dataset()
|
|
labels = df["quality"].values.astype(int)
|
|
|
|
# Train models
|
|
_, baseline_preds = train_and_evaluate("baseline", BASE_CONFIG, df, output_dir)
|
|
_, calibrated_preds = train_and_evaluate("calibrated", CALIBRATED_CONFIG, df, output_dir)
|
|
mc_model, mc_preds = train_and_evaluate("mc_dropout", MC_DROPOUT_CONFIG, df, output_dir)
|
|
|
|
# Extract probabilities
|
|
baseline_probs = baseline_preds["quality_probability_True"].values
|
|
calibrated_probs = calibrated_preds["quality_probability_True"].values
|
|
mc_probs = mc_preds["quality_probability_True"].values
|
|
|
|
# Compute ECE
|
|
baseline_ece = expected_calibration_error(baseline_probs, labels)
|
|
calibrated_ece = expected_calibration_error(calibrated_probs, labels)
|
|
mc_ece = expected_calibration_error(mc_probs, labels)
|
|
|
|
print("\n=== Expected Calibration Error (ECE) ===")
|
|
print(f" Baseline: ECE = {baseline_ece:.4f}")
|
|
print(f" Temperature Scaling: ECE = {calibrated_ece:.4f}")
|
|
print(f" MC Dropout: ECE = {mc_ece:.4f}")
|
|
print()
|
|
|
|
# Reliability diagram
|
|
reliability_diagram(
|
|
{
|
|
"Baseline": baseline_probs,
|
|
"Temperature Scaling": calibrated_probs,
|
|
},
|
|
labels,
|
|
output_path=os.path.join(viz_dir, "reliability_diagram.png"),
|
|
)
|
|
|
|
# MC Dropout uncertainty
|
|
if "quality_uncertainty" in mc_preds.columns:
|
|
uncertainty = mc_preds["quality_uncertainty"].values
|
|
print("MC Dropout uncertainty stats:")
|
|
print(f" mean={uncertainty.mean():.4f}, std={uncertainty.std():.4f}, max={uncertainty.max():.4f}")
|
|
|
|
uncertainty_histogram(
|
|
uncertainty,
|
|
output_path=os.path.join(viz_dir, "mc_dropout_uncertainty.png"),
|
|
)
|
|
|
|
# Show high-uncertainty predictions
|
|
threshold = np.percentile(uncertainty, 80)
|
|
high_unc_mask = uncertainty >= threshold
|
|
print(f"\nHigh-uncertainty samples (top 20%, threshold={threshold:.4f}):")
|
|
high_unc_preds = mc_preds["quality_predictions"].values[high_unc_mask].astype(bool)
|
|
high_unc_labels = labels[high_unc_mask].astype(bool)
|
|
high_unc_acc = (high_unc_preds == high_unc_labels).mean()
|
|
print(f" count={high_unc_mask.sum()}, accuracy on these samples: {high_unc_acc:.2%}")
|
|
else:
|
|
print("Note: 'quality_uncertainty' column not found in predictions.")
|
|
print("Make sure mc_dropout_samples > 0 and the decoder has dropout > 0.")
|
|
|
|
print(f"\nDone. Plots saved to {viz_dir}/")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|