Score every metric equally in the overall total (#218)
The overall climate-shift score was a weighted roll-up (feels-like and humidity 2x, wind and gusts 0.5x, and so on). Make it an equal average instead: every metric — and, through each metric's own mean over the 10/25/50/75/90 percentiles, every percentile — counts the same. - scoring._overall: plain mean of the present metrics' divergences, mapped to 0-100, replacing the weighted sum. Missing metrics still drop out cleanly. - Drop the now-unused per-metric weights from the METRICS table and the scored entries (nothing rendered them; only the roll-up read them). - views.SCORE_VER s4 -> s5 so cached weighted scores are recomputed. - score.js: the hero note now reads "Every metric counts equally" instead of "Temperature, feels-like and humidity are weighted most". Verified against Seattle: overall mad equals the equal mean of the eight metric mads (7.9), and the entries no longer carry a weight. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
This commit is contained in:
parent
d17ac794fd
commit
789dcb03d4
3 changed files with 36 additions and 25 deletions
|
|
@ -7,8 +7,8 @@ between where it lands and ``q`` — in percentile points, so it is unit-free an
|
||||||
comparable across metrics — is the divergence. A metric's score is the mean
|
comparable across metrics — is the divergence. A metric's score is the mean
|
||||||
absolute divergence over the categories, mapped to 0-100; its sign (bias) says
|
absolute divergence over the categories, mapped to 0-100; its sign (bias) says
|
||||||
which way it drifted. Metrics are computed per meteorological season and the
|
which way it drifted. Metrics are computed per meteorological season and the
|
||||||
seasonal differentials are averaged into an annual view, then weighted (temps /
|
seasonal differentials are averaged into an annual view, then averaged equally
|
||||||
humidity / feels-like heaviest) into one overall score.
|
across all metrics into one overall score.
|
||||||
|
|
||||||
Baseline is the ENTIRE record, including the recent years — the same all-years
|
Baseline is the ENTIRE record, including the recent years — the same all-years
|
||||||
climatology every other view grades against. The recent window's ~13% overlap
|
climatology every other view grades against. The recent window's ~13% overlap
|
||||||
|
|
@ -26,21 +26,20 @@ SEASONS = {"djf": (12, 1, 2), "mam": (3, 4, 5),
|
||||||
"jja": (6, 7, 8), "son": (9, 10, 11)}
|
"jja": (6, 7, 8), "son": (9, 10, 11)}
|
||||||
SLICES = ("annual", *SEASONS) # payload slice keys
|
SLICES = ("annual", *SEASONS) # payload slice keys
|
||||||
|
|
||||||
# Per-metric descriptor — display label, overall-score weight, and the
|
# Per-metric descriptor — display label and the (positive-drift, negative-drift)
|
||||||
# (positive-drift, negative-drift) direction words — in ONE table so a scored
|
# direction words — in ONE table so a scored metric is defined in a single place.
|
||||||
# metric is defined in a single place. Temps / humidity / feels-like dominate the
|
# The overall score weights every metric equally (see _overall). wetbulb is derived
|
||||||
# weighting; wet bulb medium; wind + precip lightest. wetbulb is derived at the
|
# at the read boundary (climate.py); the rest are raw daily columns. Insertion order
|
||||||
# read boundary (climate.py); the rest are raw daily columns. Insertion order is
|
# is the canonical compute order (the frontend renders in its own display order).
|
||||||
# the canonical compute order (the frontend renders in its own display order).
|
|
||||||
METRICS = {
|
METRICS = {
|
||||||
"tmax": {"label": "High temp", "weight": 1.5, "dir": ("warmer", "cooler")},
|
"tmax": {"label": "High temp", "dir": ("warmer", "cooler")},
|
||||||
"tmin": {"label": "Low temp", "weight": 1.5, "dir": ("warmer", "cooler")},
|
"tmin": {"label": "Low temp", "dir": ("warmer", "cooler")},
|
||||||
"feels": {"label": "Feels like", "weight": 2.0, "dir": ("warmer", "cooler")},
|
"feels": {"label": "Feels like", "dir": ("warmer", "cooler")},
|
||||||
"humid": {"label": "Humidity", "weight": 2.0, "dir": ("muggier", "drier")},
|
"humid": {"label": "Humidity", "dir": ("muggier", "drier")},
|
||||||
"wetbulb": {"label": "Wet bulb", "weight": 1.25, "dir": ("warmer", "cooler")},
|
"wetbulb": {"label": "Wet bulb", "dir": ("warmer", "cooler")},
|
||||||
"wind": {"label": "Wind", "weight": 0.5, "dir": ("windier", "calmer")},
|
"wind": {"label": "Wind", "dir": ("windier", "calmer")},
|
||||||
"gust": {"label": "Gusts", "weight": 0.5, "dir": ("gustier", "calmer")},
|
"gust": {"label": "Gusts", "dir": ("gustier", "calmer")},
|
||||||
"precip": {"label": "Precip", "weight": 0.75, "dir": ("wetter", "drier")},
|
"precip": {"label": "Precip", "dir": ("wetter", "drier")},
|
||||||
}
|
}
|
||||||
SCORE_METRICS = tuple(METRICS)
|
SCORE_METRICS = tuple(METRICS)
|
||||||
|
|
||||||
|
|
@ -174,7 +173,7 @@ def _direction(metric: str, bias: float) -> str:
|
||||||
|
|
||||||
def _null_entry(metric: str, reason: str) -> dict:
|
def _null_entry(metric: str, reason: str) -> dict:
|
||||||
return {"key": metric, "label": METRICS[metric]["label"], "score": None,
|
return {"key": metric, "label": METRICS[metric]["label"], "score": None,
|
||||||
"weight": METRICS[metric]["weight"], "reason": reason}
|
"reason": reason}
|
||||||
|
|
||||||
|
|
||||||
def _build_entry(metric: str, mad: float, bias: float, per_q: list,
|
def _build_entry(metric: str, mad: float, bias: float, per_q: list,
|
||||||
|
|
@ -189,7 +188,7 @@ def _build_entry(metric: str, mad: float, bias: float, per_q: list,
|
||||||
"score": score, "mad": round(mad, 1), "bias": round(bias, 1),
|
"score": score, "mad": round(mad, 1), "bias": round(bias, 1),
|
||||||
"tier": tier, "class": css, "direction": direction,
|
"tier": tier, "class": css, "direction": direction,
|
||||||
"grade": tier if score < 15 else f"{tier}, {direction}",
|
"grade": tier if score < 15 else f"{tier}, {direction}",
|
||||||
"weight": METRICS[metric]["weight"], "per_q": per_q,
|
"per_q": per_q,
|
||||||
"n_recent": n_recent, "n_base": n_base,
|
"n_recent": n_recent, "n_base": n_base,
|
||||||
}
|
}
|
||||||
if freq is not None:
|
if freq is not None:
|
||||||
|
|
@ -206,8 +205,9 @@ def _metric_entry(metric: str, base: np.ndarray, rec: np.ndarray) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def _overall(entries: dict) -> dict | None:
|
def _overall(entries: dict) -> dict | None:
|
||||||
"""Weighted roll-up of the present metrics in one slice. Weights renormalize
|
"""Equal-weight roll-up of the present metrics in one slice: every metric (and,
|
||||||
over whatever is present (a metric missing for the source drops out cleanly).
|
through each metric's own mean, every percentile) counts the same. Metrics
|
||||||
|
missing for the source simply drop out of the average.
|
||||||
|
|
||||||
The total reads as a direction-agnostic NET CHANGE: colored by magnitude on the
|
The total reads as a direction-agnostic NET CHANGE: colored by magnitude on the
|
||||||
intensity ramp and labeled by tier alone, not 'warmer/cooler' — the per-metric
|
intensity ramp and labeled by tier alone, not 'warmer/cooler' — the per-metric
|
||||||
|
|
@ -215,8 +215,7 @@ def _overall(entries: dict) -> dict | None:
|
||||||
present = [e for e in entries.values() if e.get("score") is not None]
|
present = [e for e in entries.values() if e.get("score") is not None]
|
||||||
if not present:
|
if not present:
|
||||||
return None
|
return None
|
||||||
wsum = sum(e["weight"] for e in present)
|
mad = sum(e["mad"] for e in present) / len(present)
|
||||||
mad = sum(e["weight"] * e["mad"] for e in present) / wsum
|
|
||||||
score = score_of(mad)
|
score = score_of(mad)
|
||||||
tier, css = tier_of(score, 1.0) # magnitude only — net change, not a direction
|
tier, css = tier_of(score, 1.0) # magnitude only — net change, not a direction
|
||||||
return {"score": score, "mad": round(mad, 1), "tier": tier, "class": css,
|
return {"score": score, "mad": round(mad, 1), "tier": tier, "class": css,
|
||||||
|
|
|
||||||
|
|
@ -160,14 +160,26 @@ def test_wetbulb_stress_frequency_rises_with_heat():
|
||||||
assert freq["f6"] > freq["f45"] and freq["d"] > 0
|
assert freq["f6"] > freq["f45"] and freq["d"] > 0
|
||||||
|
|
||||||
|
|
||||||
# --- missing metric / weight renormalization ----------------------------------
|
# --- overall aggregation ------------------------------------------------------
|
||||||
|
|
||||||
|
def test_overall_is_equal_average_of_metric_mads():
|
||||||
|
# Every present metric counts the same — no per-metric weighting.
|
||||||
|
entries = {
|
||||||
|
"a": {"score": 20, "mad": 3.0},
|
||||||
|
"b": {"score": 80, "mad": 12.0},
|
||||||
|
"c": {"score": None}, # missing -> dropped from the average
|
||||||
|
}
|
||||||
|
ov = scoring._overall(entries)
|
||||||
|
assert ov["mad"] == 7.5 # (3.0 + 12.0) / 2
|
||||||
|
assert ov["score"] == scoring.score_of(7.5) == 50
|
||||||
|
|
||||||
|
|
||||||
def test_missing_gust_column_nulls_out_but_overall_survives():
|
def test_missing_gust_column_nulls_out_but_overall_survives():
|
||||||
out = scoring.build_scores(make_frame(drop=("gust",)))
|
out = scoring.build_scores(make_frame(drop=("gust",)))
|
||||||
ann = out["slices"]["annual"]
|
ann = out["slices"]["annual"]
|
||||||
g = ann["metrics"]["gust"]
|
g = ann["metrics"]["gust"]
|
||||||
assert g["score"] is None and "not available" in g["reason"]
|
assert g["score"] is None and "not available" in g["reason"]
|
||||||
assert ann["overall"]["score"] is not None # renormalized over present metrics
|
assert ann["overall"]["score"] is not None # averaged over present metrics
|
||||||
|
|
||||||
|
|
||||||
def test_all_null_metric_reports_not_enough_data():
|
def test_all_null_metric_reports_not_enough_data():
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ def forecast_key(today, days: int) -> str:
|
||||||
# is the plain `history_token` (expires when the archive tail advances). The key
|
# is the plain `history_token` (expires when the archive tail advances). The key
|
||||||
# just carries the scoring-math version, so a math change invalidates only score
|
# just carries the scoring-math version, so a math change invalidates only score
|
||||||
# rows without disturbing any other kind.
|
# rows without disturbing any other kind.
|
||||||
SCORE_VER = "s4"
|
SCORE_VER = "s5" # s5: overall is an equal average of metrics (was weighted)
|
||||||
|
|
||||||
|
|
||||||
def score_key() -> str:
|
def score_key() -> str:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue