Skip to content

Add config-driven model evaluation pipeline (rework of #17)#28

Open
KarimAED wants to merge 7 commits into
mainfrom
claude/pr-review-architecture-e2fbn6
Open

Add config-driven model evaluation pipeline (rework of #17)#28
KarimAED wants to merge 7 commits into
mainfrom
claude/pr-review-architecture-e2fbn6

Conversation

@KarimAED

@KarimAED KarimAED commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Reworked version of the evaluation pipeline from #17, rebuilt on top of current main so it merges cleanly and keeps the repo small. Same functionality: a config-driven suite that scores model predictions against the manual tile annotations (total/hex-aggregated error with analytic and bootstrap CIs, error by municipality / month / building density / agriculture / destruction, and manual-vs-model correlations), plus the per-date prediction merge and annotation-join steps needed to evaluate a new model.

Review status: approved by the repo's thermo-nuclear review workflow at the current tip (6c3cd94), after three rounds; all findings fixed at the source (shared common.py/plots.py split, direct std instead of CI inversion, import-safe reference module, consolidated zone filters, unified logging).

What's here

  • run-evaluation console script (displacement_tracker/evaluation/run_all_analyses.py) runs all nine analyses from analysis_config.json. Relative config paths resolve against the config file, so it runs from anywhere, and all input paths are preflighted with a helpful error listing anything missing. The new-model join step is optional: leave prediction_dir/sample_tif/new_model_column null to evaluate the model_column already in the annotation CSV. (The suite keeps its own JSON config deliberately — it's a standalone analysis layer typically run with several parallel per-model configs; documented in the README.)
  • Shared helpers: evaluation/scripts/common.py (annotation/layer loading, validation, mean-error CI summaries with std, hex-grid aggregation) and evaluation/scripts/plots.py (all figure rendering plus the single Agg backend policy). Each evaluate_* module now only expresses how tiles are grouped.
  • add_new_model_results.py joins per-date prediction GPKGs onto the annotations by reconstructing the 100 m tiles around the stored centroids; counting is vectorized with a spatial join per date.
  • merge-geojsons per-date batch mode (merge.process_by_date: true in the unified config.yaml; the README documented this capability but main didn't have it), built on util/thresholding.filter_points_by_adjusted_peak so thresholding semantics stay unified with prediction and validation. merge.output is optional in this mode. Hardened against bad inputs: corrupt files are logged and skipped, per-date failures are isolated (remaining dates still process, the run exits non-zero with a summary), all-filtered dates skip the write instead of crashing, move collisions warn instead of silently overwriting, case-insensitive suffix handling prevents sorted-but-never-merged files, and dates are validated as real calendar dates.
  • Output-clash fix: total_error and spatial_bootstrap_hex both wrote hex_with_cis.* / hex_mean_error_map.png; the analytic variant now writes hex_analytic_* so both can share an output directory.
  • Repo hygiene: generated results/ outputs are deleted and gitignored; manual-eval assets move under evaluation/manual_eval/ (with manual_eval.py's output path updated to match); .gitignore gains the missing trailing newline plus entries for sample.tif, results/, predictions/, spatial_data/layers/ and .Rhistory.

Reference-data interface integration (#29)

evaluation/annotation_reference.py exposes the manual annotations to the generic validation/tuning reference interface (util/reference_data.py, introduced by #29):

  • ManualAnnotationReferenceSource implements ReferenceSource.counts_on_grid: each annotated tile's manual_tent_count is rasterized into the master-grid cell containing the tile centroid (count-weighted MergeAlg.add), with CRS reprojection and clip_geom support. Date selection is explicit and UNOSAT-style: the CSV spans 11 acquisition dates, so date is required whenever more than one is present, and the error lists the available dates.
  • Registered as reference type manual_eval in SOURCE_TYPES when the interface is present, so it works through build_reference_source / tuning.reference config after importing this module.
  • annotation-reference console script materializes one date's annotations as a counts GeoTIFF on a master grid — consumable by the built-in raster reference type without importing this module.
  • The interface dependency is optional: on checkouts without util/reference_data.py (i.e. until feat: hyperparameter tuning pipeline with a generic reference-data interface #29 lands) the module still imports and the CLI still works — only the manual_eval type registration is skipped (debug-logged). No merge-order dependency with feat: hyperparameter tuning pipeline with a generic reference-data interface #29 in either direction.
  • Documented caveat: the annotations are a sparse sample of tiles, so unannotated cells read as zero reference counts; comparisons should be restricted to annotated areas.

Data — nothing large is committed

This PR adds no data blobs to git history. The three spatial context layers the analyses read (agriculture, h3_density, destruction, ~20 MB even minified) are provisioned locally into the gitignored spatial_data/layers/ directory; the README documents how to obtain them (team share, or restored from the still-existing eval-postprocessing-pipeline branch), and run-evaluation preflights them with a clear error when absent. The branch history was rewritten to confirm no commit ever contained them. The municipal-boundary shapefile is referenced from the existing root gaza_boundaries/; the only data files touched are the manual-annotation CSV (moved, ~190 KB, pre-existing) and the deletion of ~30 previously committed results/ artifacts. Net repo growth is code only.

Verification

  • Full suite run against the real 1,248-tile annotation CSV: all CSVs/plots produced; agriculture, month, and municipal summaries match the previously committed results to the last decimal, and the bootstrap point estimates match exactly (CI bounds shift slightly because the RNG moved from legacy np.random.seed to default_rng).
  • Preflight check verified both ways: with the layers absent it lists exactly the missing files and points at the README; with locally provisioned layers the suite completes.
  • Per-date batch mode fault-injection tested with six failure modes in one run (corrupt file, all-filtered date, uppercase suffix, invalid calendar date, date-before-extension filename, re-sorted name collision): every case handled with the intended warning/error, remaining dates processed, exit code 1 with a failure summary; single-folder mode, config-driven CLI (post-Unify config.yaml and predict_config.yaml into a single sectioned config #27 merge), exclusion-zone filtering and re-run idempotency regression-tested.
  • add_new_model_results exercised end-to-end on synthetic per-date GeoJSONs + a synthetic raster, including the column-name-collision fallback.
  • Reference-source integration tested against feat: hyperparameter tuning pipeline with a generic reference-data interface #29's actual reference_data.py (checked out temporarily from that branch): the 1,316 annotated tents for 2024-10-14 resolve to exactly 1,316 on a 100 m UTM master grid; build_reference_source({"type": "manual_eval", ...}) returns identical counts; the CLI-exported GeoTIFF round-trips bit-identically through feat: hyperparameter tuning pipeline with a generic reference-data interface #29's RasterReferenceSource; module import and CLI verified working on checkouts without feat: hyperparameter tuning pipeline with a generic reference-data interface #29, with matplotlib/backend policy confirmed (Agg) on standalone imports.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XjFy5GC8fK74DeV5mcEtBc

@doug-leasure

Copy link
Copy Markdown
Member

@rapsoj , could you confirm that you are happy with this to replace your PR #17 ? If so, please approve this PR and close #17. If not, let us know what the sticking points are, please. Thanks!

I assigned @KarimAED to this PR, so once approved, he can make any final commits, merge, and delete the branch.

claude added 4 commits July 11, 2026 18:38
Reworked version of the evaluation pipeline from PR #17, rebuilt on top of
current main:

- run-evaluation console script runs the full analysis suite from a JSON
  config (paths resolved relative to the config file); the new-model join
  step is optional so the CSV's existing model column can be evaluated
  directly
- shared helpers in evaluation/scripts/common.py (annotation loading,
  CI summaries, bar plots, hex-grid aggregation) replace the load/metrics/
  plot blocks that were duplicated across the per-analysis scripts
- add_new_model_results joins per-date prediction GPKGs onto the manual
  annotations by reconstructing 100 m tiles around the stored centroids
- h_merge_geojsons gains the --process-by-date batch mode (already
  documented in the README) built on util.thresholding, and no longer
  requires OUTPUT_GPKG in that mode
- total_error outputs renamed to hex_analytic_* so they no longer collide
  with spatial_bootstrap_hex outputs in a shared output directory
- spatial context layers (agriculture, h3_density, destruction) are NOT
  committed; they are provisioned locally into the gitignored
  spatial_data/layers/ directory (see README); the municipal boundary
  shapefile is reused from gaza_boundaries/
- checked-in results/ outputs removed and gitignored; manual-eval assets
  moved under evaluation/manual_eval/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjFy5GC8fK74DeV5mcEtBc
…face

Adds displacement_tracker/evaluation/annotation_reference.py:

- ManualAnnotationReferenceSource implements ReferenceSource from
  util/reference_data.py (PR #29): each annotated tile's count is
  rasterized into the master-grid cell containing the tile centroid,
  with UNOSAT-style explicit date selection (required whenever the CSV
  spans more than one acquisition date)
- registered as reference type 'manual_eval' in SOURCE_TYPES on import
- annotation-reference console script materializes one date's
  annotations as a counts GeoTIFF on a master grid, consumable by the
  built-in 'raster' reference type without importing this module
- guarded import raises a clear error on checkouts that predate the
  reference-data interface; nothing else in the evaluation suite
  imports this module, so the pipeline keeps working without PR #29

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjFy5GC8fK74DeV5mcEtBc
Failure modes found by fault-injection testing, all previously fatal or
silently lossy:

- an all-filtered date crashed the whole batch (empty GeoDataFrame
  to_file raises); save_merged_gpkg now clears any stale output and
  skips the write with a warning
- uppercase .JSON files were moved into date folders by the sorter but
  never picked up by the case-sensitive glob; both sides now share a
  case-insensitive list_geojson_files helper
- a corrupt/unreadable GeoJSON aborted every remaining date; it is now
  logged and skipped, a folder where every file fails raises, and the
  batch loop isolates per-date failures, processes the rest, and exits
  non-zero with a summary of failed dates
- shutil.move silently overwrote an existing file in the date folder;
  collisions now leave the root copy in place with an explicit warning
  that it will not be merged
- _99999999_ passed the \d{8} pattern and created a junk folder;
  dates are now validated as real calendar dates in both the sorter
  and iter_date_folders
- dates immediately before the extension (pred_20241014.json) were not
  recognized; the pattern now accepts _YYYYMMDD followed by _ or .

Single-folder mode and re-run idempotency regression-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjFy5GC8fK74DeV5mcEtBc
The three spatial context layers (~20 MB minified) are no longer part of
the repository: spatial_data/layers/ is gitignored, the README documents
how to obtain them (team share, or restored from the original
eval-postprocessing-pipeline branch), and run-evaluation now preflights
all input paths and lists anything missing with a pointer to the README
instead of failing with a GDAL traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjFy5GC8fK74DeV5mcEtBc
@KarimAED
KarimAED force-pushed the claude/pr-review-architecture-e2fbn6 branch from d25b710 to 6a67902 Compare July 11, 2026 18:40
Resolves the h_merge_geojsons.py conflict by keeping main's config-driven
CLI (#27) and folding the --process-by-date batch mode into it as a
merge.process_by_date config key on merge_geojsons(); docs updated to the
config-driven syntax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjFy5GC8fK74DeV5mcEtBc

Copy link
Copy Markdown
Collaborator Author

@claude review


Generated by Claude Code

@claude

claude Bot commented Jul 11, 2026

Copy link
Copy Markdown

Claude finished @KarimAED's task in 7m 0s —— View job


Thermo-Nuclear Code Quality Review

  • Gather context (skill, full diff, surrounding code)
  • Review evaluation suite structure (common.py, evaluate_*, run_all_analyses.py)
  • Review h_merge_geojsons.py --process-by-date mode
  • Review annotation_reference.py reference-source integration
  • Review add_new_model_results.py and config/packaging changes
  • Post inline comments on specific issues
  • Post prioritized summary with verdict

Summary

The core of this PR is genuinely good structural work. Nine copy-pasted standalone scripts with hardcoded module-level config became parametrized functions over a shared common.py; the diff is net −500 lines while adding an orchestrator, a batch mode, and a reference-source integration. spatial_bootstrap_hex.py going from 336 lines of inline everything to 126 lines expressing only the bootstrap logic is exactly what consolidation should look like, and the process_geojson_folder extraction in h_merge_geojsons.py is the right shape for the by-date mode (no output-path special cases threaded through the merge logic). Repo hygiene (deleting committed results/ artifacts, gitignoring data) is also right.

But the PR stops one step short of its own standard in several places, and one claim in the description doesn't hold. Findings in priority order:

Must fix

  1. evaluate_municipal_bounds.py:55-68 — std/se reconstructed by algebraically inverting the CI that group_error_summary produced ((mean − ci_lower) / Z_95). This is magical coupling to the summary's internal CI formula: change the interval method and this silently emits wrong statistics. mean_error_ci already computes the std it needs — return it instead of inverting downstream. The duplicate s_tile_error/std_tile_error column pair needs a justification or deletion. (inline)
  2. manual_eval/manual_eval.py:22OUTPUT_CSV is still stale after the move. It's missing the displacement_tracker/ prefix, so run from the repo root (which GAZA_BOUNDARY_SHP assumes), the final save raises FileNotFoundError and the annotation session's work is lost. The PR description says this path was updated; it wasn't, fully. (inline)
  3. annotation_reference.py:159 — the manual_eval reference type is unreachable as shipped. Nothing imports the module, so the SOURCE_TYPES side-effect registration never fires for a tuning config; and pyproject.toml:60 wires up an annotation-reference console script that is a guaranteed ImportError traceback on every checkout until feat: hyperparameter tuning pipeline with a generic reference-data interface #29 merges. Clarify where the runtime import will live under feat: hyperparameter tuning pipeline with a generic reference-data interface #29's design, and move the entry point to land with (or after) feat: hyperparameter tuning pipeline with a generic reference-data interface #29. (inline)

Should fix (finish the consolidation this PR started)

  1. annotation_reference.py:85-101 duplicates the annotation loader that common.py just centralized — apparently because common.py bundles loading with matplotlib/Agg plotting. Split loading/stats from plotting and reuse the canonical loader. (inline)
  2. Read-layer → check-column → align-CRS preamble repeated in four evaluate_* scripts; one load_layer() helper in common.py removes all four copies. (inline)
  3. h_merge_geojsons.py:248-276 — the moved exclusion/inclusion blocks are twin 14-line stanzas over twin filter functions differing by a not; one filter_points_by_zone(points, geom, keep_inside) plus a two-entry loop deletes both. Also sort_preds_by_date's return value is dead. Pre-existing code, but this PR owns the restructure it rode in on. (inline)

Question

  1. run_all_analyses.py introduces a second config dialect (bespoke JSON loader) alongside the repo's canonical config.yaml/load_flow_config. If that's deliberate, document why; otherwise fold evaluation config into the existing mechanism. (inline)

Minor, no inline: new evaluation code mixes bare print() (common.py, add_new_model_results.py, evaluate_municipal_bounds.py) with the repo's setup_logging used everywhere else including annotation_reference.py — pick the canonical one.

Items 1 and 2 are correctness-adjacent, item 3 ships a dead registration and a broken CLI entry point, and items 4–6 are the same consolidation discipline the PR itself established, applied one level further. All are small, targeted changes.

Thermo-Nuclear Verdict: CHANGES REQUIRED

Comment on lines +55 to +68
# Derive per-region spread and extrapolated totals from the CI summary.
n = results_df["num_tiles"]
se = (results_df["mean_tile_error"] - results_df["ci_lower"]) / Z_95
results_df["std_tile_error"] = (se * np.sqrt(n)).where(n > 1, 0.0)
results_df["se_tile_error"] = se
results_df["s_tile_error"] = results_df["std_tile_error"]
results_df["total_regional_error"] = n * results_df["mean_tile_error"]
results_df["total_regional_se"] = n * se
results_df["total_regional_ci_lower"] = (
results_df["total_regional_error"] - Z_95 * results_df["total_regional_se"]
)
results_df["total_regional_ci_upper"] = (
results_df["total_regional_error"] + Z_95 * results_df["total_regional_se"]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magical inversion of the CI to recover the standard error. This block reconstructs se by algebraically inverting group_error_summary's output ((mean - ci_lower) / Z_95) and then rebuilds std from it. That couples this script to the implementation detail that the summary's CI is a symmetric normal-approximation interval at exactly Z_95 — if mean_error_ci ever grows a t-interval, a different z, or a bootstrap variant, this silently produces wrong std/se values instead of failing.

mean_error_ci already computes np.std(errors, ddof=1) and the se internally — it just throws them away. The direct fix is to include std_tile_error/se_tile_error (or just std) in the dict mean_error_ci returns; every consumer gets them for free through group_error_summary and this whole back-derivation block collapses to the two total_regional_* lines.

Also: results_df["s_tile_error"] = results_df["std_tile_error"] writes the same value under two names. If s_tile_error is only there to preserve a legacy CSV schema, say so in a comment or drop it — as written it reads like an accident.

Fix this →

PREWAR_TIF = "tif_files/prewar_gaza.tif"

OUTPUT_CSV = "evaluation/manual_annotation_results.csv"
OUTPUT_CSV = "evaluation/manual_eval/manual_annotation_results.csv"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path is still stale relative to the repo root. GAZA_BOUNDARY_SHP = "gaza_boundaries/..." (line 19) implies the script is run from the repo root, but from there evaluation/manual_eval/ doesn't exist — the file now lives at displacement_tracker/evaluation/manual_eval/. df.to_csv doesn't create directories, so the annotation session's final save raises FileNotFoundError and (looking at lines 381–388) the collected annotations are lost. The PR description says this path was updated for the move, but it's missing the displacement_tracker/ prefix:

Suggested change
OUTPUT_CSV = "evaluation/manual_eval/manual_annotation_results.csv"
OUTPUT_CSV = "displacement_tracker/evaluation/manual_eval/manual_annotation_results.csv"

return selected


SOURCE_TYPES["manual_eval"] = ManualAnnotationReferenceSource

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This registration has no importer, so the manual_eval reference type is unreachable. Nothing in this repo imports displacement_tracker.evaluation.annotation_reference (I checked), and the module deliberately isn't imported by the evaluation suite. So the documented config usage (reference: {type: manual_eval, ...}) can only work if #29's build_reference_source — or the user — happens to import this module first. Side-effect registration with no import hook is a dead code path wearing a working one's docs.

When #29 lands, either its reference layer needs an explicit plugin/import mechanism that pulls this module in, or the registration should move to wherever SOURCE_TYPES is populated. Please state which of those is the plan (and where the import will live); otherwise only the CLI/raster path actually functions.

Relatedly, pyproject.toml:60 registers the annotation-reference console script now, while this module raises ImportError on every checkout until #29 merges — poetry run annotation-reference --help today is a traceback. The guarded import with a clear message is good, but shipping a wired-up entry point that cannot run is the wrong sequencing: add the entry point (and this module) in or after #29, or land #29 first.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 79deb07 by making the interface optional instead of a hard import: the module now imports (and the annotation-reference CLI works) on every checkout — the raster-export path has no real dependency on the ABC. When util/reference_data.py is present, the class subclasses ReferenceSource and registers as manual_eval at import; when absent, registration is skipped with a debug log. The runtime-import question is documented in the module docstring and README: config-only flows should use the exported counts raster (raster type) until #29's flow imports this module (or #29 adds the import itself once both are merged).


Generated by Claude Code

Comment on lines +85 to +101
df = pd.read_csv(csv_path)
required = {lat_column, lon_column, date_column, count_column}
missing = required - set(df.columns)
if missing:
raise ValueError(
f"Annotation CSV missing required columns: {sorted(missing)}"
)

df = _select_date(df, date, date_column, csv_path)
df = df.dropna(subset=[count_column])

self._count_column = count_column
self._gdf = gpd.GeoDataFrame(
df[[count_column]],
geometry=gpd.points_from_xy(df[lon_column], df[lat_column]),
crs="EPSG:4326",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-implements the annotation loading that scripts/common.py just centralized (read CSV → validate required columns → build EPSG:4326 points from lat/lon = load_annotation_points). Two loaders for the same CSV in the same package will drift.

I suspect the reason you didn't reuse it is that importing common drags in matplotlib and force-sets the Agg backend at import time — which you rightly don't want in the tuning flow. That's the actual structural smell: common.py bundles data loading, statistics, and plotting into one module. Splitting it (e.g. common.py keeps loading/stats, plotting helpers move to a plots.py) lets this class call the canonical loader with a date/count_column filter on top, and deletes the duplicate column-validation/GeoDataFrame-construction here.

Fix this →

Comment on lines 34 to 37
agriculture_gdf = gpd.read_file(agriculture_geojson)

# Ensure CRS alignment
if agriculture_gdf.crs != tiles_gdf.crs:
agriculture_gdf = agriculture_gdf.to_crs(tiles_gdf.crs)

# Dissolve to single multipolygon
agriculture_union = agriculture_gdf.geometry.union_all()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read-layer → check-required-column → align-CRS preamble is repeated near-verbatim in four scripts (evaluate_agriculture.py:34-37, evaluate_destruction.py:41-46, evaluate_density.py:44-48, evaluate_municipal_bounds.py:39-43). Since common.py exists precisely to hold these shared steps, add one helper there, e.g. load_layer(path, target_crs, required_columns=()), and each script's preamble becomes one line. evaluate_destruction and evaluate_density also share the sjoin-then-groupby(joined.index).agg(...) collapse for multi-polygon matches — worth folding into a small sjoin_collapse helper at the same time if it stays this parallel.

Comment on lines +248 to +276
if exclusion_geom is not None:
before_exclusion = len(pts)
pts = filter_points_by_exclusion(pts, exclusion_geom)
LOGGER.info(
" %s: %d kept after exclusion filtering",
path.name,
len(pts),
)
if before_exclusion != len(pts):
LOGGER.info(
" %s: %d points removed by exclusion zones",
path.name,
before_exclusion - len(pts),
)

if inclusion_geom is not None:
before_inclusion = len(pts)
pts = filter_points_by_inclusion(pts, inclusion_geom)
LOGGER.info(
" %s: %d kept after inclusion filtering",
path.name,
len(pts),
)
if before_inclusion != len(pts):
LOGGER.info(
" %s: %d points removed outside inclusion zone",
path.name,
before_inclusion - len(pts),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extraction into process_geojson_folder is the right move, but it carried these two structurally identical 14-line zone-filter blocks along verbatim — and they sit on top of filter_points_by_exclusion/filter_points_by_inclusion, which are themselves the same function differing by a not. Since this PR already owns the restructuring, collapse the whole thing: one filter_points_by_zone(points, geom, keep_inside: bool) plus a loop over [("exclusion", exclusion_geom, False), ("inclusion", inclusion_geom, True)] replaces two functions and both blocks, and the double logging (kept after ... filtering immediately followed by removed ...) can become a single line per zone.

Minor, same file: sort_preds_by_date returns sorted(touched_dates) but the only caller (merge_geojsons:433) ignores it and re-derives folders via iter_date_folders. Drop the return value (or use it) so the function's contract matches reality.

Comment on lines +49 to +79
PATH_KEYS = (
"annotation_csv",
"output_csv",
"prediction_dir",
"sample_tif",
"output_dir",
"boundary_shp",
"agriculture_geojson",
"h3_geojson",
"destruction_geojson",
)

NEW_MODEL_KEYS = ("prediction_dir", "sample_tif", "new_model_column")


def load_config(config_path: Path) -> dict:
with config_path.open("r", encoding="utf-8") as f:
cfg = json.load(f)

# Resolve relative paths against the config file's directory.
base = config_path.parent
for key in PATH_KEYS:
if cfg.get(key):
cfg[key] = str((base / cfg[key]).resolve())
return cfg


def require(cfg: dict, key: str):
if not cfg.get(key):
raise click.ClickException(f"Missing required config key: {key}")
return cfg[key]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boundary question: why a second, parallel config mechanism? The repo's canonical configuration is config.yaml + util/config.load_flow_config (which merge-geojsons in this very PR uses). This introduces a JSON sidecar with its own bespoke machinery — PATH_KEYS path resolution, a local require(), key-presence conventions. Users now maintain two config dialects, and evaluation settings can't live next to the pipeline settings they relate to (e.g. the merge.process_by_date output dir that feeds prediction_dir here).

If there's a deliberate reason (per-run experiment configs that shouldn't touch the flow config, decoupling from ${DATA_DIR} env substitution), document it in the module docstring. Otherwise an evaluation: section in config.yaml read through load_flow_config would delete this loader and keep one config story for the repo.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate, now documented in the README (79deb07): the evaluation suite is a standalone analysis layer rather than a pipeline stage — it's typically run with several parallel configs (one per evaluated model), and its paths resolve against the config file's own directory (annotation CSV, local gitignored layers) rather than ${DATA_DIR}. Folding it into config.yaml's flow sections would force exactly one evaluation config per checkout and route standalone analysis assets through pipeline config resolution. Happy to revisit if the team prefers one dialect everywhere.


Generated by Claude Code

- mean_error_ci now returns the sample std it computes; every group
  summary carries std_tile_error, and evaluate_municipal_bounds derives
  se/totals from it directly instead of algebraically inverting the CI;
  the duplicate s_tile_error column is dropped (values unchanged,
  verified against the previous run)
- manual_eval.py OUTPUT_CSV gets the missing displacement_tracker/
  prefix so saves work from the repo root like its other paths
- annotation_reference imports cleanly on every checkout: the
  ReferenceSource base and SOURCE_TYPES registration are optional
  (skipped with a debug log when util/reference_data.py is absent), so
  the annotation-reference console script always works; registration and
  its import requirement are documented in the module docstring and README
- common.py split: loaders/statistics stay in common.py (no matplotlib),
  plotting moves to plots.py; annotation_reference reuses the canonical
  read_annotations/as_points loaders instead of its own copy
- new load_layer() helper replaces the read/validate/align-CRS preamble
  repeated in the agriculture, destruction, density and municipal scripts
- h_merge_geojsons: single filter_points_by_zone replaces the twin
  exclusion/inclusion functions and their twin logging stanzas;
  sort_preds_by_date's dead return value removed
- print() replaced with the repo's setup_logging logger across the
  evaluation scripts; README documents why the evaluation suite keeps
  its own JSON config rather than the pipeline config.yaml

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjFy5GC8fK74DeV5mcEtBc

Copy link
Copy Markdown
Collaborator Author

@claude review


Generated by Claude Code

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown

Claude finished @KarimAED's task in 3m 36s —— View job


Thermo-Nuclear Code Quality Review — Round 2

  • Gather context (skill, full diff, round-1 fix commit 79deb07)
  • Verify fix: CI back-derivation in evaluate_municipal_bounds.py
  • Verify fix: stale OUTPUT_CSV in manual_eval.py
  • Verify fix: unreachable manual_eval registration / broken console script
  • Verify fix: duplicated annotation loader in annotation_reference.py
  • Verify fix: repeated layer-loading preamble in evaluate_* scripts
  • Verify fix: twin zone-filter blocks in h_merge_geojsons.py
  • Verify: logging vs print consistency
  • Re-review whole diff for new issues introduced by the fixes
  • Post inline comments and final verdict

Summary

Commit 79deb07 addresses every round-1 finding, and it addresses them the right way — at the source, not with patches on top:

  1. CI back-derivation (must-fix Gaza tents Feb #1) — fixed as suggested. mean_error_ci now returns the std_tile_error it was already computing (common.py:96-119), evaluate_municipal_bounds.py:52-65 derives se and the regional totals directly from it, and the duplicate s_tile_error column is gone. The magical coupling to the CI formula is eliminated.
  2. Stale OUTPUT_CSV (must-fix feat: 2026-02 version #2) — fixed; manual_eval.py:22 now carries the displacement_tracker/ prefix, consistent with the script's other repo-root-relative paths.
  3. Unreachable registration / broken entry point (must-fix 1 gaza tents feb #3) — resolved with a better design than either option I offered: the ReferenceSource dependency is now optional (annotation_reference.py:54-61), so the module imports and the annotation-reference CLI works on every checkout; registration into SOURCE_TYPES happens only when util/reference_data.py exists, with the runtime-import story documented in the module docstring and README. The raster-export path genuinely has no dependency on the ABC, so making the base class optional is honest, not hacky.
  4. Duplicate loaderannotation_reference.py:92-99 now reuses the canonical read_annotations/as_points, enabled by the clean common.py (loading/stats) vs plots.py (matplotlib) split. No stale references to the moved plot helpers anywhere.
  5. Layer-loading preamble — one load_layer() in common.py:82-90; all four evaluate_* preambles are now one line each.
  6. Twin zone filters — collapsed to a single filter_points_by_zone plus a two-entry loop with unified logging (h_merge_geojsons.py:152-163, 234-248); sort_preds_by_date's dead return value removed.
  7. Config-dialect question — answered and documented in the README: the evaluation suite is a standalone analysis layer typically run with several parallel per-model configs whose paths resolve against the config file rather than ${DATA_DIR}. That's a legitimate boundary; deliberate-and-documented meets the bar.
  8. Loggingprint() is gone from the evaluation code; everything routes through the shared setup_logging logger.

I re-read the full diff against origin/main after the fixes. One minor observation, posted inline as non-blocking: the forced-Agg guarantee moved into plots.py, and evaluate_spatial_points.py/evaluate_tile_correlation.py import pyplot without importing plots, so standalone use of those two modules relies on matplotlib's headless fallback plus import order rather than an explicit backend choice. Worth a one-line follow-up, not a blocker.

Against the skill's approval bar: no structural regression, no missed simplification with a visible path, no file over 234 lines in the evaluation package (largest touched file overall is h_merge_geojsons.py at 434), no spaghetti branching, no magical abstractions (the optional-import fallback is a standard, documented pattern), and the canonical-helper duplication from round 1 is gone. The final shape — common.py loaders/stats, plots.py rendering, thin evaluate_* modules expressing only their grouping, one orchestrator — is the structure round 1 asked for.

Thermo-Nuclear Verdict: APPROVED


import numpy as np
import pandas as pd

import matplotlib.pyplot as plt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, non-blocking: before the common.py/plots.py split, every evaluation script inherited the forced Agg backend from common.py's import. Now the matplotlib.use("Agg") call lives only in plots.py, and this script (and evaluate_tile_correlation.py) imports pyplot directly without importing plots — so standalone use of these two modules no longer forces Agg and relies on matplotlib's headless fallback. Through run-evaluation it's fine (evaluate_agriculture's plots import fires first), but that's an incidental import-order dependency. A one-line from displacement_tracker.evaluation.scripts import plots # noqa: F401 — or moving these scripts' bespoke figures into plots.py alongside the other plotting helpers — would make it explicit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c3cd94, taking the second option: the hexbin and scatter-with-1:1 figures moved into plots.py alongside the other plotting helpers (with the shared finite_xy cleaner in common.py), so neither script imports matplotlib at all and the backend policy lives in exactly one place. Verified matplotlib.get_backend() == "Agg" after a standalone import of each module.


Generated by Claude Code

evaluate_spatial_points and evaluate_tile_correlation imported pyplot
directly after the common/plots split, relying on import order for the
forced Agg backend. Their figures (error hexbin, scatter with 1:1 line)
now live in plots.py with the other plotting helpers, the shared
finite_xy cleaner moves to common.py, and neither script imports
matplotlib at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjFy5GC8fK74DeV5mcEtBc

Copy link
Copy Markdown
Collaborator Author

@claude review


Generated by Claude Code

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown

Claude finished @KarimAED's task in 2m 8s —— View job


Thermo-Nuclear Code Quality Review — Round 3

  • Gather context (skill, prior rounds, latest fix commit 6c3cd94)
  • Verify fix: bespoke figures moved into plots.py, explicit Agg backend
  • Re-review full diff against origin/main for new/remaining issues
  • Post inline comments if needed and final verdict

Summary

The only change since the round-2 approval is commit 6c3cd94, which resolves the one non-blocking observation from that round — and it takes the option I'd have picked:

  • plot_error_hexbin and plot_scatter_with_1to1 moved into plots.py (plots.py:72-129), so the matplotlib.use("Agg") call and every figure the suite renders now live in the same module. Neither evaluate_spatial_points.py nor evaluate_tile_correlation.py imports matplotlib at all anymore; both are now pure "load → compute → delegate the figure" modules (48 and 85 lines), which is exactly the shape the rest of the evaluate_* family already had.
  • finite_xy landed in common.py:94-99, not plots.py — the right call, since _safe_pearsonr needs it for statistics and common.py stays matplotlib-free. No circular import (plotscommon only).
  • I checked the remaining direct pyplot users in the package: evaluate_municipal_bounds.py:6 imports pyplot for its two module-specific figures, but it imports plots at module level (evaluate_municipal_bounds.py:17), so Agg is forced at import time before any figure exists — no import-order dependency there. manual_eval.py is the interactive annotation tool and correctly does not get the forced headless backend. The backend policy is coherent everywhere.

I could not run the import-time backend check myself (executing Python isn't permitted in this environment), but the change is statically unambiguous — the scripts have no matplotlib import to mis-order — and the author reports verifying matplotlib.get_backend() == "Agg" after standalone import of each module.

Nothing else in the diff moved, so the round-2 assessment stands: all round-1 findings fixed at the source, no file-size concerns (largest evaluation file is common.py at 241 lines), no new branching complexity, no helper duplication. The final structure — common.py for loading/stats, plots.py for all rendering and the backend policy, thin evaluate_* modules expressing only their grouping, one orchestrator — is clean and consistent.

No new inline comments; there is nothing left to flag at the bar this skill sets.

Thermo-Nuclear Verdict: APPROVED


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants