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
114 lines
3.6 KiB
Python
114 lines
3.6 KiB
Python
"""Compare multi-task loss balancing strategies on a joint classification + regression task.
|
||
|
||
The dataset is UCI Wine Quality (red) with two output features:
|
||
|
||
- ``quality`` — the usual 0–10 score, trained as number regression.
|
||
- ``recommended`` — a synthetic binary target set to ``quality >= 6``, trained as binary
|
||
classification. The two outputs share everything except the final decoder head, so they
|
||
compete for the combiner's representational capacity.
|
||
|
||
For each balancer in :data:`STRATEGIES` the script trains the same model end-to-end and
|
||
records validation metrics. The summary table prints the per-task scores plus a
|
||
balance-aware geometric mean so you can see which strategy gets both tasks right.
|
||
|
||
Requires Ludwig 0.15 / PR #4092 for ``nash_mtl``.
|
||
|
||
Run: ``python compare_balancers.py``
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import math
|
||
from pathlib import Path
|
||
|
||
import pandas as pd
|
||
import yaml
|
||
|
||
from ludwig.api import LudwigModel
|
||
from ludwig.datasets import wine_quality
|
||
|
||
HERE = Path(__file__).parent
|
||
|
||
# Strategies to compare. nash_mtl is included only on the future-capabilities branch.
|
||
STRATEGIES = [
|
||
"none",
|
||
"log_transform",
|
||
"uncertainty",
|
||
"famo",
|
||
"gradnorm",
|
||
"nash_mtl",
|
||
]
|
||
|
||
|
||
def add_binary_target(df: pd.DataFrame) -> pd.DataFrame:
|
||
df = df.copy()
|
||
df["recommended"] = (df["quality"] >= 6).astype(int)
|
||
return df
|
||
|
||
|
||
def build_config(balancer: str) -> dict:
|
||
with (HERE / "config_nash_mtl.yaml").open() as f:
|
||
config = yaml.safe_load(f)
|
||
config["trainer"]["loss_balancing"] = balancer
|
||
return config
|
||
|
||
|
||
def run(balancer: str, dataset: pd.DataFrame) -> dict[str, float]:
|
||
config = build_config(balancer)
|
||
model = LudwigModel(config=config, logging_level=logging.WARNING)
|
||
result = model.train(
|
||
dataset=dataset,
|
||
output_directory=str(HERE / f"results_{balancer}"),
|
||
skip_save_processed_input=True,
|
||
skip_save_progress=True,
|
||
skip_save_unprocessed_output=True,
|
||
skip_save_predictions=True,
|
||
skip_save_model=True,
|
||
)
|
||
val = result.train_stats.validation or {}
|
||
|
||
quality_rmse = min(val["quality"].get("root_mean_squared_error", [float("nan")]))
|
||
recommended_acc = max(val["recommended"].get("accuracy", [float("nan")]))
|
||
quality_loss = min(val["quality"].get("loss", [float("nan")]))
|
||
recommended_loss = min(val["recommended"].get("loss", [float("nan")]))
|
||
|
||
# Geometric mean of losses is a balance-aware aggregate: a strategy that wrecks one task
|
||
# to win the other pays more than a strategy that keeps both reasonable.
|
||
geomean = math.sqrt(quality_loss * recommended_loss) if quality_loss and recommended_loss else float("nan")
|
||
return {
|
||
"quality_rmse": quality_rmse,
|
||
"recommended_acc": recommended_acc,
|
||
"geomean_loss": geomean,
|
||
}
|
||
|
||
|
||
def main() -> None:
|
||
dataset = add_binary_target(wine_quality.load())
|
||
|
||
rows = []
|
||
for balancer in STRATEGIES:
|
||
print(f"\n=== Training with loss_balancing: {balancer} ===")
|
||
try:
|
||
scores = run(balancer, dataset)
|
||
except Exception as exc:
|
||
print(f"[skip] {balancer}: {exc}")
|
||
continue
|
||
rows.append({"balancer": balancer, **scores})
|
||
|
||
if not rows:
|
||
raise SystemExit("No balancer runs completed successfully.")
|
||
|
||
summary = pd.DataFrame(rows).set_index("balancer")
|
||
summary = summary.sort_values("geomean_loss")
|
||
print("\nResults (best-of-training per task, sorted by geomean_loss):")
|
||
print(summary.to_string(float_format=lambda v: f"{v:.4f}"))
|
||
|
||
csv_path = HERE / "balancer_comparison.csv"
|
||
summary.to_csv(csv_path)
|
||
print(f"\nWrote {csv_path}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|