|
21 | 21 | from cybench.util.geo import get_shapes_from_polygons, world_shape_path |
22 | 22 | from cybench.config import KEY_LOC, KEY_TARGET |
23 | 23 | from cybench.evaluation.aggregated_metrics import ( |
| 24 | + MIN_SLICE_REGIONS, |
24 | 25 | calc_r_r2, |
25 | 26 | compute_report_metrics, |
26 | 27 | get_metrics_dict, |
@@ -520,6 +521,64 @@ def generate_local_report_html(stats_list: List[dict], pdf_filename: str) -> str |
520 | 521 | # ----------------------------- |
521 | 522 | # Plotting |
522 | 523 | # ----------------------------- |
| 524 | +def _yearly_cross_region_stats( |
| 525 | + df: pd.DataFrame, |
| 526 | + value_col: str, |
| 527 | + *, |
| 528 | + year_col: str = "year", |
| 529 | + min_regions: int = MIN_SLICE_REGIONS, |
| 530 | +) -> pd.DataFrame: |
| 531 | + """Per-year mean and std of *value_col* across regions.""" |
| 532 | + grouped = df.groupby(year_col)[value_col] |
| 533 | + out = pd.DataFrame( |
| 534 | + { |
| 535 | + "mean": grouped.mean(), |
| 536 | + "std": grouped.std(ddof=1), |
| 537 | + "n_regions": grouped.count(), |
| 538 | + } |
| 539 | + ).sort_index() |
| 540 | + out.loc[out["n_regions"] < min_regions, "std"] = np.nan |
| 541 | + return out |
| 542 | + |
| 543 | + |
| 544 | +def _plot_mean_std_band( |
| 545 | + ax: Axes, |
| 546 | + stats: pd.DataFrame, |
| 547 | + *, |
| 548 | + color: str, |
| 549 | + mean_linestyle: str, |
| 550 | + label: str, |
| 551 | + zorder: int = 2, |
| 552 | +) -> None: |
| 553 | + years = stats.index.to_numpy() |
| 554 | + mean = stats["mean"].to_numpy(dtype=float) |
| 555 | + std = stats["std"].to_numpy(dtype=float) |
| 556 | + ax.plot( |
| 557 | + years, |
| 558 | + mean, |
| 559 | + mean_linestyle, |
| 560 | + color=color, |
| 561 | + linewidth=2.0, |
| 562 | + markersize=5, |
| 563 | + marker="o", |
| 564 | + label=label, |
| 565 | + zorder=zorder + 1, |
| 566 | + ) |
| 567 | + lower = mean - std |
| 568 | + upper = mean + std |
| 569 | + has_band = np.isfinite(std) |
| 570 | + if np.any(has_band): |
| 571 | + ax.fill_between( |
| 572 | + years, |
| 573 | + lower, |
| 574 | + upper, |
| 575 | + color=color, |
| 576 | + alpha=0.18, |
| 577 | + linewidth=0, |
| 578 | + zorder=zorder, |
| 579 | + ) |
| 580 | + |
| 581 | + |
523 | 582 | def _plot_scatter_panel( |
524 | 583 | ax: Axes, |
525 | 584 | df_filtered: pd.DataFrame, |
@@ -575,17 +634,12 @@ def _plot_scatter_panel( |
575 | 634 | ax.set_xlabel("Actual yield") |
576 | 635 | ax.set_ylabel("Predicted yield") |
577 | 636 |
|
578 | | - def fmt_line(label, m): |
579 | | - return ( |
580 | | - f"{label}: r = {m['r']:.2f}, R² = {m['r2']:.2f} | " |
581 | | - f"r_res = {m['r_res']:.2f}, R²_res = {m['r2_res']:.2f}" |
582 | | - ) |
| 637 | + def fmt_region_year(m: dict) -> str: |
| 638 | + return f"r = {m['r']:.2f}, R² = {m['r2']:.2f}, NRMSE = {m['nrmse']:.2f}" |
583 | 639 |
|
584 | | - txt_lines = [f"N = {n_samples:,}", fmt_line("Model", metrics_model)] |
| 640 | + txt_lines = [f"N = {n_samples:,}", f"Model: {fmt_region_year(metrics_model)}"] |
585 | 641 | if metrics_base: |
586 | | - txt_lines.append(fmt_line("Base", metrics_base)) |
587 | | - else: |
588 | | - txt_lines.append("(Baseline not found)") |
| 642 | + txt_lines.append(f"Baseline: {fmt_region_year(metrics_base)}") |
589 | 643 |
|
590 | 644 | ax.text( |
591 | 645 | 0.05, |
@@ -751,27 +805,53 @@ def process_dataset( |
751 | 805 | n_samples=n_samples, |
752 | 806 | ) |
753 | 807 | elif panel_name == "temporal": |
754 | | - ax.plot(ts.index, ts[KEY_TARGET], "-o", linewidth=2, label="Actual") |
755 | | - ax.plot( |
756 | | - ts.index, |
757 | | - ts[model], |
758 | | - "--o", |
759 | | - linewidth=2, |
760 | | - label=f"Model ($r={r_time_model:.2f}$)", |
| 808 | + r2_med_reg = report["temporal"]["r2_typical_region"] |
| 809 | + actual_yr = _yearly_cross_region_stats(df_filtered, KEY_TARGET) |
| 810 | + model_yr = _yearly_cross_region_stats(df_filtered, model) |
| 811 | + _plot_mean_std_band( |
| 812 | + ax, |
| 813 | + actual_yr, |
| 814 | + color="#1f2328", |
| 815 | + mean_linestyle="-", |
| 816 | + label="Actual (mean ± std across regions)", |
| 817 | + ) |
| 818 | + model_label = ( |
| 819 | + f"Model (mean ± std, R² med/reg = {r2_med_reg:.2f})" |
| 820 | + if pd.notnull(r2_med_reg) |
| 821 | + else "Model (mean ± std across regions)" |
| 822 | + ) |
| 823 | + _plot_mean_std_band( |
| 824 | + ax, |
| 825 | + model_yr, |
| 826 | + color="#2166ac", |
| 827 | + mean_linestyle="--", |
| 828 | + label=model_label, |
761 | 829 | ) |
762 | 830 | if has_baseline: |
763 | | - ax.plot( |
764 | | - ts.index, |
765 | | - ts[base_col_name], |
766 | | - ":o", |
767 | | - color="green", |
768 | | - alpha=0.7, |
769 | | - label=f"Base ($r={r_time_base:.2f}$)", |
| 831 | + base_yr = _yearly_cross_region_stats(df_filtered, BASELINE_MODEL) |
| 832 | + _plot_mean_std_band( |
| 833 | + ax, |
| 834 | + base_yr, |
| 835 | + color="#1a7f37", |
| 836 | + mean_linestyle=":", |
| 837 | + label="Baseline (mean ± std)", |
770 | 838 | ) |
771 | | - ax.set_title(f"Spatial Mean over Time ($N_{{yrs}}={n_years}$)") |
772 | | - ax.legend() |
| 839 | + ax.set_title( |
| 840 | + f"Cross-region spread by year ($N_{{reg}}$={n_regions}, " |
| 841 | + f"$N_{{yrs}}$={n_years})" |
| 842 | + ) |
| 843 | + ax.legend(fontsize=9) |
773 | 844 | ax.grid(alpha=0.3) |
774 | 845 | ax.set_xlabel("Year") |
| 846 | + ax.text( |
| 847 | + 0.02, |
| 848 | + 0.02, |
| 849 | + f"Band: ±1 std across regions (years with ≥{MIN_SLICE_REGIONS} regions)", |
| 850 | + transform=ax.transAxes, |
| 851 | + fontsize=8, |
| 852 | + color="#555555", |
| 853 | + verticalalignment="bottom", |
| 854 | + ) |
775 | 855 |
|
776 | 856 | return fig, stats, panel_axes |
777 | 857 |
|
|
0 commit comments