@@ -879,6 +879,129 @@ def build_horizon_skill_curves_payload(
879879 }
880880
881881
882+ def report_model_horizon_pairs (
883+ df : pd .DataFrame ,
884+ model : str ,
885+ * ,
886+ crop : str | None = None ,
887+ min_sample_ratio : float | None = None ,
888+ ) -> dict [str , Any ]:
889+ """Paired mid/qtr/eos report for one model (fair country intersection).
890+
891+ Inner-joins on (country, model) across all horizons present in ``df``.
892+ Optional ``min_sample_ratio`` drops pairs where min(n_samples)/max(n_samples)
893+ across horizons falls below the threshold (e.g. 0.95).
894+ """
895+ horizons = horizons_in_data (df )
896+ if len (horizons ) < 2 :
897+ return {"model" : model , "error" : "Need at least two horizons in summaries." }
898+
899+ work = _filter_summary_work (df , crop = crop )
900+ work = work [work ["model" ] == model ]
901+ if work .empty :
902+ return {"model" : model , "crop" : crop , "error" : f"No rows for model { model !r} ." }
903+
904+ group_keys = ["country" , "model" ]
905+ wide : pd .DataFrame | None = None
906+ samples_wide : pd .DataFrame | None = None
907+ for hz in horizons :
908+ hz_df = work [work ["batch_horizon" ] == hz ]
909+ if hz_df .empty :
910+ return {"model" : model , "crop" : crop , "error" : f"No { hz } rows for { model } ." }
911+ nrmse_part = hz_df .groupby (group_keys , as_index = False )["nrmse" ].median ().rename (
912+ columns = {"nrmse" : f"nrmse_{ hz } " }
913+ )
914+ wide = nrmse_part if wide is None else wide .merge (nrmse_part , on = group_keys , how = "inner" )
915+ if "n_samples" in hz_df .columns :
916+ samp_part = hz_df .groupby (group_keys , as_index = False )["n_samples" ].sum ().rename (
917+ columns = {"n_samples" : f"n_samples_{ hz } " }
918+ )
919+ samples_wide = (
920+ samp_part if samples_wide is None else samples_wide .merge (samp_part , on = group_keys , how = "inner" )
921+ )
922+
923+ if wide is None or wide .empty :
924+ return {"model" : model , "crop" : crop , "error" : "No country intersection across horizons." }
925+
926+ if samples_wide is not None :
927+ for _ , row in samples_wide .iterrows ():
928+ vals = [row .get (f"n_samples_{ hz } " ) for hz in horizons ]
929+ vals = [float (v ) for v in vals if pd .notna (v ) and float (v ) > 0 ]
930+ if len (vals ) >= 2 :
931+ ratio = min (vals ) / max (vals )
932+ wide .loc [wide ["country" ] == row ["country" ], "_sample_ratio" ] = ratio
933+
934+ if min_sample_ratio is not None and "_sample_ratio" in wide .columns :
935+ wide = wide [wide ["_sample_ratio" ].fillna (0 ) >= min_sample_ratio ].copy ()
936+
937+ crop_work = _filter_summary_work (df , crop = crop )
938+ any_countries = (
939+ set (crop_work [crop_work ["model" ] == model ]["country" ].astype (str ).unique ())
940+ if "country" in crop_work .columns
941+ else set ()
942+ )
943+ paired_countries = sorted (wide ["country" ].astype (str ).unique ())
944+ excluded = sorted (any_countries - set (paired_countries ))
945+
946+ detail_rows : list [dict [str , Any ]] = []
947+ for _ , row in wide .sort_values ("country" ).iterrows ():
948+ country = str (row ["country" ])
949+ nrmse_by_hz = {hz : float (row [f"nrmse_{ hz } " ]) for hz in horizons }
950+ best_hz = min (nrmse_by_hz , key = nrmse_by_hz .get ) # type: ignore[arg-type]
951+ entry : dict [str , Any ] = {
952+ "country" : country ,
953+ ** {f"nrmse_{ hz } " : round (nrmse_by_hz [hz ], 4 ) for hz in horizons },
954+ "best_horizon" : best_hz ,
955+ }
956+ if samples_wide is not None :
957+ sw = samples_wide [samples_wide ["country" ] == country ]
958+ if not sw .empty :
959+ for hz in horizons :
960+ col = f"n_samples_{ hz } "
961+ if col in sw .columns :
962+ val = sw .iloc [0 ][col ]
963+ entry [col ] = int (val ) if pd .notna (val ) else None
964+ if "_sample_ratio" in row .index and pd .notna (row ["_sample_ratio" ]):
965+ entry ["sample_ratio" ] = round (float (row ["_sample_ratio" ]), 4 )
966+ detail_rows .append (entry )
967+
968+ summary : dict [str , Any ] = {"n_paired_countries" : len (paired_countries )}
969+ for hz in horizons :
970+ col = f"nrmse_{ hz } "
971+ vals = wide [col ].astype (float )
972+ summary [f"median_{ col } " ] = round (float (vals .median ()), 4 )
973+ summary [f"mean_{ col } " ] = round (float (vals .mean ()), 4 )
974+
975+ if "eos" in horizons :
976+ for hz in horizons :
977+ if hz == "eos" :
978+ continue
979+ col = f"nrmse_{ hz } "
980+ delta = wide [col ] - wide ["nrmse_eos" ]
981+ wins = delta > 0 # lower NRMSE is better; positive delta => eos better
982+ summary [f"eos_better_than_{ hz } _rate" ] = round (float (wins .mean ()), 4 )
983+ summary [f"mean_delta_{ hz } _minus_eos" ] = round (float (delta .mean ()), 4 )
984+ summary [f"median_delta_{ hz } _minus_eos" ] = round (float (delta .median ()), 4 )
985+
986+ return {
987+ "model" : model ,
988+ "crop" : crop or "all" ,
989+ "horizons" : list (horizons ),
990+ "horizon_labels" : {hz : HORIZON_DISPLAY_LABELS .get (hz , hz ) for hz in horizons },
991+ "n_paired_countries" : len (paired_countries ),
992+ "paired_countries" : paired_countries ,
993+ "excluded_countries" : excluded ,
994+ "summary" : summary ,
995+ "detail" : detail_rows ,
996+ "interpretation" : (
997+ f"Paired comparison for { model } : only countries with walk-forward summaries "
998+ f"at all horizons ({ ', ' .join (horizons )} ). "
999+ "delta = horizon_nrmse − eos_nrmse; positive ⇒ end-of-season has lower NRMSE. "
1000+ "best_horizon = lowest NRMSE within country."
1001+ ),
1002+ }
1003+
1004+
8821005def build_crop_comparison_payload (df : pd .DataFrame ) -> dict [str , dict [str , Any ]]:
8831006 """Per horizon, pairwise crop NRMSE on shared countries only."""
8841007 if df .empty :
0 commit comments