593b94c120
pytest / Unit Tests (push) Waiting to run
pytest / Integration (integration_tests_a) (push) Waiting to run
pytest / Integration (integration_tests_b) (push) Waiting to run
pytest / Integration (integration_tests_c) (push) Waiting to run
pytest / Integration (integration_tests_d) (push) Waiting to run
pytest / Integration (integration_tests_e) (push) Waiting to run
pytest / Integration (integration_tests_f) (push) Waiting to run
pytest / Integration (integration_tests_g) (push) Waiting to run
pytest / Integration (integration_tests_h) (push) Waiting to run
pytest / Integration (integration_tests_i) (push) Waiting to run
pytest / Integration (integration_tests_j) (push) Waiting to run
pytest / Distributed (distributed_a) (push) Waiting to run
pytest / Distributed (distributed_b) (push) Waiting to run
pytest / Distributed (distributed_c) (push) Waiting to run
pytest / Distributed (distributed_d) (push) Waiting to run
pytest / Distributed (distributed_e) (push) Waiting to run
pytest / Distributed (distributed_f) (push) Waiting to run
pytest / Minimal Install (push) Waiting to run
pytest / Event File (push) Waiting to run
pytest (slow) / py-slow (push) Waiting to run
Publish JSON Schema / publish-schema (push) Waiting to run
238 lines
7.5 KiB
Python
238 lines
7.5 KiB
Python
#!/usr/bin/env python
|
||
"""Multi-Task Learning with Loss Balancing in Ludwig.
|
||
|
||
Trains four models on the UCI Wine Quality dataset with two output features:
|
||
- quality_score : raw 0-10 quality score (regression)
|
||
- quality_binary : quality >= 7 is "good" (binary classification)
|
||
|
||
Compares loss balancing strategies:
|
||
1. none — static weighted sum (baseline)
|
||
2. famo — Fast Adaptive Multitask Optimization (available now)
|
||
3. uncertainty — Homoscedastic uncertainty weighting (available now)
|
||
4. nash_mtl — Nash bargaining solution (requires PR #4092)
|
||
|
||
# Colab: !pip install ludwig
|
||
|
||
Usage:
|
||
python train_multi_task.py
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
import shutil
|
||
import warnings
|
||
|
||
import pandas as pd
|
||
|
||
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 dual-output wine quality dataset."""
|
||
print("Downloading wine quality dataset...")
|
||
df = pd.read_csv(WINE_URL, sep=";")
|
||
df.columns = [c.replace(" ", "_") for c in df.columns]
|
||
# quality_score: keep the raw 0-10 numerical score
|
||
df["quality_score"] = df["quality"].astype(float)
|
||
# quality_binary: 1 if quality >= 7 (good wine), else 0
|
||
df["quality_binary"] = (df["quality"] >= 7).astype(int)
|
||
df = df.drop(columns=["quality"])
|
||
print(f" {len(df)} rows | good wines (quality >= 7): {df['quality_binary'].mean():.1%}")
|
||
print(f" quality_score range: {df['quality_score'].min():.0f} – {df['quality_score'].max():.0f}")
|
||
return df
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ludwig config helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _input_features() -> list:
|
||
return [{"name": feat, "type": "number", "preprocessing": {"normalization": "zscore"}} for feat in WINE_FEATURES]
|
||
|
||
|
||
def _base_config(loss_balancing: str) -> dict:
|
||
return {
|
||
"model_type": "ecd",
|
||
"input_features": _input_features(),
|
||
"output_features": [
|
||
{"name": "quality_score", "type": "number"},
|
||
{"name": "quality_binary", "type": "binary"},
|
||
],
|
||
"combiner": {
|
||
"type": "concat",
|
||
"num_fc_layers": 2,
|
||
"output_size": 128,
|
||
"dropout": 0.1,
|
||
},
|
||
"trainer": {
|
||
"epochs": 30,
|
||
"learning_rate": 0.001,
|
||
"batch_size": 128,
|
||
"loss_balancing": loss_balancing,
|
||
},
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Training helper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def train_and_evaluate(
|
||
name: str,
|
||
config: dict,
|
||
dataset: pd.DataFrame,
|
||
output_dir: str,
|
||
) -> dict | None:
|
||
"""Train a Ludwig model and return evaluation metrics.
|
||
|
||
Returns a dict with metric values, or None if training failed.
|
||
"""
|
||
from ludwig.api import LudwigModel
|
||
|
||
result_dir = os.path.join(output_dir, name)
|
||
shutil.rmtree(result_dir, ignore_errors=True)
|
||
|
||
print(f"\n--- Training: {name} ---")
|
||
try:
|
||
model = LudwigModel(config=config, logging_level=logging.WARNING)
|
||
result = model.train(
|
||
dataset=dataset,
|
||
experiment_name="multi_task",
|
||
model_name=name,
|
||
output_directory=result_dir,
|
||
)
|
||
|
||
# Extract final validation metrics
|
||
metrics = {}
|
||
vset = result.train_stats.validation or {}
|
||
# quality_score: mean absolute error (lower is better)
|
||
score_metrics = vset.get("quality_score", {})
|
||
metrics["score_mae"] = _last_value(score_metrics.get("mean_absolute_error", []))
|
||
# quality_binary: ROC AUC (higher is better)
|
||
binary_metrics = vset.get("quality_binary", {})
|
||
metrics["binary_roc_auc"] = _last_value(binary_metrics.get("roc_auc", []))
|
||
return metrics
|
||
|
||
except Exception as exc:
|
||
warnings.warn(f"Training '{name}' failed: {exc}", stacklevel=2)
|
||
return None
|
||
|
||
|
||
def _last_value(series) -> float | None:
|
||
"""Return the last numeric value in a list, or None."""
|
||
if not series:
|
||
return None
|
||
val = series[-1]
|
||
if isinstance(val, (list, tuple)):
|
||
val = val[-1]
|
||
try:
|
||
return float(val)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Comparison table
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def print_comparison_table(results: dict) -> None:
|
||
"""Print a formatted side-by-side comparison of all methods."""
|
||
col_w = 14
|
||
header = f"{'Method':<{col_w}} | {'Score MAE':>{col_w}} | {'Binary ROC-AUC':>{col_w}}"
|
||
separator = "-" * len(header)
|
||
print()
|
||
print("=" * len(header))
|
||
print(" Multi-Task Loss Balancing — Comparison")
|
||
print("=" * len(header))
|
||
print(header)
|
||
print(separator)
|
||
for method, metrics in results.items():
|
||
if metrics is None:
|
||
mae_str = "FAILED"
|
||
auc_str = "FAILED"
|
||
else:
|
||
mae = metrics.get("score_mae")
|
||
auc = metrics.get("binary_roc_auc")
|
||
mae_str = f"{mae:.4f}" if mae is not None else "n/a"
|
||
auc_str = f"{auc:.4f}" if auc is not None else "n/a"
|
||
print(f"{method:<{col_w}} | {mae_str:>{col_w}} | {auc_str:>{col_w}}")
|
||
print(separator)
|
||
print(" Score MAE: lower is better | Binary ROC-AUC: higher is better")
|
||
print()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def main():
|
||
output_dir = "./results"
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
|
||
df = load_dataset()
|
||
|
||
# Methods to compare. nash_mtl is attempted but skipped gracefully if
|
||
# PR #4092 is not yet merged.
|
||
methods = [
|
||
("none", False),
|
||
("famo", False),
|
||
("uncertainty", False),
|
||
("nash_mtl", True), # requires PR #4092
|
||
]
|
||
|
||
results = {}
|
||
for method, requires_pr in methods:
|
||
if requires_pr:
|
||
print(f"\n--- Skipping {method} (requires PR #4092 / Ludwig >= 0.14) ---")
|
||
print(" To enable, install Ludwig from the 'future-capabilities' branch:")
|
||
print(" pip install git+https://github.com/ludwig-ai/ludwig@future-capabilities")
|
||
results[method] = None
|
||
continue
|
||
|
||
config = _base_config(method)
|
||
results[method] = train_and_evaluate(method, config, df, output_dir)
|
||
|
||
# Attempt nash_mtl — will succeed if PR #4092 is available
|
||
try:
|
||
from ludwig.api import LudwigModel
|
||
|
||
config = _base_config("nash_mtl")
|
||
# Try instantiating to check if nash_mtl is a valid option
|
||
model = LudwigModel(config=config, logging_level=logging.WARNING)
|
||
del model
|
||
print("\n nash_mtl is available — training now...")
|
||
results["nash_mtl"] = train_and_evaluate("nash_mtl", config, df, output_dir)
|
||
except Exception:
|
||
pass # already marked as None above
|
||
|
||
print_comparison_table(results)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|