coarsegate 0.1.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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joshua Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.5
2
+ Name: coarsegate
3
+ Version: 0.1.0
4
+ Summary: Measure what a coarsening removes, before interpreting a spatial-resolution comparison
5
+ Author: Joshua Wang
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: change of support,cross-validation,downscaling,ecology,spatial resolution,statistical power
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: numpy>=1.24
16
+ Requires-Dist: pandas>=2.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pyarrow>=14; extra == 'dev'
19
+ Requires-Dist: pytest>=7; extra == 'dev'
20
+ Provides-Extra: sim
21
+ Requires-Dist: scikit-learn>=1.3; extra == 'sim'
22
+ Requires-Dist: scipy>=1.10; extra == 'sim'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # coarsegate
26
+
27
+ Measure what a coarsening removes, before you interpret a spatial-resolution
28
+ comparison.
29
+
30
+ ## The problem
31
+
32
+ A common design asks whether finer predictors beat coarser ones: take a
33
+ predictor field, block-average it onto a coarse grid, fit the same model to
34
+ both arms, compare. The design looks airtight, because resolution is the only
35
+ thing that varies.
36
+
37
+ It can still fail to test its own hypothesis. Over a small extent, block
38
+ averaging barely changes the predictors. The two arms are fed nearly the same
39
+ numbers, the comparison cannot come out either way, and it returns a null that
40
+ means nothing. Nothing in the output distinguishes that case from a real
41
+ negative result.
42
+
43
+ The condition is measurable from the predictors alone, with no model fitted, so
44
+ it is available while the design can still be changed.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install coarsegate
50
+ ```
51
+
52
+ ## Use
53
+
54
+ ```python
55
+ from coarsegate import precondition_report
56
+
57
+ report = precondition_report(
58
+ df, features=["tmax", "ppt", "cmd"],
59
+ cell_size=4000, # same units as x and y
60
+ x="x_m", y="y_m", # projected coordinates, not degrees
61
+ time="year", # optional; a panel needs it, one slice does not
62
+ )
63
+ print(report)
64
+ ```
65
+
66
+ ```
67
+ coarsening 153 sites to 25 cells of 4000 units
68
+ spatial variance removed (median over 14 features): f = 0.123
69
+ fine-coarse correlation (median): r = 0.998
70
+ verdict: UNINFORMATIVE
71
+
72
+ f = 0.123 -> detection rate 0.08 [0.04, 0.15] at k = 5
73
+ f is below the 0.4 floor: a null here says nothing about the hypothesis.
74
+
75
+ The two predictor sets are close to the same numbers, so a comparison between them
76
+ cannot come out either way. Report this as unanswerable at this design, not as a null.
77
+ ```
78
+
79
+ `report.passes` is the verdict as a boolean. `report.per_feature` and
80
+ `report.correlations` carry the detail behind the two summary numbers, in case
81
+ one predictor is behaving differently from the rest.
82
+
83
+ The pieces are usable on their own:
84
+
85
+ ```python
86
+ from coarsegate import variance_removed, correlation_retained, power_at, upscale
87
+
88
+ variance_removed(df, features, cell_size=25_000, x="x_m", y="y_m", time="year")
89
+ power_at(0.484) # what that fraction buys in detection
90
+ upscale(df, features, cell_size=25_000, x="x_m", y="y_m", time="year")
91
+ ```
92
+
93
+ ## What the floor is, and what it is not
94
+
95
+ `f` is the fraction of between-site predictor variance that block averaging
96
+ destroys. It is the within-cell share of the change-of-support decomposition,
97
+ and geostatistics has computed that quantity from the variogram since long
98
+ before this package existed. That part is not new.
99
+
100
+ What is not available analytically is the probability that a comparison at a
101
+ given `f` returns a verdict at all. That has to be measured, and the shipped
102
+ calibration is the measurement: 800 simulated panels across eight correlation
103
+ lengths, plus a block-count sweep, all under the same spatially-blocked
104
+ cross-validation and paired bootstrap the method is meant to be used with.
105
+
106
+ ```python
107
+ from coarsegate import calibration_table
108
+ calibration_table("f") # the correlation-length sweep, k = 5
109
+ calibration_table("blocks") # the block-count sweep, at the two highest f
110
+ ```
111
+
112
+ Read the floor in one direction only. Below `f` of about 0.4, a null carries no
113
+ information, so the diagnostic disqualifies the comparison. Above it, detection
114
+ depends on the number of validation blocks as much as on `f`, so clearing the
115
+ floor licenses interpretation rather than guaranteeing power. Detection rates
116
+ below the floor drift back up, and those are not detections in any useful sense.
117
+ They are what a bootstrap does with two nearly identical predictor sets, which
118
+ is exactly why the floor is a floor and not a point on a curve.
119
+
120
+ ## Caveats worth reading before you cite a number
121
+
122
+ - The calibration is measured at five validation blocks and 300 sites. A design
123
+ far from that should re-run the simulation rather than read the table.
124
+ - `x`, `y` and `cell_size` must be projected and share units. Averaging degrees
125
+ gives cells whose ground area varies with latitude, which puts a spurious
126
+ north-south gradient into `f`.
127
+ - Pass `time` for a panel. Without it the coarse arm loses the interannual
128
+ signal too, so `f` counts variance the coarsening was never meant to touch and
129
+ comes out too **high**, which is the dangerous direction: it makes an
130
+ unanswerable design look answerable. Repeated coordinates with no `time`
131
+ raise a warning.
132
+ - `f` is summarised as the median over features, so one nearly flat predictor
133
+ cannot drag the verdict.
134
+
135
+ ## Citation
136
+
137
+ The method, the calibration and the two worked extents:
138
+
139
+ > Wang, J. Measure what the coarsening removes: a precondition for interpreting
140
+ > spatial-resolution comparisons in ecological models. EcoEvoRxiv.
141
+ > https://doi.org/10.32942/X2ZH5C
142
+
143
+ That is a preprint. It has not been peer reviewed.
144
+
145
+ ## License
146
+
147
+ MIT.
@@ -0,0 +1,123 @@
1
+ # coarsegate
2
+
3
+ Measure what a coarsening removes, before you interpret a spatial-resolution
4
+ comparison.
5
+
6
+ ## The problem
7
+
8
+ A common design asks whether finer predictors beat coarser ones: take a
9
+ predictor field, block-average it onto a coarse grid, fit the same model to
10
+ both arms, compare. The design looks airtight, because resolution is the only
11
+ thing that varies.
12
+
13
+ It can still fail to test its own hypothesis. Over a small extent, block
14
+ averaging barely changes the predictors. The two arms are fed nearly the same
15
+ numbers, the comparison cannot come out either way, and it returns a null that
16
+ means nothing. Nothing in the output distinguishes that case from a real
17
+ negative result.
18
+
19
+ The condition is measurable from the predictors alone, with no model fitted, so
20
+ it is available while the design can still be changed.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install coarsegate
26
+ ```
27
+
28
+ ## Use
29
+
30
+ ```python
31
+ from coarsegate import precondition_report
32
+
33
+ report = precondition_report(
34
+ df, features=["tmax", "ppt", "cmd"],
35
+ cell_size=4000, # same units as x and y
36
+ x="x_m", y="y_m", # projected coordinates, not degrees
37
+ time="year", # optional; a panel needs it, one slice does not
38
+ )
39
+ print(report)
40
+ ```
41
+
42
+ ```
43
+ coarsening 153 sites to 25 cells of 4000 units
44
+ spatial variance removed (median over 14 features): f = 0.123
45
+ fine-coarse correlation (median): r = 0.998
46
+ verdict: UNINFORMATIVE
47
+
48
+ f = 0.123 -> detection rate 0.08 [0.04, 0.15] at k = 5
49
+ f is below the 0.4 floor: a null here says nothing about the hypothesis.
50
+
51
+ The two predictor sets are close to the same numbers, so a comparison between them
52
+ cannot come out either way. Report this as unanswerable at this design, not as a null.
53
+ ```
54
+
55
+ `report.passes` is the verdict as a boolean. `report.per_feature` and
56
+ `report.correlations` carry the detail behind the two summary numbers, in case
57
+ one predictor is behaving differently from the rest.
58
+
59
+ The pieces are usable on their own:
60
+
61
+ ```python
62
+ from coarsegate import variance_removed, correlation_retained, power_at, upscale
63
+
64
+ variance_removed(df, features, cell_size=25_000, x="x_m", y="y_m", time="year")
65
+ power_at(0.484) # what that fraction buys in detection
66
+ upscale(df, features, cell_size=25_000, x="x_m", y="y_m", time="year")
67
+ ```
68
+
69
+ ## What the floor is, and what it is not
70
+
71
+ `f` is the fraction of between-site predictor variance that block averaging
72
+ destroys. It is the within-cell share of the change-of-support decomposition,
73
+ and geostatistics has computed that quantity from the variogram since long
74
+ before this package existed. That part is not new.
75
+
76
+ What is not available analytically is the probability that a comparison at a
77
+ given `f` returns a verdict at all. That has to be measured, and the shipped
78
+ calibration is the measurement: 800 simulated panels across eight correlation
79
+ lengths, plus a block-count sweep, all under the same spatially-blocked
80
+ cross-validation and paired bootstrap the method is meant to be used with.
81
+
82
+ ```python
83
+ from coarsegate import calibration_table
84
+ calibration_table("f") # the correlation-length sweep, k = 5
85
+ calibration_table("blocks") # the block-count sweep, at the two highest f
86
+ ```
87
+
88
+ Read the floor in one direction only. Below `f` of about 0.4, a null carries no
89
+ information, so the diagnostic disqualifies the comparison. Above it, detection
90
+ depends on the number of validation blocks as much as on `f`, so clearing the
91
+ floor licenses interpretation rather than guaranteeing power. Detection rates
92
+ below the floor drift back up, and those are not detections in any useful sense.
93
+ They are what a bootstrap does with two nearly identical predictor sets, which
94
+ is exactly why the floor is a floor and not a point on a curve.
95
+
96
+ ## Caveats worth reading before you cite a number
97
+
98
+ - The calibration is measured at five validation blocks and 300 sites. A design
99
+ far from that should re-run the simulation rather than read the table.
100
+ - `x`, `y` and `cell_size` must be projected and share units. Averaging degrees
101
+ gives cells whose ground area varies with latitude, which puts a spurious
102
+ north-south gradient into `f`.
103
+ - Pass `time` for a panel. Without it the coarse arm loses the interannual
104
+ signal too, so `f` counts variance the coarsening was never meant to touch and
105
+ comes out too **high**, which is the dangerous direction: it makes an
106
+ unanswerable design look answerable. Repeated coordinates with no `time`
107
+ raise a warning.
108
+ - `f` is summarised as the median over features, so one nearly flat predictor
109
+ cannot drag the verdict.
110
+
111
+ ## Citation
112
+
113
+ The method, the calibration and the two worked extents:
114
+
115
+ > Wang, J. Measure what the coarsening removes: a precondition for interpreting
116
+ > spatial-resolution comparisons in ecological models. EcoEvoRxiv.
117
+ > https://doi.org/10.32942/X2ZH5C
118
+
119
+ That is a preprint. It has not been peer reviewed.
120
+
121
+ ## License
122
+
123
+ MIT.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "coarsegate"
7
+ version = "0.1.0"
8
+ description = "Measure what a coarsening removes, before interpreting a spatial-resolution comparison"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Joshua Wang" }]
13
+ keywords = ["change of support", "spatial resolution", "downscaling",
14
+ "statistical power", "cross-validation", "ecology"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering",
21
+ ]
22
+ dependencies = ["numpy>=1.24", "pandas>=2.0"]
23
+
24
+ [project.optional-dependencies]
25
+ sim = ["scikit-learn>=1.3", "scipy>=1.10"]
26
+ dev = ["pytest>=7", "pyarrow>=14"]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/coarsegate"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
@@ -0,0 +1,26 @@
1
+ """Measure what a coarsening removes, before interpreting a resolution comparison.
2
+
3
+ A comparison between a fine and a coarse version of the same predictor field can
4
+ fail to test its own hypothesis. Over a small extent, block averaging barely
5
+ changes the predictors, the two models are fed nearly the same numbers, and the
6
+ comparison returns a null that means nothing. This package measures that
7
+ condition first, from the predictors alone, with no model fitted.
8
+
9
+ >>> from coarsegate import precondition_report
10
+ >>> print(precondition_report(df, features, cell_size=4000,
11
+ ... x="x_m", y="y_m", time="year"))
12
+
13
+ Method and calibration: Wang, J. Measure what the coarsening removes.
14
+ EcoEvoRxiv, doi:10.32942/X2ZH5C
15
+ """
16
+ from ._upscale import n_cells, upscale
17
+ from .calibration import INFORMATIVE_FLOOR, Power, calibration_table, power_at
18
+ from .diagnostics import correlation_retained, variance_removed
19
+ from .report import Precondition, precondition_report
20
+
21
+ __version__ = "0.1.0"
22
+ __all__ = [
23
+ "INFORMATIVE_FLOOR", "Power", "Precondition", "calibration_table",
24
+ "correlation_retained", "n_cells", "power_at", "precondition_report",
25
+ "upscale", "variance_removed",
26
+ ]
@@ -0,0 +1,75 @@
1
+ """Block-average a predictor field onto a coarse grid."""
2
+ from __future__ import annotations
3
+
4
+ import warnings
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+
10
+ def upscale(
11
+ df: pd.DataFrame,
12
+ features: list[str],
13
+ *,
14
+ cell_size: float,
15
+ x: str = "x",
16
+ y: str = "y",
17
+ time: str | None = None,
18
+ ) -> pd.DataFrame:
19
+ """Replace each site's predictors with its coarse cell's mean.
20
+
21
+ Averaging within (cell, time) rather than within cell alone keeps the
22
+ temporal signal intact, so only the spatial detail is destroyed. That is the
23
+ single thing a resolution comparison is allowed to vary: if the coarse arm
24
+ also loses temporal detail, a difference between the two arms no longer
25
+ isolates resolution.
26
+
27
+ ``x``, ``y`` and ``cell_size`` must share units, and the coordinates must be
28
+ projected. Averaging degrees gives cells whose ground area varies with
29
+ latitude, which puts a spurious north-south gradient into ``f``.
30
+ """
31
+ _require_columns(df, [x, y] + list(features) + ([time] if time else []))
32
+ if cell_size <= 0:
33
+ raise ValueError(f"cell_size must be positive, got {cell_size}")
34
+
35
+ out = df.copy()
36
+ cell = (np.floor(out[x] / cell_size).astype("int64").astype(str) + "_"
37
+ + np.floor(out[y] / cell_size).astype("int64").astype(str))
38
+ by = [cell] if time is None else [cell, out[time]]
39
+ out[features] = out.groupby(by, sort=False)[features].transform("mean")
40
+ return out
41
+
42
+
43
+ def n_cells(df: pd.DataFrame, *, cell_size: float, x: str = "x", y: str = "y") -> int:
44
+ """How many occupied coarse cells the sites fall into."""
45
+ _require_columns(df, [x, y])
46
+ cell = (np.floor(df[x] / cell_size).astype("int64").astype(str) + "_"
47
+ + np.floor(df[y] / cell_size).astype("int64").astype(str))
48
+ return int(cell.nunique())
49
+
50
+
51
+ def _require_columns(df: pd.DataFrame, cols: list[str]) -> None:
52
+ missing = [c for c in cols if c not in df.columns]
53
+ if missing:
54
+ raise KeyError(f"columns not in the frame: {missing}")
55
+
56
+
57
+ def _warn_if_panel_without_time(df: pd.DataFrame, *, x: str, y: str,
58
+ time: str | None) -> None:
59
+ """A panel measured without a time column inflates f. Say so, loudly.
60
+
61
+ Repeated coordinates mean several observations per site. Averaging across
62
+ them destroys the temporal signal along with the spatial detail, so the
63
+ fraction removed counts variance the coarsening was never supposed to touch.
64
+ """
65
+ if time is not None:
66
+ return
67
+ n_sites = len(df.drop_duplicates([x, y]))
68
+ if n_sites < len(df):
69
+ warnings.warn(
70
+ f"{len(df)} rows over {n_sites} distinct coordinates but no `time` "
71
+ "column was given. On a panel this counts temporal variance as "
72
+ "spatial and overstates the fraction removed. Pass time= to measure "
73
+ "the spatial part alone.",
74
+ UserWarning, stacklevel=3,
75
+ )
@@ -0,0 +1,107 @@
1
+ """What a measured f buys you in detection power.
2
+
3
+ The tables shipped here are simulation output, not theory. The change-of-support
4
+ literature gives the variance decomposition analytically; what it does not give
5
+ is the probability that a resolution comparison at a given f returns a verdict.
6
+ That has to be measured, and these are the measurements.
7
+
8
+ Both tables come from Gaussian random fields sampled at 300 sites over four
9
+ time slices, upscaled to 25 km cells, with the two arms compared under
10
+ spatially-blocked cross-validation and a paired percentile bootstrap. A
11
+ "detection" is the paper's own signed criterion: the paired interval excludes
12
+ zero *in the direction of the fine arm*. Detecting the wrong direction is not a
13
+ detection.
14
+
15
+ Two limits worth stating before anyone reads a number off these:
16
+
17
+ * The f table is calibrated at five validation blocks and 300 sites. It is not a
18
+ general power curve, and a design far from that one should re-run the
19
+ simulation rather than read this table.
20
+ * Below f of about 0.4 the detection rate stops falling and drifts back up. Those
21
+ are not detections in any useful sense, they are what a bootstrap does with two
22
+ nearly identical predictor sets. This is why the floor is a floor rather than
23
+ a point on a curve, and why ``power_at`` reports ``informative`` separately
24
+ from the rate.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass, asdict
29
+ from functools import lru_cache
30
+ from importlib.resources import files
31
+
32
+ import numpy as np
33
+ import pandas as pd
34
+
35
+ #: Below this fraction a null result carries no information about the hypothesis.
36
+ INFORMATIVE_FLOOR = 0.4
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Power:
41
+ """An interpolated detection rate, with everything needed to distrust it."""
42
+
43
+ f: float
44
+ detection_rate: float
45
+ ci_low: float
46
+ ci_high: float
47
+ informative: bool
48
+ extrapolated: bool
49
+ n_blocks: int
50
+ basis: str
51
+
52
+ def __str__(self) -> str:
53
+ head = (f"f = {self.f:.3f} -> detection rate {self.detection_rate:.2f} "
54
+ f"[{self.ci_low:.2f}, {self.ci_high:.2f}] at k = {self.n_blocks}")
55
+ if not self.informative:
56
+ head += (f"\n f is below the {INFORMATIVE_FLOOR:g} floor: a null here "
57
+ "says nothing about the hypothesis.")
58
+ if self.extrapolated:
59
+ head += "\n f is outside the calibrated range; the rate is clamped."
60
+ return head
61
+
62
+ def as_dict(self) -> dict:
63
+ return asdict(self)
64
+
65
+
66
+ @lru_cache(maxsize=None)
67
+ def calibration_table(axis: str = "f") -> pd.DataFrame:
68
+ """The raw simulation output behind ``power_at``.
69
+
70
+ ``axis="f"`` is the correlation-length sweep at five blocks; ``axis="blocks"``
71
+ is the block-count sweep at the two highest-f levels. Read them directly
72
+ rather than trusting an interpolation when your design is unusual.
73
+ """
74
+ names = {"f": "calibration_f.csv", "blocks": "calibration_blocks.csv"}
75
+ if axis not in names:
76
+ raise ValueError(f"axis must be one of {sorted(names)}, got {axis!r}")
77
+ with files("coarsegate.data").joinpath(names[axis]).open() as fh:
78
+ return pd.read_csv(fh)
79
+
80
+
81
+ def power_at(f: float, *, n_blocks: int = 5) -> Power:
82
+ """Detection rate at a measured variance-removed fraction.
83
+
84
+ Linear interpolation over the shipped sweep. ``n_blocks`` is carried into
85
+ the result and checked against the block-count table, but it does not move
86
+ the rate: the sweep resolves f at k = 5, and the k axis was measured at only
87
+ two f levels, which is not enough to interpolate a surface from.
88
+ """
89
+ if not np.isfinite(f):
90
+ raise ValueError("f must be finite")
91
+ tab = calibration_table("f").sort_values("f_mean")
92
+ lo, hi = float(tab["f_mean"].iloc[0]), float(tab["f_mean"].iloc[-1])
93
+ extrapolated = not (lo <= f <= hi)
94
+ fc = float(np.clip(f, lo, hi))
95
+
96
+ rate, ci_lo, ci_hi = (
97
+ float(np.interp(fc, tab["f_mean"], tab[c]))
98
+ for c in ("detection_rate", "det_lo", "det_hi")
99
+ )
100
+ basis = (f"{int(tab['n'].iloc[0])} simulated panels per level, "
101
+ f"{len(tab)} levels, k = 5 blocks, n = 300 sites")
102
+ if n_blocks != 5:
103
+ basis += (f"; requested k = {n_blocks}, which the f sweep does not "
104
+ "resolve (see calibration_table('blocks'))")
105
+ return Power(f=float(f), detection_rate=rate, ci_low=ci_lo, ci_high=ci_hi,
106
+ informative=f >= INFORMATIVE_FLOOR, extrapolated=extrapolated,
107
+ n_blocks=n_blocks, basis=basis)
@@ -0,0 +1,7 @@
1
+ range_m,n_blocks,n_sites,f_mean,f_sd,detection_rate,n,det_lo,det_hi
2
+ 3500,5,300,0.810033,0.0239791,0.68,50,0.541894,0.79242
3
+ 3500,10,300,0.810033,0.0239791,0.78,50,0.647582,0.872462
4
+ 3500,20,300,0.810033,0.0239791,0.84,50,0.714855,0.916627
5
+ 5000,5,300,0.70606,0.0359356,0.58,50,0.442332,0.706252
6
+ 5000,10,300,0.70606,0.0359356,0.56,50,0.423058,0.68838
7
+ 5000,20,300,0.70606,0.0359356,0.6,50,0.461812,0.723918
@@ -0,0 +1,9 @@
1
+ range_m,n_blocks,n_sites,f_mean,f_sd,detection_rate,det_lo,det_hi,n
2
+ 40000,5,300,0.0595642,0.00756533,0.19,0.125147,0.27779,100
3
+ 28000,5,300,0.0860021,0.0148084,0.1,0.0552285,0.174367,100
4
+ 20000,5,300,0.135776,0.0234062,0.07,0.0343188,0.137497,100
5
+ 14000,5,300,0.23796,0.0370467,0.11,0.0625413,0.186315,100
6
+ 10000,5,300,0.388117,0.0505486,0.2,0.133366,0.288831,100
7
+ 7000,5,300,0.561183,0.0518677,0.28,0.201396,0.374882,100
8
+ 5000,5,300,0.700381,0.0409765,0.54,0.442647,0.634394,100
9
+ 3500,5,300,0.805313,0.0289198,0.63,0.532203,0.718178,100
@@ -0,0 +1,86 @@
1
+ """How much spatial variance a coarsening actually removes."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from ._upscale import upscale, _require_columns, _warn_if_panel_without_time
8
+
9
+
10
+ def variance_removed(
11
+ df: pd.DataFrame,
12
+ features: list[str],
13
+ *,
14
+ cell_size: float,
15
+ x: str = "x",
16
+ y: str = "y",
17
+ time: str | None = None,
18
+ ) -> pd.DataFrame:
19
+ """Per feature, the fraction of spatial variance the coarsening destroys.
20
+
21
+ Returns one row per feature with the spatial variance before and after
22
+ block averaging and their ratio, sorted by the fraction removed.
23
+
24
+ The reference point is the mean within each time slice, not the grand mean,
25
+ so what is measured is variance *between sites* and interannual variation
26
+ never enters it. With ``time=None`` the grand mean is used instead, which is
27
+ correct for a single-slice frame and wrong for a panel: on a panel it counts
28
+ temporal variance as spatial and *inflates* the fraction removed, which is
29
+ the dangerous direction, since it makes an unanswerable comparison look
30
+ answerable. Repeated coordinates with no ``time`` therefore raise a warning.
31
+
32
+ A fraction near zero means the two predictor sets are nearly the same
33
+ numbers. A comparison between them cannot come out either way, so its result
34
+ is uninformative rather than negative, and that is worth being able to read
35
+ off the data rather than argue about afterwards.
36
+ """
37
+ features = list(features)
38
+ if not features:
39
+ raise ValueError("no features given")
40
+ _require_columns(df, features)
41
+
42
+ _warn_if_panel_without_time(df, x=x, y=y, time=time)
43
+ coarse = upscale(df, features, cell_size=cell_size, x=x, y=y, time=time)
44
+ rows = []
45
+ for f in features:
46
+ ref = (df[f].mean() if time is None
47
+ else df.groupby(time)[f].transform("mean"))
48
+ var_fine = float(((df[f] - ref) ** 2).mean())
49
+ var_coarse = float(((coarse[f] - ref) ** 2).mean())
50
+ rows.append({
51
+ "feature": f,
52
+ "spatial_var_fine": var_fine,
53
+ "spatial_var_coarse": var_coarse,
54
+ "var_removed_frac": 1 - var_coarse / var_fine if var_fine > 0 else np.nan,
55
+ })
56
+ return (pd.DataFrame(rows)
57
+ .sort_values("var_removed_frac", ascending=False)
58
+ .reset_index(drop=True))
59
+
60
+
61
+ def correlation_retained(
62
+ df: pd.DataFrame,
63
+ features: list[str],
64
+ *,
65
+ cell_size: float,
66
+ x: str = "x",
67
+ y: str = "y",
68
+ time: str | None = None,
69
+ ) -> pd.DataFrame:
70
+ """Per feature, the correlation between the fine and coarse arms.
71
+
72
+ The companion to ``variance_removed`` and the more legible number of the
73
+ two: a fraction removed of 0.12 is abstract, whereas r = 0.998 between the
74
+ two arms says plainly that the models are being fed the same predictor.
75
+ """
76
+ features = list(features)
77
+ _require_columns(df, features)
78
+ coarse = upscale(df, features, cell_size=cell_size, x=x, y=y, time=time)
79
+ rows = []
80
+ for f in features:
81
+ a, b = df[f].to_numpy(float), coarse[f].to_numpy(float)
82
+ ok = np.isfinite(a) & np.isfinite(b)
83
+ r = (np.corrcoef(a[ok], b[ok])[0, 1]
84
+ if ok.sum() > 1 and a[ok].std() > 0 and b[ok].std() > 0 else np.nan)
85
+ rows.append({"feature": f, "r_fine_coarse": float(r)})
86
+ return pd.DataFrame(rows).sort_values("r_fine_coarse").reset_index(drop=True)
@@ -0,0 +1,88 @@
1
+ """The precondition, run end to end and stated as a verdict."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ import pandas as pd
7
+
8
+ from ._upscale import n_cells
9
+ from .calibration import INFORMATIVE_FLOOR, Power, power_at
10
+ from .diagnostics import correlation_retained, variance_removed
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Precondition:
15
+ """Whether a resolution comparison at this design can answer its question."""
16
+
17
+ f: float
18
+ verdict: str
19
+ power: Power
20
+ n_cells: int
21
+ n_sites: int
22
+ cell_size: float
23
+ per_feature: pd.DataFrame
24
+ correlations: pd.DataFrame
25
+
26
+ @property
27
+ def passes(self) -> bool:
28
+ return self.verdict == "informative"
29
+
30
+ def __str__(self) -> str:
31
+ med_r = float(self.correlations["r_fine_coarse"].median())
32
+ lines = [
33
+ f"coarsening {self.n_sites} sites to {self.n_cells} cells "
34
+ f"of {self.cell_size:g} units",
35
+ f" spatial variance removed (median over "
36
+ f"{len(self.per_feature)} features): f = {self.f:.3f}",
37
+ f" fine-coarse correlation (median): r = {med_r:.3f}",
38
+ f" verdict: {self.verdict.upper()}",
39
+ "",
40
+ str(self.power),
41
+ ]
42
+ if not self.passes:
43
+ lines += [
44
+ "",
45
+ "The two predictor sets are close to the same numbers, so a "
46
+ "comparison between them",
47
+ "cannot come out either way. Report this as unanswerable at this "
48
+ "design, not as a null.",
49
+ ]
50
+ return "\n".join(lines)
51
+
52
+
53
+ def precondition_report(
54
+ df: pd.DataFrame,
55
+ features: list[str],
56
+ *,
57
+ cell_size: float,
58
+ x: str = "x",
59
+ y: str = "y",
60
+ time: str | None = None,
61
+ n_blocks: int = 5,
62
+ ) -> Precondition:
63
+ """Measure f, look up what it buys, and say whether the comparison is worth
64
+ interpreting.
65
+
66
+ Run this before fitting either model. The whole point of the diagnostic is
67
+ that it needs no model: it is a property of the predictor field and the cell
68
+ size, so it is available while the design can still be changed.
69
+
70
+ ``f`` is summarised as the **median** over features rather than the mean, so
71
+ one predictor that happens to be nearly flat in space cannot drag the
72
+ verdict.
73
+ """
74
+ per_feature = variance_removed(df, features, cell_size=cell_size,
75
+ x=x, y=y, time=time)
76
+ corr = correlation_retained(df, features, cell_size=cell_size,
77
+ x=x, y=y, time=time)
78
+ f = float(per_feature["var_removed_frac"].median())
79
+ return Precondition(
80
+ f=f,
81
+ verdict="informative" if f >= INFORMATIVE_FLOOR else "uninformative",
82
+ power=power_at(f, n_blocks=n_blocks),
83
+ n_cells=n_cells(df, cell_size=cell_size, x=x, y=y),
84
+ n_sites=int(len(df.drop_duplicates([x, y]))),
85
+ cell_size=float(cell_size),
86
+ per_feature=per_feature,
87
+ correlations=corr,
88
+ )
@@ -0,0 +1,70 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from coarsegate import INFORMATIVE_FLOOR, calibration_table, power_at
5
+
6
+
7
+ def test_both_tables_ship_and_carry_their_provenance():
8
+ f = calibration_table("f")
9
+ assert len(f) == 8 and (f["n"] == 100).all() and (f["n_blocks"] == 5).all()
10
+ b = calibration_table("blocks")
11
+ assert set(b["n_blocks"]) == {5, 10, 20}
12
+
13
+
14
+ def test_unknown_axis_is_refused():
15
+ with pytest.raises(ValueError):
16
+ calibration_table("elevation")
17
+
18
+
19
+ def test_detection_rises_with_f_across_the_informative_range():
20
+ f = calibration_table("f").sort_values("f_mean")
21
+ high = f[f["f_mean"] >= INFORMATIVE_FLOOR]["detection_rate"].tolist()
22
+ assert all(b > a for a, b in zip(high, high[1:]))
23
+
24
+
25
+ def test_the_low_f_end_is_not_monotone_which_is_why_there_is_a_floor():
26
+ """A rate at low f is bootstrap noise, not power. If this ever becomes
27
+ monotone the floor's justification has changed and the docs must follow."""
28
+ f = calibration_table("f").sort_values("f_mean")
29
+ low = f[f["f_mean"] < INFORMATIVE_FLOOR]["detection_rate"].tolist()
30
+ assert not all(b > a for a, b in zip(low, low[1:]))
31
+
32
+
33
+ def test_power_at_interpolates_between_measured_levels():
34
+ f = calibration_table("f").sort_values("f_mean")
35
+ lo, hi = f.iloc[-2], f.iloc[-1]
36
+ mid = power_at((lo["f_mean"] + hi["f_mean"]) / 2)
37
+ assert lo["detection_rate"] < mid.detection_rate < hi["detection_rate"]
38
+ assert not mid.extrapolated and mid.informative
39
+
40
+
41
+ def test_power_at_reproduces_a_measured_level_exactly():
42
+ row = calibration_table("f").iloc[0]
43
+ p = power_at(float(row["f_mean"]))
44
+ assert p.detection_rate == pytest.approx(row["detection_rate"])
45
+
46
+
47
+ def test_the_two_extents_from_the_paper_land_either_side_of_the_floor():
48
+ surrey, transect = power_at(0.123), power_at(0.484)
49
+ assert not surrey.informative
50
+ assert transect.informative
51
+ assert "floor" in str(surrey) and "floor" not in str(transect)
52
+
53
+
54
+ @pytest.mark.parametrize("f", [0.0, 1.0])
55
+ def test_outside_the_calibrated_range_the_rate_is_clamped_and_says_so(f):
56
+ p = power_at(f)
57
+ assert p.extrapolated and "clamped" in str(p)
58
+ assert 0.0 <= p.detection_rate <= 1.0
59
+
60
+
61
+ def test_a_block_count_the_sweep_cannot_resolve_is_declared_not_silently_used():
62
+ p = power_at(0.7, n_blocks=20)
63
+ assert p.n_blocks == 20
64
+ assert "does not" in p.basis
65
+ assert p.detection_rate == power_at(0.7).detection_rate
66
+
67
+
68
+ def test_non_finite_f_is_refused():
69
+ with pytest.raises(ValueError):
70
+ power_at(np.nan)
@@ -0,0 +1,107 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import pytest
4
+
5
+ from coarsegate import (correlation_retained, n_cells, upscale,
6
+ variance_removed)
7
+
8
+
9
+ @pytest.fixture
10
+ def panel():
11
+ """Nine sites on a 3x3 grid at 1 km spacing, three time slices."""
12
+ rng = np.random.default_rng(0)
13
+ xs, ys = np.meshgrid(np.arange(3) * 1000.0, np.arange(3) * 1000.0)
14
+ rows = []
15
+ for t in (2020, 2021, 2022):
16
+ rows.append(pd.DataFrame({
17
+ "x": xs.ravel(), "y": ys.ravel(), "year": t,
18
+ "a": rng.normal(size=9) + t, # a different mean per year
19
+ "b": rng.normal(size=9) * 5,
20
+ }))
21
+ return pd.concat(rows, ignore_index=True)
22
+
23
+
24
+ def test_upscale_preserves_the_mean_within_each_time_slice(panel):
25
+ coarse = upscale(panel, ["a", "b"], cell_size=5000, x="x", y="y", time="year")
26
+ for col in ("a", "b"):
27
+ pd.testing.assert_series_equal(
28
+ panel.groupby("year")[col].mean(),
29
+ coarse.groupby("year")[col].mean(),
30
+ )
31
+
32
+
33
+ def test_upscale_leaves_other_columns_alone(panel):
34
+ coarse = upscale(panel, ["a"], cell_size=5000, x="x", y="y", time="year")
35
+ pd.testing.assert_series_equal(panel["b"], coarse["b"])
36
+ assert "_cell" not in coarse.columns
37
+
38
+
39
+ def test_nothing_removed_when_every_site_owns_its_cell(panel):
40
+ v = variance_removed(panel, ["a", "b"], cell_size=500,
41
+ x="x", y="y", time="year")
42
+ assert n_cells(panel, cell_size=500) == 9
43
+ assert v["var_removed_frac"].abs().max() < 1e-12
44
+
45
+
46
+ def test_everything_removed_when_one_cell_holds_them_all(panel):
47
+ v = variance_removed(panel, ["a", "b"], cell_size=1e6,
48
+ x="x", y="y", time="year")
49
+ assert n_cells(panel, cell_size=1e6) == 1
50
+ assert np.allclose(v["var_removed_frac"], 1.0)
51
+
52
+
53
+ def test_f_rises_with_cell_size(panel):
54
+ fs = [variance_removed(panel, ["a", "b"], cell_size=c,
55
+ x="x", y="y", time="year")["var_removed_frac"].median()
56
+ for c in (500, 1500, 1e6)]
57
+ assert fs[0] < fs[1] < fs[2]
58
+
59
+
60
+ def test_dropping_time_on_a_panel_inflates_f_in_the_dangerous_direction(panel):
61
+ """Without `time`, the coarse arm loses the interannual signal too, so the
62
+ fraction removed counts variance the coarsening should never have touched.
63
+ It comes out HIGHER, which makes an unanswerable design look answerable."""
64
+ with_time = variance_removed(panel, ["a"], cell_size=1500,
65
+ x="x", y="y", time="year")
66
+ with pytest.warns(UserWarning, match="overstates"):
67
+ without = variance_removed(panel, ["a"], cell_size=1500, x="x", y="y")
68
+ assert without["spatial_var_fine"][0] > with_time["spatial_var_fine"][0]
69
+ assert without["var_removed_frac"][0] > with_time["var_removed_frac"][0]
70
+
71
+
72
+ def test_a_single_time_slice_needs_no_time_column_and_warns_about_nothing(panel):
73
+ import warnings
74
+ one = panel[panel["year"] == 2020]
75
+ with warnings.catch_warnings():
76
+ warnings.simplefilter("error")
77
+ v = variance_removed(one, ["a"], cell_size=1500, x="x", y="y")
78
+ assert 0.0 <= v["var_removed_frac"][0] <= 1.0
79
+
80
+
81
+ def test_correlation_is_one_when_nothing_is_removed(panel):
82
+ c = correlation_retained(panel, ["a", "b"], cell_size=500,
83
+ x="x", y="y", time="year")
84
+ assert np.allclose(c["r_fine_coarse"], 1.0)
85
+
86
+
87
+ def test_constant_feature_gives_nan_not_a_divide_by_zero(panel):
88
+ panel = panel.assign(flat=1.0)
89
+ v = variance_removed(panel, ["flat"], cell_size=1500,
90
+ x="x", y="y", time="year")
91
+ assert np.isnan(v["var_removed_frac"][0])
92
+
93
+
94
+ @pytest.mark.parametrize("kwargs, exc", [
95
+ (dict(features=["a"], cell_size=0), ValueError),
96
+ (dict(features=["a"], cell_size=-1), ValueError),
97
+ (dict(features=[], cell_size=1000), ValueError),
98
+ (dict(features=["nope"], cell_size=1000), KeyError),
99
+ ])
100
+ def test_bad_input_is_refused(panel, kwargs, exc):
101
+ with pytest.raises(exc):
102
+ variance_removed(panel, x="x", y="y", time="year", **kwargs)
103
+
104
+
105
+ def test_missing_coordinate_column_is_refused(panel):
106
+ with pytest.raises(KeyError):
107
+ variance_removed(panel, ["a"], cell_size=1000, x="easting", y="y")
@@ -0,0 +1,47 @@
1
+ """Reproduce the two fractions the preprint reports, from the panels themselves.
2
+
3
+ The panels are not redistributed with this package, so these skip unless
4
+ ``COARSEGATE_PANELS`` points at a directory holding ``features.parquet`` and
5
+ ``features_phase3b.parquet``. They exist so that a change to the generalised
6
+ API cannot silently move a published number: the values below are what
7
+ ``src.pipeline.experiment.upscaling_diagnostics`` returned when the preprint
8
+ was written.
9
+ """
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+ import pytest
15
+
16
+ from coarsegate import precondition_report
17
+
18
+ PANELS = Path(os.environ["COARSEGATE_PANELS"]) if os.environ.get("COARSEGATE_PANELS") else None
19
+
20
+ pytestmark = pytest.mark.skipif(
21
+ PANELS is None or not PANELS.is_dir(),
22
+ reason="set COARSEGATE_PANELS to the directory holding the analysis panels",
23
+ )
24
+
25
+ CLIMATE = ["CMD_sm", "CMD_sm_anom", "DD18_sm", "Eref_sm", "Eref_sm_anom",
26
+ "PPT_sm", "PPT_sm_anom", "Rad_sm", "Tmax_sm", "Tmax_sm_anom",
27
+ "Tmin_sm", "Tmin_sm_anom", "logPPT_sm", "novelty"]
28
+
29
+
30
+ @pytest.mark.parametrize("panel, cell_size, published, informative", [
31
+ ("features.parquet", 4_000, 0.123, False), # Surrey, 12.3%
32
+ ("features_phase3b.parquet", 25_000, 0.484, True), # transect, 48.4%
33
+ ])
34
+ def test_the_published_fraction_reproduces(panel, cell_size, published, informative):
35
+ df = pd.read_parquet(PANELS / panel).reset_index(drop=True)
36
+ r = precondition_report(df, CLIMATE, cell_size=cell_size,
37
+ x="x_m", y="y_m", time="year")
38
+ assert round(r.f, 3) == published
39
+ assert r.passes is informative
40
+
41
+
42
+ def test_surrey_leaves_the_two_arms_all_but_identical():
43
+ """The 0.998 median correlation the abstract reports."""
44
+ df = pd.read_parquet(PANELS / "features.parquet").reset_index(drop=True)
45
+ r = precondition_report(df, CLIMATE, cell_size=4_000,
46
+ x="x_m", y="y_m", time="year")
47
+ assert round(float(r.correlations["r_fine_coarse"].median()), 3) == 0.998
@@ -0,0 +1,38 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from coarsegate import precondition_report
5
+
6
+
7
+ def _panel(spacing, n=6, seed=0):
8
+ rng = np.random.default_rng(seed)
9
+ xs, ys = np.meshgrid(np.arange(n) * spacing, np.arange(n) * spacing)
10
+ return pd.concat([
11
+ pd.DataFrame({"x": xs.ravel(), "y": ys.ravel(), "year": t,
12
+ "p": rng.normal(size=n * n), "q": rng.normal(size=n * n)})
13
+ for t in (2020, 2021)
14
+ ], ignore_index=True)
15
+
16
+
17
+ def test_a_coarsening_that_changes_nothing_is_reported_as_unanswerable():
18
+ r = precondition_report(_panel(10_000), ["p", "q"], cell_size=1000,
19
+ x="x", y="y", time="year")
20
+ assert r.f < 0.4 and not r.passes and r.verdict == "uninformative"
21
+ assert "unanswerable" in str(r)
22
+ assert r.n_cells == r.n_sites == 36
23
+
24
+
25
+ def test_a_coarsening_that_destroys_the_field_passes():
26
+ r = precondition_report(_panel(1000), ["p", "q"], cell_size=100_000,
27
+ x="x", y="y", time="year")
28
+ assert r.f > 0.4 and r.passes
29
+ assert "unanswerable" not in str(r)
30
+ assert r.n_cells == 1
31
+
32
+
33
+ def test_the_report_carries_the_per_feature_detail_not_just_the_summary():
34
+ r = precondition_report(_panel(1000), ["p", "q"], cell_size=2000,
35
+ x="x", y="y", time="year")
36
+ assert list(r.per_feature["feature"]) and len(r.per_feature) == 2
37
+ assert r.f == r.per_feature["var_removed_frac"].median()
38
+ assert len(r.correlations) == 2