views-frames 1.6.0__tar.gz → 1.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- views_frames-1.6.0/README.md → views_frames-1.8.0/PKG-INFO +85 -4
- views_frames-1.6.0/PKG-INFO → views_frames-1.8.0/README.md +53 -30
- {views_frames-1.6.0 → views_frames-1.8.0}/pyproject.toml +29 -5
- views_frames-1.8.0/src/views_frames_reconcile/__init__.py +24 -0
- views_frames-1.8.0/src/views_frames_reconcile/conformance.py +77 -0
- views_frames-1.8.0/src/views_frames_reconcile/frames.py +69 -0
- views_frames-1.8.0/src/views_frames_reconcile/grouping.py +92 -0
- views_frames-1.8.0/src/views_frames_reconcile/module.py +120 -0
- views_frames-1.8.0/src/views_frames_reconcile/proportional.py +77 -0
- views_frames-1.8.0/src/views_frames_reconcile/result.py +55 -0
- views_frames-1.8.0/src/views_frames_reconcile/validation.py +81 -0
- views_frames-1.8.0/src/views_frames_summarize/py.typed +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/.gitignore +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/LICENSE +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/__init__.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/_typing.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/_validation.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/conformance/__init__.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/feature_frame.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/index.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/io/__init__.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/io/arrow.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/io/npz.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/metadata.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/prediction_frame.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/protocols.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/py.typed +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/spatial_level.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames/target_frame.py +0 -0
- {views_frames-1.6.0/src/views_frames_summarize → views_frames-1.8.0/src/views_frames_reconcile}/py.typed +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/__init__.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/_common.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/aggregate.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/bimodality.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/collapse.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/config.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/conformance.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/exceedance.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/expected_shortfall.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/interval.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/point.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/summarize_tower.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/tower.py +0 -0
- {views_frames-1.6.0 → views_frames-1.8.0}/src/views_frames_summarize/tower_point.py +0 -0
|
@@ -1,22 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: views-frames
|
|
3
|
+
Version: 1.8.0
|
|
4
|
+
Summary: The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG).
|
|
5
|
+
Project-URL: Homepage, https://github.com/views-platform/views-frames
|
|
6
|
+
Project-URL: Repository, https://github.com/views-platform/views-frames
|
|
7
|
+
Project-URL: Changelog, https://github.com/views-platform/views-frames/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Simon Polichinel von der Maase <simmaa@prio.org>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: numpy<3,>=1.26
|
|
23
|
+
Provides-Extra: arrow
|
|
24
|
+
Requires-Dist: pyarrow<20,>=14; extra == 'arrow'
|
|
25
|
+
Provides-Extra: docs
|
|
26
|
+
Requires-Dist: jupyterlab<5,>=4; extra == 'docs'
|
|
27
|
+
Requires-Dist: matplotlib<4,>=3.8; extra == 'docs'
|
|
28
|
+
Requires-Dist: nbmake<2,>=1.5; extra == 'docs'
|
|
29
|
+
Requires-Dist: pyarrow<20,>=14; extra == 'docs'
|
|
30
|
+
Requires-Dist: scipy<2,>=1.11; extra == 'docs'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
1
33
|
# views-frames
|
|
2
34
|
|
|
3
35
|
> The VIEWS platform's **data-contract layer**: small, stable, abstract array
|
|
4
36
|
> containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings)
|
|
5
37
|
> that every other repo depends on and that depends on nothing internal.
|
|
6
38
|
>
|
|
7
|
-
> **Status:** **v1.
|
|
39
|
+
> **Status:** **v1.7.0 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1 surface is
|
|
8
40
|
> purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
|
|
9
41
|
> `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
|
|
10
42
|
> zeroing by default, register C-45; v1.4.0 adds generic provenance to `FrameMetadata`
|
|
11
43
|
> (`run_id`/`data_version`) and publishes the shared `assert_frame_envelope` checker,
|
|
12
44
|
> ADR-020; v1.5.0 adds the threshold **exceedance** estimator `P(Y > c)`, ADR-021; v1.6.0
|
|
13
|
-
> adds the worst-case **expected_shortfall** estimator, ADR-022
|
|
45
|
+
> adds the worst-case **expected_shortfall** estimator, ADR-022; v1.7.0 adds a third
|
|
46
|
+
> sibling package **`views_frames_reconcile`** — forecast reconciliation, ADR-023). This
|
|
14
47
|
> README is the design
|
|
15
48
|
> bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
|
|
16
49
|
> io, conformance suite) plus the `src/views_frames_summarize/` sibling package
|
|
17
50
|
> (sample-axis summarization — `collapse`/MAP/HDI/quantiles, the coherent-tower estimators
|
|
18
51
|
> `hdi_tower`/`tower_point`/`bimodality`/`summarize_tower`, + cross-level
|
|
19
|
-
> aggregation; ADR-017, ADR-019)
|
|
52
|
+
> aggregation; ADR-017, ADR-019) and the `src/views_frames_reconcile/` sibling package
|
|
53
|
+
> (forecast reconciliation — make grid forecasts sum to country totals per draw; ADR-023).
|
|
54
|
+
> The blocking design decisions are resolved (§13a) and
|
|
20
55
|
> ratified as ADRs 011–018; two rounds of consumer review validated
|
|
21
56
|
> the design. Consumer adoption (re-export shims, pandas migration) is the owner's
|
|
22
57
|
> migration, not this repo's.
|
|
@@ -220,7 +255,13 @@ makes it safe to depend on from everywhere (SDP).
|
|
|
220
255
|
3. **Immutable value objects.** A frame is validated at construction and then
|
|
221
256
|
treated as read-only. Operations (`collapse`, `select`, `with_metadata`)
|
|
222
257
|
**return new frames**; they never mutate in place. (Directly forbids the
|
|
223
|
-
C-184 cross-repo-mutation anti-pattern.) **
|
|
258
|
+
C-184 cross-repo-mutation anti-pattern.) **Enforced for the index, by
|
|
259
|
+
convention for the value buffer:** the identifier arrays (`time`/`unit`) are
|
|
260
|
+
write-protected (`setflags(write=False)`); the `values` buffer is left
|
|
261
|
+
**writeable on purpose** — so structural ops can share it zero-copy — and is
|
|
262
|
+
immutable *by convention*: mutating `.values` in place is unsupported and may
|
|
263
|
+
silently corrupt buffer-sharing frames (ADR-025 / register C-63; enforcing it
|
|
264
|
+
is a deferred MAJOR-rider). **Copy-vs-view:** structural and
|
|
224
265
|
metadata-only operations (`with_metadata`, contiguous `select`) return frames
|
|
225
266
|
that **share** the underlying `values` buffer (numpy view / zero-copy), and a
|
|
226
267
|
`mmap`-backed frame stays `mmap`-backed — a new frame must never copy a
|
|
@@ -395,6 +436,14 @@ views-frames/
|
|
|
395
436
|
│ ├── point.py # map_estimate (histogram MAP)
|
|
396
437
|
│ ├── interval.py # hdi, quantiles → arrays aligned to the frame index
|
|
397
438
|
│ └── aggregate.py # conservation-correct cross-level aggregation
|
|
439
|
+
├── src/views_frames_reconcile/ # forecast reconciliation OVER frames (ADR-023)
|
|
440
|
+
│ ├── __init__.py # depends on views_frames + numpy only; never the reverse
|
|
441
|
+
│ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
|
|
442
|
+
│ ├── grouping.py # reconcile_pgm_to_cm — group by (time, country)
|
|
443
|
+
│ ├── frames.py # prediction_frame_from_arrays adapter
|
|
444
|
+
│ ├── validation.py # fail-loud input guards
|
|
445
|
+
│ ├── module.py # ReconciliationModule (holds the injected mapping)
|
|
446
|
+
│ └── conformance.py # assert_reconcile_contract
|
|
398
447
|
└── tests/
|
|
399
448
|
├── conformance/ # the published contract suite consumers re-run (see §9)
|
|
400
449
|
└── unit/
|
|
@@ -417,6 +466,38 @@ Layout rules (these *are* the screaming-architecture requirements):
|
|
|
417
466
|
- A new developer should infer every responsibility from the file tree without
|
|
418
467
|
reading bodies.
|
|
419
468
|
|
|
469
|
+
### 6a. The `views_frames_reconcile` sibling (ADR-023)
|
|
470
|
+
|
|
471
|
+
Forecast **reconciliation** — making grid (`pgm`) predictions sum to their country
|
|
472
|
+
(`cm`) totals — is a numpy-only frame→frame operation, structurally the same kind of
|
|
473
|
+
thing as `views_frames_summarize` (ADR-017). It is its own sibling package, **not** in
|
|
474
|
+
the leaf and **not** in views-postprocessing (its old, mis-homed host).
|
|
475
|
+
|
|
476
|
+
**Charter.** **May:** forecast-reconciliation algorithms on frames (the per-draw
|
|
477
|
+
top-down proportional method; future methods as sibling modules — the principled
|
|
478
|
+
probabilistic upgrade, C-37); per-sample reconciliation; fail-loud validation; a
|
|
479
|
+
conformance suite. **Must not:** IO; **fetch the `(time, unit) → country` mapping** (it
|
|
480
|
+
is *injected* by the caller as numpy arrays, exactly like `cross_level_align` — ADR-014);
|
|
481
|
+
actuals/scoring (views-evaluation); plotting; or any `views_*` import except
|
|
482
|
+
`views_frames`. Import-DAG: `views_frames_reconcile → {views_frames}`.
|
|
483
|
+
|
|
484
|
+
```python
|
|
485
|
+
import numpy as np
|
|
486
|
+
from views_frames_reconcile import ReconciliationModule
|
|
487
|
+
|
|
488
|
+
# mapping is INJECTED by the caller (sourced from the producer; never fetched here):
|
|
489
|
+
# map_keys: (M, 2) int (time, priogrid_gid) map_vals: (M,) int country_id
|
|
490
|
+
reconciler = ReconciliationModule(map_keys, map_vals)
|
|
491
|
+
pgm_reconciled = reconciler.reconcile(cm_frame, pgm_frame) # new pgm PredictionFrame
|
|
492
|
+
# each (time, country) group's cells now sum, per draw, to the country total
|
|
493
|
+
# (all-zero grid draws stay zero); zeros preserved; non-negative.
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
> **Future DRY pass (deferred).** `grouping.py` labels grid rows by country with the
|
|
497
|
+
> leaf's `cross_level_align_arrays` and overlaps `cross_level_align` (index.py). Folding
|
|
498
|
+
> the two is a separate later story — the v1.7.0 relocation is a faithful **WET** move,
|
|
499
|
+
> proven bit-identical to the original before any refactor.
|
|
500
|
+
|
|
420
501
|
---
|
|
421
502
|
|
|
422
503
|
## 7. On-disk / serialization contract (where "doesn't scale" is actually decided)
|
|
@@ -1,48 +1,25 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: views-frames
|
|
3
|
-
Version: 1.6.0
|
|
4
|
-
Summary: The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG).
|
|
5
|
-
Project-URL: Homepage, https://github.com/views-platform/views-frames
|
|
6
|
-
Project-URL: Repository, https://github.com/views-platform/views-frames
|
|
7
|
-
Project-URL: Changelog, https://github.com/views-platform/views-frames/blob/main/CHANGELOG.md
|
|
8
|
-
Author-email: Simon Polichinel von der Maase <simmaa@prio.org>
|
|
9
|
-
License-Expression: MIT
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
-
Classifier: Intended Audience :: Science/Research
|
|
13
|
-
Classifier: Operating System :: OS Independent
|
|
14
|
-
Classifier: Programming Language :: Python :: 3
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
-
Classifier: Topic :: Scientific/Engineering
|
|
20
|
-
Classifier: Typing :: Typed
|
|
21
|
-
Requires-Python: >=3.10
|
|
22
|
-
Requires-Dist: numpy<3,>=1.26
|
|
23
|
-
Provides-Extra: arrow
|
|
24
|
-
Requires-Dist: pyarrow<20,>=14; extra == 'arrow'
|
|
25
|
-
Description-Content-Type: text/markdown
|
|
26
|
-
|
|
27
1
|
# views-frames
|
|
28
2
|
|
|
29
3
|
> The VIEWS platform's **data-contract layer**: small, stable, abstract array
|
|
30
4
|
> containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings)
|
|
31
5
|
> that every other repo depends on and that depends on nothing internal.
|
|
32
6
|
>
|
|
33
|
-
> **Status:** **v1.
|
|
7
|
+
> **Status:** **v1.7.0 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1 surface is
|
|
34
8
|
> purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
|
|
35
9
|
> `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
|
|
36
10
|
> zeroing by default, register C-45; v1.4.0 adds generic provenance to `FrameMetadata`
|
|
37
11
|
> (`run_id`/`data_version`) and publishes the shared `assert_frame_envelope` checker,
|
|
38
12
|
> ADR-020; v1.5.0 adds the threshold **exceedance** estimator `P(Y > c)`, ADR-021; v1.6.0
|
|
39
|
-
> adds the worst-case **expected_shortfall** estimator, ADR-022
|
|
13
|
+
> adds the worst-case **expected_shortfall** estimator, ADR-022; v1.7.0 adds a third
|
|
14
|
+
> sibling package **`views_frames_reconcile`** — forecast reconciliation, ADR-023). This
|
|
40
15
|
> README is the design
|
|
41
16
|
> bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
|
|
42
17
|
> io, conformance suite) plus the `src/views_frames_summarize/` sibling package
|
|
43
18
|
> (sample-axis summarization — `collapse`/MAP/HDI/quantiles, the coherent-tower estimators
|
|
44
19
|
> `hdi_tower`/`tower_point`/`bimodality`/`summarize_tower`, + cross-level
|
|
45
|
-
> aggregation; ADR-017, ADR-019)
|
|
20
|
+
> aggregation; ADR-017, ADR-019) and the `src/views_frames_reconcile/` sibling package
|
|
21
|
+
> (forecast reconciliation — make grid forecasts sum to country totals per draw; ADR-023).
|
|
22
|
+
> The blocking design decisions are resolved (§13a) and
|
|
46
23
|
> ratified as ADRs 011–018; two rounds of consumer review validated
|
|
47
24
|
> the design. Consumer adoption (re-export shims, pandas migration) is the owner's
|
|
48
25
|
> migration, not this repo's.
|
|
@@ -246,7 +223,13 @@ makes it safe to depend on from everywhere (SDP).
|
|
|
246
223
|
3. **Immutable value objects.** A frame is validated at construction and then
|
|
247
224
|
treated as read-only. Operations (`collapse`, `select`, `with_metadata`)
|
|
248
225
|
**return new frames**; they never mutate in place. (Directly forbids the
|
|
249
|
-
C-184 cross-repo-mutation anti-pattern.) **
|
|
226
|
+
C-184 cross-repo-mutation anti-pattern.) **Enforced for the index, by
|
|
227
|
+
convention for the value buffer:** the identifier arrays (`time`/`unit`) are
|
|
228
|
+
write-protected (`setflags(write=False)`); the `values` buffer is left
|
|
229
|
+
**writeable on purpose** — so structural ops can share it zero-copy — and is
|
|
230
|
+
immutable *by convention*: mutating `.values` in place is unsupported and may
|
|
231
|
+
silently corrupt buffer-sharing frames (ADR-025 / register C-63; enforcing it
|
|
232
|
+
is a deferred MAJOR-rider). **Copy-vs-view:** structural and
|
|
250
233
|
metadata-only operations (`with_metadata`, contiguous `select`) return frames
|
|
251
234
|
that **share** the underlying `values` buffer (numpy view / zero-copy), and a
|
|
252
235
|
`mmap`-backed frame stays `mmap`-backed — a new frame must never copy a
|
|
@@ -421,6 +404,14 @@ views-frames/
|
|
|
421
404
|
│ ├── point.py # map_estimate (histogram MAP)
|
|
422
405
|
│ ├── interval.py # hdi, quantiles → arrays aligned to the frame index
|
|
423
406
|
│ └── aggregate.py # conservation-correct cross-level aggregation
|
|
407
|
+
├── src/views_frames_reconcile/ # forecast reconciliation OVER frames (ADR-023)
|
|
408
|
+
│ ├── __init__.py # depends on views_frames + numpy only; never the reverse
|
|
409
|
+
│ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
|
|
410
|
+
│ ├── grouping.py # reconcile_pgm_to_cm — group by (time, country)
|
|
411
|
+
│ ├── frames.py # prediction_frame_from_arrays adapter
|
|
412
|
+
│ ├── validation.py # fail-loud input guards
|
|
413
|
+
│ ├── module.py # ReconciliationModule (holds the injected mapping)
|
|
414
|
+
│ └── conformance.py # assert_reconcile_contract
|
|
424
415
|
└── tests/
|
|
425
416
|
├── conformance/ # the published contract suite consumers re-run (see §9)
|
|
426
417
|
└── unit/
|
|
@@ -443,6 +434,38 @@ Layout rules (these *are* the screaming-architecture requirements):
|
|
|
443
434
|
- A new developer should infer every responsibility from the file tree without
|
|
444
435
|
reading bodies.
|
|
445
436
|
|
|
437
|
+
### 6a. The `views_frames_reconcile` sibling (ADR-023)
|
|
438
|
+
|
|
439
|
+
Forecast **reconciliation** — making grid (`pgm`) predictions sum to their country
|
|
440
|
+
(`cm`) totals — is a numpy-only frame→frame operation, structurally the same kind of
|
|
441
|
+
thing as `views_frames_summarize` (ADR-017). It is its own sibling package, **not** in
|
|
442
|
+
the leaf and **not** in views-postprocessing (its old, mis-homed host).
|
|
443
|
+
|
|
444
|
+
**Charter.** **May:** forecast-reconciliation algorithms on frames (the per-draw
|
|
445
|
+
top-down proportional method; future methods as sibling modules — the principled
|
|
446
|
+
probabilistic upgrade, C-37); per-sample reconciliation; fail-loud validation; a
|
|
447
|
+
conformance suite. **Must not:** IO; **fetch the `(time, unit) → country` mapping** (it
|
|
448
|
+
is *injected* by the caller as numpy arrays, exactly like `cross_level_align` — ADR-014);
|
|
449
|
+
actuals/scoring (views-evaluation); plotting; or any `views_*` import except
|
|
450
|
+
`views_frames`. Import-DAG: `views_frames_reconcile → {views_frames}`.
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
import numpy as np
|
|
454
|
+
from views_frames_reconcile import ReconciliationModule
|
|
455
|
+
|
|
456
|
+
# mapping is INJECTED by the caller (sourced from the producer; never fetched here):
|
|
457
|
+
# map_keys: (M, 2) int (time, priogrid_gid) map_vals: (M,) int country_id
|
|
458
|
+
reconciler = ReconciliationModule(map_keys, map_vals)
|
|
459
|
+
pgm_reconciled = reconciler.reconcile(cm_frame, pgm_frame) # new pgm PredictionFrame
|
|
460
|
+
# each (time, country) group's cells now sum, per draw, to the country total
|
|
461
|
+
# (all-zero grid draws stay zero); zeros preserved; non-negative.
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
> **Future DRY pass (deferred).** `grouping.py` labels grid rows by country with the
|
|
465
|
+
> leaf's `cross_level_align_arrays` and overlaps `cross_level_align` (index.py). Folding
|
|
466
|
+
> the two is a separate later story — the v1.7.0 relocation is a faithful **WET** move,
|
|
467
|
+
> proven bit-identical to the original before any refactor.
|
|
468
|
+
|
|
446
469
|
---
|
|
447
470
|
|
|
448
471
|
## 7. On-disk / serialization contract (where "doesn't scale" is actually decided)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "views-frames"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.8.0"
|
|
8
8
|
description = "The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG)."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "Simon Polichinel von der Maase", email = "simmaa@prio.org" },
|
|
@@ -39,6 +39,22 @@ Changelog = "https://github.com/views-platform/views-frames/blob/main/CHANGELOG.
|
|
|
39
39
|
# Optional serialization extra — pyarrow is allowed ONLY behind io/ (README §3.1).
|
|
40
40
|
[project.optional-dependencies]
|
|
41
41
|
arrow = ["pyarrow>=14,<20"]
|
|
42
|
+
# Optional docs/notebooks extra — plotting + jupyter for the showcase notebooks
|
|
43
|
+
# (notebooks/, an un-gated dev artifact outside src/). NEVER runtime dependencies;
|
|
44
|
+
# self-references [arrow] so `uv sync --extra docs` also enables the parquet round-trip
|
|
45
|
+
# demo in notebooks/01_frames.ipynb. matplotlib/scipy are notebook-only (the core is
|
|
46
|
+
# numpy-only); these are imported by notebooks, never by the package.
|
|
47
|
+
docs = [
|
|
48
|
+
"views-frames[arrow]",
|
|
49
|
+
"matplotlib>=3.8,<4",
|
|
50
|
+
"scipy>=1.11,<2",
|
|
51
|
+
"jupyterlab>=4,<5",
|
|
52
|
+
# nbmake drives the non-blocking notebook-execution CI check (issue #151): it just
|
|
53
|
+
# runs the notebooks end-to-end to catch frozen-API drift. A test tool, but it rides
|
|
54
|
+
# in [docs] so the single `uv sync --extra docs` provisions everything the notebooks
|
|
55
|
+
# job needs (kernel + plotting + the checker).
|
|
56
|
+
"nbmake>=1.5,<2",
|
|
57
|
+
]
|
|
42
58
|
|
|
43
59
|
[dependency-groups]
|
|
44
60
|
dev = [
|
|
@@ -52,7 +68,7 @@ dev = [
|
|
|
52
68
|
]
|
|
53
69
|
|
|
54
70
|
[tool.hatch.build.targets.wheel]
|
|
55
|
-
packages = ["src/views_frames", "src/views_frames_summarize"]
|
|
71
|
+
packages = ["src/views_frames", "src/views_frames_summarize", "src/views_frames_reconcile"]
|
|
56
72
|
|
|
57
73
|
[tool.hatch.build.targets.sdist]
|
|
58
74
|
include = ["/src"]
|
|
@@ -76,9 +92,10 @@ show_missing = true
|
|
|
76
92
|
[tool.ruff]
|
|
77
93
|
line-length = 88
|
|
78
94
|
target-version = "py310"
|
|
79
|
-
# research/ is the volatile estimator lab
|
|
80
|
-
#
|
|
81
|
-
|
|
95
|
+
# research/ is the volatile estimator lab and notebooks/ holds the un-gated showcase
|
|
96
|
+
# notebooks + their helper (notebooks/_synthetic.py) — both sit outside the frozen,
|
|
97
|
+
# gated src/ and are not held to the leaf's lint/format bar.
|
|
98
|
+
extend-exclude = ["research", "notebooks"]
|
|
82
99
|
|
|
83
100
|
[tool.ruff.lint]
|
|
84
101
|
select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "SIM"]
|
|
@@ -87,6 +104,13 @@ select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "SIM"]
|
|
|
87
104
|
# The falsification stubs assert against the README via long, literal regexes
|
|
88
105
|
# whose lines cannot wrap without changing their semantics.
|
|
89
106
|
"tests/test_falsification_*.py" = ["E501"]
|
|
107
|
+
# The reconciliation parity tests are faithfully relocated from views-postprocessing
|
|
108
|
+
# (Epic 11 / ADR-023); their dense numpy/dict-comprehension assertions are kept verbatim
|
|
109
|
+
# rather than re-wrapped.
|
|
110
|
+
"tests/test_reconciliation_*.py" = ["E501"]
|
|
111
|
+
# The dev/ops scripts (oracle regeneration; the reconciliation parity verifier) are not
|
|
112
|
+
# CI-run and carry dense numpy one-liners + report f-strings; not held to the wrap bar.
|
|
113
|
+
"scripts/*.py" = ["E501"]
|
|
90
114
|
|
|
91
115
|
[tool.mypy]
|
|
92
116
|
python_version = "3.10"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Forecast reconciliation (pgm forecasts reconciled to cm totals).
|
|
2
|
+
|
|
3
|
+
Slice 1 ports the top-down proportional method from views-reporting as a pure
|
|
4
|
+
numpy function. New methods (e.g. principled probabilistic reconciliation, C-37)
|
|
5
|
+
should be added as sibling modules, not by modifying ``proportional``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from views_frames_reconcile.module import ReconciliationModule
|
|
9
|
+
from views_frames_reconcile.proportional import reconcile_proportional
|
|
10
|
+
from views_frames_reconcile.result import (
|
|
11
|
+
ALIGNED_DRAWS,
|
|
12
|
+
METHOD_PROPORTIONAL,
|
|
13
|
+
POINT_BROADCAST,
|
|
14
|
+
ReconciliationResult,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ALIGNED_DRAWS",
|
|
19
|
+
"METHOD_PROPORTIONAL",
|
|
20
|
+
"POINT_BROADCAST",
|
|
21
|
+
"ReconciliationModule",
|
|
22
|
+
"ReconciliationResult",
|
|
23
|
+
"reconcile_proportional",
|
|
24
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Conformance checks for the reconcile package (ADR-023).
|
|
2
|
+
|
|
3
|
+
A consumer can re-run these against its own frame factories to confirm the reconciler
|
|
4
|
+
behaves: grid (``pgm``) predictions sum, per draw, to their country (``cm``) totals
|
|
5
|
+
(except all-zero grid draws, which stay zero — the documented edge); zeros preserved;
|
|
6
|
+
values stay non-negative; the output is a same-shape ``pgm`` ``PredictionFrame`` at PGM
|
|
7
|
+
level; and the cm/pgm mapping is **injected, never fetched** (ADR-014/ADR-023).
|
|
8
|
+
|
|
9
|
+
Mirrors ``views_frames_summarize/conformance.py:assert_summarizer_contract``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
from views_frames import PredictionFrame, SpatialLevel
|
|
20
|
+
from views_frames_reconcile.module import ReconciliationModule
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def assert_reconcile_contract(
|
|
24
|
+
cm_frame: PredictionFrame,
|
|
25
|
+
pgm_frame: PredictionFrame,
|
|
26
|
+
map_keys: NDArray[np.integer[Any]] | object,
|
|
27
|
+
map_vals: NDArray[np.integer[Any]] | object,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Assert the reconciler obeys its contract on ``(cm_frame, pgm_frame, mapping)``.
|
|
30
|
+
|
|
31
|
+
The mapping is **injected** (``map_keys``/``map_vals`` arrays) — never fetched
|
|
32
|
+
(enforced by the signature + the import-DAG; honoured here by the sum-to-country
|
|
33
|
+
law, which only holds if each cell is grouped under its injected country).
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
AssertionError: a contract law is violated.
|
|
37
|
+
"""
|
|
38
|
+
mk = np.asarray(map_keys)
|
|
39
|
+
mv = np.asarray(map_vals)
|
|
40
|
+
out = ReconciliationModule(mk, mv).reconcile(cm_frame, pgm_frame)
|
|
41
|
+
|
|
42
|
+
# 1. Output type / shape / level / index: a same-shape pgm PredictionFrame at PGM.
|
|
43
|
+
assert type(out) is type(pgm_frame), "reconcile must return the pgm frame type"
|
|
44
|
+
assert out.values.shape == pgm_frame.values.shape, "reconcile must preserve (N, S)"
|
|
45
|
+
assert out.index.level is SpatialLevel.PGM, "reconciled frame stays at PGM level"
|
|
46
|
+
assert np.array_equal(out.index.time, pgm_frame.index.time), "time index preserved"
|
|
47
|
+
assert np.array_equal(out.index.unit, pgm_frame.index.unit), "unit index preserved"
|
|
48
|
+
|
|
49
|
+
# 2. Non-negativity.
|
|
50
|
+
assert bool((out.values >= 0).all()), "reconciled forecasts must be non-negative"
|
|
51
|
+
|
|
52
|
+
# 3. Zero-preservation: an input zero cell stays zero.
|
|
53
|
+
assert bool((out.values[pgm_frame.values == 0] == 0).all()), "zeros preserved"
|
|
54
|
+
|
|
55
|
+
# 4. Sum-to-country per draw: each (time, country) group's cells sum, per draw, to
|
|
56
|
+
# its country total — except all-zero input draws, which stay zero (the edge).
|
|
57
|
+
cm_units = pgm_frame.index.cross_level_align_arrays(mk, mv, SpatialLevel.CM).unit
|
|
58
|
+
pg_time = pgm_frame.index.time
|
|
59
|
+
cm_pos = {
|
|
60
|
+
(int(t), int(u)): j
|
|
61
|
+
for j, (t, u) in enumerate(
|
|
62
|
+
zip(cm_frame.index.time, cm_frame.index.unit, strict=True)
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
for t, c in np.unique(np.stack([pg_time, cm_units], axis=1), axis=0):
|
|
66
|
+
rows = (pg_time == t) & (cm_units == c)
|
|
67
|
+
in_sum = pgm_frame.values[rows].sum(axis=0) # (S,)
|
|
68
|
+
out_sum = out.values[rows].sum(axis=0) # (S,)
|
|
69
|
+
# cm may be a point (sample_count == 1, broadcast inside reconcile) or aligned
|
|
70
|
+
# draws — broadcast its per-(time, country) total to the draw axis either way.
|
|
71
|
+
cm_total = cm_frame.values[cm_pos[(int(t), int(c))]]
|
|
72
|
+
total = np.broadcast_to(cm_total, out_sum.shape)
|
|
73
|
+
active = in_sum != 0
|
|
74
|
+
np.testing.assert_allclose(
|
|
75
|
+
out_sum[active], total[active], rtol=1e-4, atol=1e-3
|
|
76
|
+
)
|
|
77
|
+
assert bool((out_sum[~active] == 0).all()), "all-zero draws stay zero"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Array → `PredictionFrame` adapters for reconciliation (epic #31, story #33).
|
|
2
|
+
|
|
3
|
+
Reconciliation works on views-frames `PredictionFrame`s at two spatial levels:
|
|
4
|
+
country (`cm`) and PRIO-GRID (`pgm`). Both are built the same way from
|
|
5
|
+
`(time, unit, values)` arrays — only the `SpatialLevel` and the unit identifier
|
|
6
|
+
(`country_id` vs `priogrid_gid`) differ. Predictions here carry a real posterior
|
|
7
|
+
sample axis, so values are `(N, S)` with `S >= 1`.
|
|
8
|
+
|
|
9
|
+
This is the reconciliation package's **own** I/O: it differs from the unfao
|
|
10
|
+
delivery adapters (`unfao/frames.py`), which wrap pandas *scalar* columns as
|
|
11
|
+
`(N, 1)` point frames — so per CRP they are not forced together. numpy +
|
|
12
|
+
views-frames only; no torch, no pandas.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from numpy.typing import NDArray
|
|
21
|
+
|
|
22
|
+
from views_frames import (
|
|
23
|
+
FrameMetadata,
|
|
24
|
+
PredictionFrame,
|
|
25
|
+
SpatialLevel,
|
|
26
|
+
SpatioTemporalIndex,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prediction_frame_from_arrays(
|
|
31
|
+
time: NDArray[np.integer[Any]] | object,
|
|
32
|
+
unit: NDArray[np.integer[Any]] | object,
|
|
33
|
+
values: NDArray[np.floating[Any]] | object,
|
|
34
|
+
*,
|
|
35
|
+
level: SpatialLevel,
|
|
36
|
+
metadata: FrameMetadata | None = None,
|
|
37
|
+
) -> PredictionFrame:
|
|
38
|
+
"""Build a `PredictionFrame` from `(time, unit, values)` at ``level``.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
time: 1-D integer array, length ``N`` (``month_id``).
|
|
42
|
+
unit: 1-D integer array, length ``N`` — ``country_id`` for
|
|
43
|
+
``SpatialLevel.CM``, ``priogrid_gid`` for ``SpatialLevel.PGM``.
|
|
44
|
+
values: ``(N, S)`` float32-coercible array of posterior samples.
|
|
45
|
+
level: the frame's spatial level.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A `PredictionFrame` of shape ``(N, S)`` at ``level``. The values buffer
|
|
49
|
+
is reused without copy when already float32 (views-frames C-07); the
|
|
50
|
+
input arrays are never mutated.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: ``values`` is not 2-D, or ``time``/``unit`` are not 1-D of
|
|
54
|
+
length ``N``.
|
|
55
|
+
"""
|
|
56
|
+
time_arr = np.asarray(time, dtype=np.int64)
|
|
57
|
+
unit_arr = np.asarray(unit, dtype=np.int64)
|
|
58
|
+
vals = np.asarray(values, dtype=np.float32)
|
|
59
|
+
|
|
60
|
+
if vals.ndim != 2:
|
|
61
|
+
raise ValueError(f"values must be 2-D (N, S); got ndim={vals.ndim}")
|
|
62
|
+
if time_arr.shape != (vals.shape[0],) or unit_arr.shape != (vals.shape[0],):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"time {time_arr.shape} and unit {unit_arr.shape} must both be 1-D "
|
|
65
|
+
f"of length N={vals.shape[0]}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
index = SpatioTemporalIndex(time=time_arr, unit=unit_arr, level=level)
|
|
69
|
+
return PredictionFrame(vals, index, metadata)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Reconcile a pgm `PredictionFrame` to cm country totals (epic #31, story #34).
|
|
2
|
+
|
|
3
|
+
The heart of the migration: for each `(time, country)`, scale that country's grid
|
|
4
|
+
cells so their per-draw sum matches the country forecast, using the parity-proven
|
|
5
|
+
leaf `reconcile_proportional` (PR #30). Grid rows are labelled by country with
|
|
6
|
+
views-frames `cross_level_align` — the sanctioned cm↔pgm primitive, which fails
|
|
7
|
+
loud if any grid row lacks a country (mirrors the original's "valid countries"
|
|
8
|
+
guard). De-mutated: returns a **new** pgm frame (C-184); the input is untouched.
|
|
9
|
+
|
|
10
|
+
numpy + views-frames only. The loop is over `(time, country)` groups (a small
|
|
11
|
+
number), not over rows; each group call is fully vectorised over cells × samples.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from numpy.typing import NDArray
|
|
20
|
+
|
|
21
|
+
from views_frames import PredictionFrame, SpatialLevel
|
|
22
|
+
from views_frames_reconcile.proportional import reconcile_proportional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reconcile_pgm_to_cm(
|
|
26
|
+
pgm_frame: PredictionFrame,
|
|
27
|
+
cm_frame: PredictionFrame,
|
|
28
|
+
map_keys: NDArray[np.integer[Any]] | object,
|
|
29
|
+
map_vals: NDArray[np.integer[Any]] | object,
|
|
30
|
+
) -> PredictionFrame:
|
|
31
|
+
"""Return a new pgm `PredictionFrame` reconciled to ``cm_frame``'s totals.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
pgm_frame: grid forecasts at PGM level, values ``(N_pg, S)``.
|
|
35
|
+
cm_frame: country forecasts at CM level, values ``(N_cm, S)``.
|
|
36
|
+
map_keys: ``(M, 2)`` int ``(time, priogrid_gid)`` covering every pgm row.
|
|
37
|
+
map_vals: ``(M,)`` int ``country_id`` for each key (injected; geography is
|
|
38
|
+
never embedded here — views-frames ADR-014).
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A new pgm `PredictionFrame` (same index/metadata as ``pgm_frame``) whose
|
|
42
|
+
cells sum, per draw, to the country forecast — except all-zero country
|
|
43
|
+
draws, which stay zero (the leaf's documented edge case).
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: a grid row has no country mapping (raised by
|
|
47
|
+
``cross_level_align``), or a ``(time, country)`` group has no matching
|
|
48
|
+
country forecast in ``cm_frame``.
|
|
49
|
+
"""
|
|
50
|
+
# 1. Label every grid row with its country (cm-level units); fails loud if a
|
|
51
|
+
# row's (time, priogrid) is absent from the injected mapping.
|
|
52
|
+
cm_units = pgm_frame.index.cross_level_align_arrays(
|
|
53
|
+
np.asarray(map_keys), np.asarray(map_vals), SpatialLevel.CM
|
|
54
|
+
).unit # (N_pg,)
|
|
55
|
+
pg_time = pgm_frame.index.time
|
|
56
|
+
|
|
57
|
+
# 2. (time, country) -> row position in the country frame.
|
|
58
|
+
cm_time = cm_frame.index.time
|
|
59
|
+
cm_unit = cm_frame.index.unit
|
|
60
|
+
cm_vals = cm_frame.values
|
|
61
|
+
cm_pos = {(int(cm_time[j]), int(cm_unit[j])): j for j in range(cm_frame.n_rows)}
|
|
62
|
+
|
|
63
|
+
# 3. Group grid rows by (time, country) and reconcile each group with the leaf.
|
|
64
|
+
# Group-by-sort: a single argsort lays the rows out contiguously per group
|
|
65
|
+
# (O(N log N), one index array) — no per-group full-array scan — so it
|
|
66
|
+
# scales to the full grid (register C-38). Group iteration order is
|
|
67
|
+
# irrelevant: each group writes only its own rows.
|
|
68
|
+
pg_vals = pgm_frame.values
|
|
69
|
+
out = np.empty_like(pg_vals)
|
|
70
|
+
group_key = np.stack([pg_time, cm_units], axis=1) # (N_pg, 2)
|
|
71
|
+
unique_groups, inverse, counts = np.unique(
|
|
72
|
+
group_key, axis=0, return_inverse=True, return_counts=True
|
|
73
|
+
)
|
|
74
|
+
inverse = np.asarray(inverse).reshape(-1)
|
|
75
|
+
order = np.argsort(inverse, kind="stable") # rows grouped contiguously by group
|
|
76
|
+
# group gi -> order[bounds[gi]:bounds[gi + 1]]
|
|
77
|
+
bounds = np.concatenate(([0], np.cumsum(counts)))
|
|
78
|
+
|
|
79
|
+
for gi in range(unique_groups.shape[0]):
|
|
80
|
+
t, c = int(unique_groups[gi, 0]), int(unique_groups[gi, 1])
|
|
81
|
+
if (t, c) not in cm_pos:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"grid group (time={t}, country={c}) has no country forecast "
|
|
84
|
+
"in cm_frame"
|
|
85
|
+
)
|
|
86
|
+
rows = order[bounds[gi]:bounds[gi + 1]]
|
|
87
|
+
country_total = cm_vals[cm_pos[(t, c)]] # (S,)
|
|
88
|
+
# leaf convention: grid is (samples, cells); our frame slice is (cells, samples)
|
|
89
|
+
scaled = reconcile_proportional(pg_vals[rows].T, country_total) # (S, n_cells)
|
|
90
|
+
out[rows] = scaled.T # back to (n_cells, S)
|
|
91
|
+
|
|
92
|
+
return PredictionFrame(out, pgm_frame.index, pgm_frame.metadata)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Frames-native reconciliation orchestration (epic #31, story #36).
|
|
2
|
+
|
|
3
|
+
`ReconciliationModule` holds the injected `(time, priogrid_gid) -> country_id`
|
|
4
|
+
mapping (geography is injected, never embedded — views-frames ADR-014) and
|
|
5
|
+
applies it: `reconcile(cm_frame, pgm_frame)` validates the inputs and scales the
|
|
6
|
+
grid forecasts to the country totals, returning a **new** pgm frame (de-mutated,
|
|
7
|
+
C-184).
|
|
8
|
+
|
|
9
|
+
**SRP:** orchestration only — the scaling math is the leaf (`proportional`), the
|
|
10
|
+
grouping is `grouping`, the guards are `validation`, the I/O is `frames`. No
|
|
11
|
+
torch, no pandas, no viewser, no wandb: the original's `ProcessPoolExecutor` and
|
|
12
|
+
WandB alerting are dropped (numpy is fast; there is no GPU). If scale ever needs
|
|
13
|
+
parallelism, add it behind this same interface (OCP). Multi-target inputs are
|
|
14
|
+
reconciled by calling `reconcile` once per target.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
from numpy.typing import NDArray
|
|
23
|
+
|
|
24
|
+
from views_frames import PredictionFrame
|
|
25
|
+
from views_frames_reconcile.grouping import reconcile_pgm_to_cm
|
|
26
|
+
from views_frames_reconcile.result import (
|
|
27
|
+
ALIGNED_DRAWS,
|
|
28
|
+
METHOD_PROPORTIONAL,
|
|
29
|
+
POINT_BROADCAST,
|
|
30
|
+
ReconciliationResult,
|
|
31
|
+
)
|
|
32
|
+
from views_frames_reconcile.validation import validate_reconciliation_inputs
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _broadcast_point_country(
|
|
36
|
+
cm_frame: PredictionFrame, n_samples: int
|
|
37
|
+
) -> PredictionFrame:
|
|
38
|
+
"""Tile a point country forecast (``sample_count == 1``) across ``n_samples`` draws.
|
|
39
|
+
|
|
40
|
+
Bit-identical to the WET broadcast pipeline-core's ``align_country_to_grid`` does
|
|
41
|
+
(views-frames#143): the single column is repeated, so every draw is rescaled to the
|
|
42
|
+
same total. It lives in the orchestrator so the leaf ``proportional`` and the
|
|
43
|
+
parity-frozen ``grouping`` hot loop stay **untouched** — the equal-count path is
|
|
44
|
+
byte-for-byte identical and only ``sample_count == 1`` takes this new path.
|
|
45
|
+
"""
|
|
46
|
+
tiled = np.tile(np.asarray(cm_frame.values, dtype=np.float32), (1, n_samples))
|
|
47
|
+
return PredictionFrame(tiled, cm_frame.index, cm_frame.metadata)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ReconciliationModule:
|
|
51
|
+
"""Reconcile pgm forecasts to cm country totals (one target per call)."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
map_keys: NDArray[np.integer[Any]] | object,
|
|
56
|
+
map_vals: NDArray[np.integer[Any]] | object,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Inject the `(time, priogrid_gid) -> country_id` mapping.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
map_keys: ``(M, 2)`` int array of ``(time, priogrid_gid)`` pairs.
|
|
62
|
+
map_vals: ``(M,)`` int ``country_id`` for each key.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: ``map_keys`` is not ``(M, 2)`` or ``map_vals`` is not
|
|
66
|
+
length ``M``.
|
|
67
|
+
"""
|
|
68
|
+
keys = np.asarray(map_keys)
|
|
69
|
+
vals = np.asarray(map_vals)
|
|
70
|
+
if keys.ndim != 2 or keys.shape[1] != 2:
|
|
71
|
+
raise ValueError("map_keys must be an (M, 2) array of (time, priogrid_gid)")
|
|
72
|
+
if vals.shape != (keys.shape[0],):
|
|
73
|
+
raise ValueError("map_vals must be a length-M array aligned to map_keys")
|
|
74
|
+
self._map_keys = keys
|
|
75
|
+
self._map_vals = vals
|
|
76
|
+
|
|
77
|
+
def reconcile_result(
|
|
78
|
+
self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
|
|
79
|
+
) -> ReconciliationResult:
|
|
80
|
+
"""Reconcile, also reporting the mode — a ``ReconciliationResult``.
|
|
81
|
+
|
|
82
|
+
A **point** country (``cm.sample_count == 1``) is broadcast across the grid's
|
|
83
|
+
draws before reconciling (mode ``point-broadcast``); an **aligned** country
|
|
84
|
+
(``cm.sample_count == S``) scales draw-for-draw (mode ``aligned-draws``, the
|
|
85
|
+
documented per-draw approximation). Any other count fails loud.
|
|
86
|
+
|
|
87
|
+
The mode is **returned**, never stamped on the leaf's generic ``FrameMetadata``
|
|
88
|
+
(ADR-020 / register C-47 — the leaf carries no reconciliation vocabulary; see
|
|
89
|
+
``result.py``).
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: the inputs fail validation (level / sample-count — cm must be 1
|
|
93
|
+
or pgm's ``S`` / time coverage / missing country forecast).
|
|
94
|
+
"""
|
|
95
|
+
validate_reconciliation_inputs(
|
|
96
|
+
cm_frame, pgm_frame, self._map_keys, self._map_vals
|
|
97
|
+
)
|
|
98
|
+
if cm_frame.sample_count != pgm_frame.sample_count:
|
|
99
|
+
cm = _broadcast_point_country(cm_frame, pgm_frame.sample_count)
|
|
100
|
+
mode = POINT_BROADCAST
|
|
101
|
+
else:
|
|
102
|
+
cm = cm_frame
|
|
103
|
+
mode = ALIGNED_DRAWS
|
|
104
|
+
frame = reconcile_pgm_to_cm(pgm_frame, cm, self._map_keys, self._map_vals)
|
|
105
|
+
return ReconciliationResult(frame=frame, mode=mode, method=METHOD_PROPORTIONAL)
|
|
106
|
+
|
|
107
|
+
def reconcile(
|
|
108
|
+
self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
|
|
109
|
+
) -> PredictionFrame:
|
|
110
|
+
"""Validate the inputs, then return a new pgm frame reconciled to cm totals.
|
|
111
|
+
|
|
112
|
+
Point/aligned handling and fail-loud are as :meth:`reconcile_result`; this
|
|
113
|
+
returns only the reconciled frame. Call :meth:`reconcile_result` when you also
|
|
114
|
+
need the reconciliation *mode* (point-broadcast vs aligned-draws).
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: the inputs fail validation (level / sample-count — cm must be 1
|
|
118
|
+
or pgm's ``S`` / time coverage / missing country forecast).
|
|
119
|
+
"""
|
|
120
|
+
return self.reconcile_result(cm_frame, pgm_frame).frame
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Top-down proportional reconciliation (numpy port — phase 2, slice 1).
|
|
2
|
+
|
|
3
|
+
Makes PRIO-GRID-month (pgm) forecasts sum to their country-month (cm) total by
|
|
4
|
+
**top-down disaggregation using forecast proportions** (FPP3 terminology),
|
|
5
|
+
applied **per posterior draw**: within a draw, each grid cell keeps its relative
|
|
6
|
+
share and the cells are rescaled so they sum to that draw's country total. Zeros
|
|
7
|
+
stay zero; country totals are authoritative; the result is non-negative.
|
|
8
|
+
|
|
9
|
+
This is a *faithful, numpy-only* port of views-reporting's
|
|
10
|
+
``ForecastReconciler.reconcile_forecast`` (torch), migrated here because the
|
|
11
|
+
algorithm belongs in post-processing, not reporting (views-reporting issue #72).
|
|
12
|
+
It is intentionally the **same** method — a pragmatic per-draw approximation, not
|
|
13
|
+
principled joint probabilistic reconciliation. The upgrade to the latter is designed
|
|
14
|
+
in **ADR-024** (register **C-62**; the cross-repo lineage is views-postprocessing
|
|
15
|
+
C-37) and is deliberately **deferred**: per-draw pairing of independently-trained
|
|
16
|
+
grid and country draws has no shared draw identity, so the principled method waits on
|
|
17
|
+
a defined draw-identity/coupling contract and a consumer that needs calibrated joint
|
|
18
|
+
tails.
|
|
19
|
+
|
|
20
|
+
No torch, no pandas — numpy only.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
from numpy.typing import NDArray
|
|
29
|
+
|
|
30
|
+
_EPS = np.float32(1e-8)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reconcile_proportional(
|
|
34
|
+
grid: NDArray[np.floating[Any]] | object,
|
|
35
|
+
country: NDArray[np.floating[Any]] | float | object,
|
|
36
|
+
) -> NDArray[np.float32]:
|
|
37
|
+
"""Rescale grid forecasts so each draw sums to its country total.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
grid: Grid-level forecasts, float32-coercible. Either
|
|
41
|
+
``(num_samples, num_grid_cells)`` (probabilistic) or
|
|
42
|
+
``(num_grid_cells,)`` (point).
|
|
43
|
+
country: Country-level total. Either ``(num_samples,)`` (probabilistic)
|
|
44
|
+
or a scalar (point). Must align with ``grid``'s sample axis.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Adjusted grid forecasts, float32, same shape as ``grid``. ``sum`` over
|
|
48
|
+
grid cells equals ``country`` per sample; zero cells stay zero; values
|
|
49
|
+
are clamped to be non-negative.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: the grid and country sample counts disagree.
|
|
53
|
+
"""
|
|
54
|
+
grid_arr = np.asarray(grid, dtype=np.float32)
|
|
55
|
+
is_point = grid_arr.ndim == 1
|
|
56
|
+
|
|
57
|
+
if is_point:
|
|
58
|
+
grid_arr = grid_arr[np.newaxis, :] # (1, N)
|
|
59
|
+
country_arr = np.asarray([country], dtype=np.float32)
|
|
60
|
+
else:
|
|
61
|
+
country_arr = np.asarray(country, dtype=np.float32).reshape(-1)
|
|
62
|
+
|
|
63
|
+
if grid_arr.shape[0] != country_arr.shape[0]:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Mismatch in sample count: grid has {grid_arr.shape[0]}, "
|
|
66
|
+
f"country has {country_arr.shape[0]}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Preserve zeros: only strictly-positive cells carry probability mass.
|
|
70
|
+
nonzero = np.where(grid_arr > 0, grid_arr, np.float32(0.0))
|
|
71
|
+
|
|
72
|
+
# Per-draw proportional scaling to the (authoritative) country total.
|
|
73
|
+
sum_nonzero = nonzero.sum(axis=1, keepdims=True) # (S, 1)
|
|
74
|
+
scaling = country_arr.reshape(-1, 1) / (sum_nonzero + _EPS) # (S, 1)
|
|
75
|
+
adjusted = np.clip(nonzero * scaling, 0.0, None).astype(np.float32)
|
|
76
|
+
|
|
77
|
+
return np.asarray(adjusted[0] if is_point else adjusted, dtype=np.float32)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The reconciliation result — the reconciled frame plus *how* it was produced (#144).
|
|
2
|
+
|
|
3
|
+
The reconciliation **mode** (`point-broadcast` vs `aligned-draws`) and **method** are
|
|
4
|
+
reported here, on a reconcile-package result object — deliberately **not** stamped onto
|
|
5
|
+
the leaf's generic ``FrameMetadata``. That header is governed *generic-only* (ADR-020 /
|
|
6
|
+
register C-47): the numpy leaf carries provenance meaningful for *any* frame (`run_id`,
|
|
7
|
+
`data_version`) and must never carry domain/operation vocabulary it cannot know — as
|
|
8
|
+
evaluation provenance lives in views-evaluation's ``MetricFrame``, not the leaf.
|
|
9
|
+
Reconciliation is a sibling operation, so its provenance is reported by the sibling.
|
|
10
|
+
|
|
11
|
+
A caller that needs the mode reads it off :class:`ReconciliationResult`
|
|
12
|
+
(``ReconciliationModule.reconcile_result``); a caller that only needs the frame uses
|
|
13
|
+
``ReconciliationModule.reconcile``, which returns the frame directly.
|
|
14
|
+
|
|
15
|
+
The mode reflects **what this reconcile call did** (broadcast a point, or scaled aligned
|
|
16
|
+
draws) — not upstream provenance: a caller that pre-tiles a point country to ``S`` draws
|
|
17
|
+
before calling reconciles in ``aligned-draws`` mode. The string values match
|
|
18
|
+
pipeline-core's ``reconcile_frames`` constants verbatim, so the *vocabulary* is shared
|
|
19
|
+
across the repos that produce and consume the mode.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from views_frames import PredictionFrame
|
|
27
|
+
|
|
28
|
+
#: Mode — the call **broadcast a point**: the country arrived as a point
|
|
29
|
+
#: (``cm.sample_count == 1``) against a multi-draw grid and was tiled across the grid's
|
|
30
|
+
#: ``S`` draws (every draw rescaled to the same total).
|
|
31
|
+
POINT_BROADCAST = "point-broadcast"
|
|
32
|
+
#: Mode — the call **scaled aligned draws**: the country already carried the grid's draw
|
|
33
|
+
#: count (``cm.sample_count == S``) and was scaled draw-for-draw — the per-draw
|
|
34
|
+
#: approximation (the principled joint upgrade is a separate design, #145). The mode
|
|
35
|
+
#: describes *what this call did*: a point against a single-draw grid, or a point the
|
|
36
|
+
#: caller pre-tiled to ``S``, also reads as aligned-draws (nothing was broadcast).
|
|
37
|
+
ALIGNED_DRAWS = "aligned-draws"
|
|
38
|
+
#: The reconciliation algorithm — top-down proportional, per draw.
|
|
39
|
+
METHOD_PROPORTIONAL = "proportional"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ReconciliationResult:
|
|
44
|
+
"""A reconciled grid frame plus how it was produced.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
frame: the reconciled pgm ``PredictionFrame`` (identical to what ``reconcile``
|
|
48
|
+
returns).
|
|
49
|
+
mode: :data:`POINT_BROADCAST` or :data:`ALIGNED_DRAWS`.
|
|
50
|
+
method: the reconciliation method, e.g. :data:`METHOD_PROPORTIONAL`.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
frame: PredictionFrame
|
|
54
|
+
mode: str
|
|
55
|
+
method: str
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Fail-loud validation for reconciliation inputs (epic #31, story #35).
|
|
2
|
+
|
|
3
|
+
Ports views-reporting `ReconciliationModule.__init__`'s guards to frames-native
|
|
4
|
+
checks, run before any work. SRP: small, independently testable helpers — not
|
|
5
|
+
buried in the orchestrator. The original's intents map as:
|
|
6
|
+
|
|
7
|
+
- dataset type checks -> spatial-level guard (cm@CM, pgm@PGM)
|
|
8
|
+
- same time steps + exact overlap -> identical set of time values
|
|
9
|
+
- (per-draw scaling needs it) -> equal `sample_count`, or a point cm
|
|
10
|
+
(`sample_count == 1`) broadcast across the draws
|
|
11
|
+
- valid countries -> every (time, country) the grid maps to has
|
|
12
|
+
a country forecast in the cm frame
|
|
13
|
+
|
|
14
|
+
The original's "different time units" (e.g. month_id vs year_id) check is
|
|
15
|
+
subsumed by the level guard; per-target intersection is handled by the
|
|
16
|
+
orchestrator, since frames here are single-target.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from numpy.typing import NDArray
|
|
25
|
+
|
|
26
|
+
from views_frames import PredictionFrame, SpatialLevel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_reconciliation_inputs(
|
|
30
|
+
cm_frame: PredictionFrame,
|
|
31
|
+
pgm_frame: PredictionFrame,
|
|
32
|
+
map_keys: NDArray[np.integer[Any]] | object,
|
|
33
|
+
map_vals: NDArray[np.integer[Any]] | object,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Raise ``ValueError`` if the reconciliation inputs are inconsistent.
|
|
36
|
+
|
|
37
|
+
Checks spatial levels, sample-count alignment, identical time coverage, and
|
|
38
|
+
that every country the grid maps to has a forecast in ``cm_frame``.
|
|
39
|
+
"""
|
|
40
|
+
if cm_frame.index.level is not SpatialLevel.CM:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"country frame must be at SpatialLevel.CM, got {cm_frame.index.level}"
|
|
43
|
+
)
|
|
44
|
+
if pgm_frame.index.level is not SpatialLevel.PGM:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"grid frame must be at SpatialLevel.PGM, got {pgm_frame.index.level}"
|
|
47
|
+
)
|
|
48
|
+
if cm_frame.sample_count not in (1, pgm_frame.sample_count):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"sample-count mismatch: cm has {cm_frame.sample_count}, pgm has "
|
|
51
|
+
f"{pgm_frame.sample_count}; cm must be 1 (point, broadcast across draws) "
|
|
52
|
+
f"or {pgm_frame.sample_count} (aligned draws)"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
cm_times = {int(t) for t in np.unique(cm_frame.index.time)}
|
|
56
|
+
pg_times = {int(t) for t in np.unique(pgm_frame.index.time)}
|
|
57
|
+
if cm_times != pg_times:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"cm and pgm cover different time steps: "
|
|
60
|
+
f"cm-only={sorted(cm_times - pg_times)}, "
|
|
61
|
+
f"pgm-only={sorted(pg_times - cm_times)}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Valid countries: every (time, country) the grid maps to must have a forecast.
|
|
65
|
+
cm_units = pgm_frame.index.cross_level_align_arrays(
|
|
66
|
+
np.asarray(map_keys), np.asarray(map_vals), SpatialLevel.CM
|
|
67
|
+
).unit
|
|
68
|
+
needed = {
|
|
69
|
+
(int(t), int(c))
|
|
70
|
+
for t, c in zip(pgm_frame.index.time, cm_units, strict=True)
|
|
71
|
+
}
|
|
72
|
+
have = {
|
|
73
|
+
(int(t), int(c))
|
|
74
|
+
for t, c in zip(cm_frame.index.time, cm_frame.index.unit, strict=True)
|
|
75
|
+
}
|
|
76
|
+
missing = needed - have
|
|
77
|
+
if missing:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"{len(missing)} grid group(s) have no country forecast in cm_frame, "
|
|
80
|
+
f"e.g. {sorted(missing)[:5]}"
|
|
81
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|