equipop 1.1.0__py3-none-any.whl
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.
- equipop/__init__.py +31 -0
- equipop/analysis.py +369 -0
- equipop/area.py +121 -0
- equipop/cells.py +139 -0
- equipop/decay.py +88 -0
- equipop/fastcounts.py +96 -0
- equipop/fetch.py +64 -0
- equipop/friction.py +265 -0
- equipop/hex.py +109 -0
- equipop/io.py +164 -0
- equipop/meta.py +186 -0
- equipop/projection.py +168 -0
- equipop/raster.py +106 -0
- equipop/segregation.py +103 -0
- equipop/stata_bridge.py +99 -0
- equipop/stats.py +86 -0
- equipop/transform.py +115 -0
- equipop/viz.py +113 -0
- equipop-1.1.0.dist-info/METADATA +89 -0
- equipop-1.1.0.dist-info/RECORD +23 -0
- equipop-1.1.0.dist-info/WHEEL +5 -0
- equipop-1.1.0.dist-info/licenses/LICENSE +21 -0
- equipop-1.1.0.dist-info/top_level.txt +1 -0
equipop/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EquiPop Pangea - k-nearest neighbour contextual analysis on gridded data.
|
|
3
|
+
|
|
4
|
+
Phase 1, Step 1: projection, grid snapping, and the basic radial k-NN engine
|
|
5
|
+
(no friction, no decay yet - those come in Phase 2).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .transform import project_to_metric, snap_to_grid
|
|
9
|
+
from .analysis import run_knn
|
|
10
|
+
from .decay import Decay
|
|
11
|
+
from .analysis import run_knn_stats
|
|
12
|
+
from .cells import build_cells, CellData
|
|
13
|
+
from .friction import run_knn_friction, load_friction_table
|
|
14
|
+
from .projection import suggest_projection, assign_zones
|
|
15
|
+
from .io import read_table, save_output
|
|
16
|
+
from .fetch import fetch
|
|
17
|
+
from .hex import build_hex_cells
|
|
18
|
+
from .meta import RunLog, load_meta
|
|
19
|
+
from .io import list_layers
|
|
20
|
+
from .fastcounts import run_knn_counts
|
|
21
|
+
from .segregation import seg_profile
|
|
22
|
+
from .area import aggregate_output
|
|
23
|
+
try:
|
|
24
|
+
from .viz import map_output
|
|
25
|
+
except ImportError: # matplotlib is an optional extra
|
|
26
|
+
def map_output(*a, **k):
|
|
27
|
+
raise ImportError("map_output needs matplotlib: "
|
|
28
|
+
"pip install equipop[viz]")
|
|
29
|
+
|
|
30
|
+
__version__ = "1.1.0"
|
|
31
|
+
__all__ = ["project_to_metric", "snap_to_grid", "run_knn", "Decay", "run_knn_stats", "build_cells", "CellData", "run_knn_friction", "load_friction_table", "suggest_projection", "assign_zones", "read_table", "save_output", "fetch", "build_hex_cells", "RunLog", "load_meta", "list_layers", "run_knn_counts", "seg_profile", "aggregate_output", "map_output"]
|
equipop/analysis.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
analysis.py - the radial k-NN engine (Phase 1 + decay).
|
|
3
|
+
|
|
4
|
+
Core idea (from the EquiPop papers): on a uniform grid, the relative
|
|
5
|
+
distances from ANY origin cell to its surrounding cells are always the
|
|
6
|
+
same. So we compute ONE list of cell-offsets sorted by distance, and
|
|
7
|
+
reuse it for every origin. Cells at identical distance form a "ring".
|
|
8
|
+
|
|
9
|
+
Decay: when a Decay object is passed, every neighbour's contribution
|
|
10
|
+
is ALSO accumulated multiplied by weight(distance). The k-thresholds
|
|
11
|
+
are still defined by the RAW (unweighted) counts - the decayed values
|
|
12
|
+
are simply recorded at the same moment, exactly as in the original
|
|
13
|
+
EquiPop ("decayed variables use the same k-values as the non-decaying
|
|
14
|
+
variables"). Decayed counts are therefore always <= raw counts.
|
|
15
|
+
|
|
16
|
+
Output naming - two schemes, chosen with naming="short" | "legacy":
|
|
17
|
+
|
|
18
|
+
short (default) legacy (original EquiPop)
|
|
19
|
+
--------------- -------------------------------
|
|
20
|
+
N_50 IntervalSumCountAll_50
|
|
21
|
+
T_50 IntervalSumCountGroup_50
|
|
22
|
+
R_50 IntervalRatio_50
|
|
23
|
+
Dist_50 IntervalDistance_50
|
|
24
|
+
ND_50 IntervalSumCountAllDecay_50
|
|
25
|
+
TD_50 IntervalSumCountGroupDecay_50
|
|
26
|
+
RD_50 IntervalRatioDecay_50
|
|
27
|
+
|
|
28
|
+
(N = count of all, T = treatment, R = ratio, D = decayed.)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
from itertools import groupby
|
|
35
|
+
|
|
36
|
+
from .decay import Decay
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------- naming
|
|
40
|
+
NAMES = {
|
|
41
|
+
"short": {
|
|
42
|
+
"N": "N_{k}", "T": "T_{k}", "R": "R_{k}", "Dist": "Dist_{k}",
|
|
43
|
+
"ND": "ND_{k}", "TD": "TD_{k}", "RD": "RD_{k}",
|
|
44
|
+
},
|
|
45
|
+
"legacy": {
|
|
46
|
+
"N": "IntervalSumCountAll_{k}",
|
|
47
|
+
"T": "IntervalSumCountGroup_{k}",
|
|
48
|
+
"R": "IntervalRatio_{k}",
|
|
49
|
+
"Dist": "IntervalDistance_{k}",
|
|
50
|
+
"ND": "IntervalSumCountAllDecay_{k}",
|
|
51
|
+
"TD": "IntervalSumCountGroupDecay_{k}",
|
|
52
|
+
"RD": "IntervalRatioDecay_{k}",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_distance_rings(max_radius_units: int):
|
|
58
|
+
"""
|
|
59
|
+
Pre-compute all cell offsets (dx, dy) within max_radius_units,
|
|
60
|
+
grouped into 'rings' of identical distance, sorted by distance.
|
|
61
|
+
The origin (0, 0) is excluded - it is counted first, separately.
|
|
62
|
+
|
|
63
|
+
Returns a list of (distance_in_units, [(dx, dy), ...]).
|
|
64
|
+
"""
|
|
65
|
+
offsets = []
|
|
66
|
+
for dx in range(-max_radius_units, max_radius_units + 1):
|
|
67
|
+
for dy in range(-max_radius_units, max_radius_units + 1):
|
|
68
|
+
if dx == 0 and dy == 0:
|
|
69
|
+
continue
|
|
70
|
+
d = math.hypot(dx, dy)
|
|
71
|
+
if d <= max_radius_units:
|
|
72
|
+
offsets.append((d, dx, dy))
|
|
73
|
+
offsets.sort(key=lambda t: t[0])
|
|
74
|
+
return [(dist, [(dx, dy) for _, dx, dy in grp])
|
|
75
|
+
for dist, grp in groupby(offsets, key=lambda t: t[0])]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_knn(
|
|
79
|
+
cells: pd.DataFrame,
|
|
80
|
+
k_values: list[int],
|
|
81
|
+
count_all_col: str = "FullPop",
|
|
82
|
+
count_group_col: str = "Treatment",
|
|
83
|
+
unit_size: float = 100.0,
|
|
84
|
+
max_radius_units: int = 500,
|
|
85
|
+
id_col: str | None = "id",
|
|
86
|
+
tie_mode: str = "ring",
|
|
87
|
+
decay: Decay | None = None,
|
|
88
|
+
naming: str = "short",
|
|
89
|
+
seed: int | None = None,
|
|
90
|
+
) -> pd.DataFrame:
|
|
91
|
+
"""
|
|
92
|
+
Radial k-NN analysis for every populated cell.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
cells : DataFrame with one row per grid cell ('E_grid', 'N_grid',
|
|
97
|
+
a total-count column, a treatment-count column).
|
|
98
|
+
k_values : the k thresholds, e.g. [50, 100, 200, 400, 800].
|
|
99
|
+
unit_size : grid size in metres.
|
|
100
|
+
max_radius_units : search limit in grid units; unreached ks get
|
|
101
|
+
partial results (mirrors original EquiPop behaviour).
|
|
102
|
+
tie_mode : "ring" (default) adds all equidistant cells before
|
|
103
|
+
checking thresholds; "sequential" checks after every
|
|
104
|
+
single cell (original EquiPop, order-dependent).
|
|
105
|
+
decay : a Decay object, e.g. Decay(half_life_m=8000), or None.
|
|
106
|
+
naming : "short" (N_50, T_50, R_50, ...) or
|
|
107
|
+
"legacy" (IntervalSumCountAll_50, ...).
|
|
108
|
+
seed : only used with tie_mode="sequential": shuffles the (otherwise
|
|
109
|
+
arbitrary) within-ring visiting order reproducibly. Record it
|
|
110
|
+
in the run's metadata log. None keeps the construction order.
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
One row per origin cell. Fixed columns: Id, EastWest, NorthSouth,
|
|
115
|
+
CountAllLocal, CountGroupLocal, SumCountAll, SumCountGroup, Ratio,
|
|
116
|
+
MaxDistance. Per-k columns as per the chosen naming scheme.
|
|
117
|
+
"""
|
|
118
|
+
k_values = sorted(k_values)
|
|
119
|
+
nm = NAMES[naming]
|
|
120
|
+
|
|
121
|
+
def col(kind: str, k: int) -> str:
|
|
122
|
+
return nm[kind].format(k=k)
|
|
123
|
+
|
|
124
|
+
# ---- fast lookup: (E, N) -> (count_all, count_group) ----
|
|
125
|
+
lookup: dict[tuple[int, int], tuple[float, float]] = {}
|
|
126
|
+
for row in cells.itertuples(index=False):
|
|
127
|
+
key = (int(getattr(row, "E_grid")), int(getattr(row, "N_grid")))
|
|
128
|
+
lookup[key] = (float(getattr(row, count_all_col)),
|
|
129
|
+
float(getattr(row, count_group_col)))
|
|
130
|
+
|
|
131
|
+
print(f"[analysis] {len(lookup)} populated cells, k = {k_values}, "
|
|
132
|
+
f"unit = {unit_size} m")
|
|
133
|
+
if decay:
|
|
134
|
+
print(f"[analysis] decay active: {decay.describe()}")
|
|
135
|
+
rings = build_distance_rings(max_radius_units)
|
|
136
|
+
if tie_mode == "sequential" and seed is not None:
|
|
137
|
+
rng = np.random.default_rng(seed)
|
|
138
|
+
rings = [(d, list(rng.permutation(np.array(offs, dtype=object))))
|
|
139
|
+
for d, offs in rings]
|
|
140
|
+
print(f"[analysis] sequential tie order shuffled with seed {seed}")
|
|
141
|
+
print(f"[analysis] {len(rings)} distance rings ready")
|
|
142
|
+
|
|
143
|
+
results = []
|
|
144
|
+
step = int(unit_size)
|
|
145
|
+
|
|
146
|
+
for row in cells.itertuples(index=False):
|
|
147
|
+
e0 = int(getattr(row, "E_grid"))
|
|
148
|
+
n0 = int(getattr(row, "N_grid"))
|
|
149
|
+
local_all, local_grp = lookup[(e0, n0)]
|
|
150
|
+
|
|
151
|
+
rec: dict = {
|
|
152
|
+
"Id": getattr(row, id_col) if id_col else None,
|
|
153
|
+
"EastWest": e0,
|
|
154
|
+
"NorthSouth": n0,
|
|
155
|
+
"CountAllLocal": local_all,
|
|
156
|
+
"CountGroupLocal": local_grp,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# running totals - raw and (optionally) decay-weighted
|
|
160
|
+
sum_all, sum_grp, dist_m = local_all, local_grp, 0.0
|
|
161
|
+
d_all, d_grp = local_all, local_grp # weight(0) = 1
|
|
162
|
+
pending = list(k_values)
|
|
163
|
+
|
|
164
|
+
def record(k: int):
|
|
165
|
+
"""Write all per-k output columns at the current state."""
|
|
166
|
+
rec[col("N", k)] = sum_all
|
|
167
|
+
rec[col("T", k)] = sum_grp
|
|
168
|
+
rec[col("R", k)] = sum_grp / sum_all if sum_all else np.nan
|
|
169
|
+
rec[col("Dist", k)] = dist_m
|
|
170
|
+
if decay:
|
|
171
|
+
rec[col("ND", k)] = d_all
|
|
172
|
+
rec[col("TD", k)] = d_grp
|
|
173
|
+
rec[col("RD", k)] = d_grp / d_all if d_all else np.nan
|
|
174
|
+
|
|
175
|
+
# thresholds already satisfied inside the origin cell
|
|
176
|
+
while pending and sum_all >= pending[0]:
|
|
177
|
+
record(pending.pop(0))
|
|
178
|
+
|
|
179
|
+
# --- expand ring by ring ---
|
|
180
|
+
for dist_units, offsets in rings:
|
|
181
|
+
if not pending:
|
|
182
|
+
break
|
|
183
|
+
ring_dist_m = dist_units * unit_size
|
|
184
|
+
w = decay.weight(ring_dist_m) if decay else 1.0
|
|
185
|
+
|
|
186
|
+
if tie_mode == "sequential":
|
|
187
|
+
for dx, dy in offsets:
|
|
188
|
+
cell = lookup.get((e0 + dx * step, n0 + dy * step))
|
|
189
|
+
if not cell:
|
|
190
|
+
continue
|
|
191
|
+
sum_all += cell[0]
|
|
192
|
+
sum_grp += cell[1]
|
|
193
|
+
d_all += cell[0] * w
|
|
194
|
+
d_grp += cell[1] * w
|
|
195
|
+
dist_m = ring_dist_m
|
|
196
|
+
while pending and sum_all >= pending[0]:
|
|
197
|
+
record(pending.pop(0))
|
|
198
|
+
else: # "ring" - atomic per equidistant ring
|
|
199
|
+
ring_all = ring_grp = 0.0
|
|
200
|
+
for dx, dy in offsets:
|
|
201
|
+
cell = lookup.get((e0 + dx * step, n0 + dy * step))
|
|
202
|
+
if cell:
|
|
203
|
+
ring_all += cell[0]
|
|
204
|
+
ring_grp += cell[1]
|
|
205
|
+
if ring_all == 0:
|
|
206
|
+
continue
|
|
207
|
+
sum_all += ring_all
|
|
208
|
+
sum_grp += ring_grp
|
|
209
|
+
d_all += ring_all * w
|
|
210
|
+
d_grp += ring_grp * w
|
|
211
|
+
dist_m = ring_dist_m
|
|
212
|
+
while pending and sum_all >= pending[0]:
|
|
213
|
+
record(pending.pop(0))
|
|
214
|
+
|
|
215
|
+
# unreached thresholds: partial results (spec, section 12)
|
|
216
|
+
for k in pending:
|
|
217
|
+
record(k)
|
|
218
|
+
|
|
219
|
+
rec["SumCountAll"] = sum_all
|
|
220
|
+
rec["SumCountGroup"] = sum_grp
|
|
221
|
+
rec["Ratio"] = sum_grp / sum_all if sum_all else np.nan
|
|
222
|
+
rec["MaxDistance"] = dist_m
|
|
223
|
+
results.append(rec)
|
|
224
|
+
|
|
225
|
+
out = pd.DataFrame(results)
|
|
226
|
+
fixed = ["Id", "EastWest", "NorthSouth", "CountAllLocal",
|
|
227
|
+
"CountGroupLocal", "SumCountAll", "SumCountGroup",
|
|
228
|
+
"Ratio", "MaxDistance"]
|
|
229
|
+
kinds = ["N", "T", "R", "Dist"] + (["ND", "TD", "RD"] if decay else [])
|
|
230
|
+
per_k = [col(kind, k) for k in k_values for kind in kinds]
|
|
231
|
+
return out[fixed + per_k]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ======================================================================
|
|
235
|
+
# run_knn_stats - k-NN with per-variable statistics (tiers 1-3)
|
|
236
|
+
# ======================================================================
|
|
237
|
+
from .cells import CellData
|
|
238
|
+
from .stats import BINARY_STATS, VALUE_STATS, PREFIX
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def run_knn_stats(
|
|
242
|
+
cd: CellData,
|
|
243
|
+
k_values: list[int],
|
|
244
|
+
stats: dict[str, list[str]],
|
|
245
|
+
max_radius_units: int | None = None,
|
|
246
|
+
) -> pd.DataFrame:
|
|
247
|
+
"""
|
|
248
|
+
Radial k-NN analysis with user-selected statistics per variable.
|
|
249
|
+
|
|
250
|
+
The user switches statistics on per FUNCTION (applied to all k),
|
|
251
|
+
exactly as requested:
|
|
252
|
+
|
|
253
|
+
stats = {
|
|
254
|
+
"HighEdu": ["ratio", "sd", "se", "entropy", "gini"], # binary
|
|
255
|
+
"ForvInk": ["mean", "median", "sd", "se", "gini"], # value
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
Whether a variable is binary (tier 1, exact from counts) or
|
|
259
|
+
continuous (tiers 2/3, from stored individual values) is decided
|
|
260
|
+
by how it was declared in build_cells().
|
|
261
|
+
|
|
262
|
+
Engine note: this function uses a distance-sort core rather than
|
|
263
|
+
the ring-expansion core of run_knn(). For the radial (no-friction)
|
|
264
|
+
model the two are MATHEMATICALLY IDENTICAL - cells at equal
|
|
265
|
+
distance are still processed as one atomic ring - but the sort
|
|
266
|
+
core is much faster when populated cells are sparse. The
|
|
267
|
+
ring-expansion core remains the basis for the future friction
|
|
268
|
+
model, where visiting order genuinely depends on the path.
|
|
269
|
+
|
|
270
|
+
Output columns
|
|
271
|
+
--------------
|
|
272
|
+
EastWest, NorthSouth, N_local, plus per k:
|
|
273
|
+
N_{k}, Dist_{k},
|
|
274
|
+
per binary var+stat: e.g. R_HighEdu_{k}, Gini_HighEdu_{k}
|
|
275
|
+
per value var+stat: e.g. Mean_ForvInk_{k}, Med_ForvInk_{k}
|
|
276
|
+
per value var: Nv_{var}_{k} (count of valid values)
|
|
277
|
+
Unreached k-levels receive partial results, as in run_knn().
|
|
278
|
+
"""
|
|
279
|
+
k_values = sorted(k_values)
|
|
280
|
+
|
|
281
|
+
bin_vars = [v for v in stats if v in cd.binary_sums]
|
|
282
|
+
val_vars = [v for v in stats if v in cd.value_arrays]
|
|
283
|
+
unknown = [v for v in stats if v not in bin_vars + val_vars]
|
|
284
|
+
if unknown:
|
|
285
|
+
raise ValueError(f"Variables {unknown} were not declared in "
|
|
286
|
+
f"build_cells(binary_vars=..., value_vars=...).")
|
|
287
|
+
for v in bin_vars:
|
|
288
|
+
for s in stats[v]:
|
|
289
|
+
if s not in BINARY_STATS:
|
|
290
|
+
raise ValueError(f"Unknown binary statistic '{s}' for {v}. "
|
|
291
|
+
f"Available: {list(BINARY_STATS)}")
|
|
292
|
+
for v in val_vars:
|
|
293
|
+
for s in stats[v]:
|
|
294
|
+
if s not in VALUE_STATS:
|
|
295
|
+
raise ValueError(f"Unknown value statistic '{s}' for {v}. "
|
|
296
|
+
f"Available: {list(VALUE_STATS)}")
|
|
297
|
+
|
|
298
|
+
m = len(cd)
|
|
299
|
+
print(f"[stats] {m} cells, k = {k_values}")
|
|
300
|
+
print(f"[stats] binary vars: {bin_vars} | value vars: {val_vars}")
|
|
301
|
+
|
|
302
|
+
results = []
|
|
303
|
+
Ef, Nf = cd.E.astype(float), cd.N.astype(float)
|
|
304
|
+
|
|
305
|
+
for oi in range(m):
|
|
306
|
+
e0, n0 = cd.E[oi], cd.N[oi]
|
|
307
|
+
|
|
308
|
+
# distances from this origin to ALL populated cells (vectorised)
|
|
309
|
+
dist = np.hypot(Ef - e0, Nf - n0)
|
|
310
|
+
order = np.argsort(dist, kind="stable")
|
|
311
|
+
|
|
312
|
+
rec: dict = {"EastWest": round(float(e0), 2),
|
|
313
|
+
"NorthSouth": round(float(n0), 2),
|
|
314
|
+
"N_local": float(cd.n[oi])}
|
|
315
|
+
if cd.labels is not None:
|
|
316
|
+
rec["CellId"] = cd.labels[oi]
|
|
317
|
+
for v in bin_vars:
|
|
318
|
+
rec[f"{v}_local"] = float(cd.binary_sums[v][oi])
|
|
319
|
+
|
|
320
|
+
# running state
|
|
321
|
+
sum_n = 0.0
|
|
322
|
+
bin_t = {v: 0.0 for v in bin_vars}
|
|
323
|
+
val_chunks = {v: [] for v in val_vars}
|
|
324
|
+
dist_m = 0.0
|
|
325
|
+
pending = list(k_values)
|
|
326
|
+
|
|
327
|
+
def record(k: int):
|
|
328
|
+
rec[f"N_{k}"] = sum_n
|
|
329
|
+
rec[f"Dist_{k}"] = dist_m
|
|
330
|
+
for v in bin_vars:
|
|
331
|
+
for s in stats[v]:
|
|
332
|
+
rec[f"{PREFIX[s]}_{v}_{k}"] = BINARY_STATS[s](sum_n, bin_t[v])
|
|
333
|
+
for v in val_vars:
|
|
334
|
+
x = (np.concatenate(val_chunks[v])
|
|
335
|
+
if val_chunks[v] else np.empty(0))
|
|
336
|
+
rec[f"Nv_{v}_{k}"] = len(x)
|
|
337
|
+
for s in stats[v]:
|
|
338
|
+
rec[f"{PREFIX[s]}_{v}_{k}"] = VALUE_STATS[s](x)
|
|
339
|
+
|
|
340
|
+
# walk cells in distance order, atomically per equal-distance ring
|
|
341
|
+
j = 0
|
|
342
|
+
while j < m and pending:
|
|
343
|
+
d = dist[order[j]]
|
|
344
|
+
if max_radius_units is not None and d > max_radius_units * cd.unit_size:
|
|
345
|
+
break
|
|
346
|
+
# gather the full ring of cells at this exact distance
|
|
347
|
+
ring = []
|
|
348
|
+
while j < m and dist[order[j]] - d < 1e-6:
|
|
349
|
+
ring.append(order[j])
|
|
350
|
+
j += 1
|
|
351
|
+
for ci in ring:
|
|
352
|
+
sum_n += float(cd.n[ci])
|
|
353
|
+
for v in bin_vars:
|
|
354
|
+
bin_t[v] += cd.binary_sums[v][ci]
|
|
355
|
+
for v in val_vars:
|
|
356
|
+
a = cd.value_arrays[v][ci]
|
|
357
|
+
if len(a):
|
|
358
|
+
val_chunks[v].append(a)
|
|
359
|
+
dist_m = float(d)
|
|
360
|
+
while pending and sum_n >= pending[0]:
|
|
361
|
+
record(pending.pop(0))
|
|
362
|
+
|
|
363
|
+
for k in pending: # unreached: partial results
|
|
364
|
+
record(k)
|
|
365
|
+
rec["SumN"] = sum_n
|
|
366
|
+
rec["MaxDistance"] = dist_m
|
|
367
|
+
results.append(rec)
|
|
368
|
+
|
|
369
|
+
return pd.DataFrame(results)
|
equipop/area.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
area.py - area-based output (backlog item 9): bring overlapping
|
|
3
|
+
bespoke-neighbourhood results back to fixed geographies that policy
|
|
4
|
+
audiences grasp.
|
|
5
|
+
|
|
6
|
+
DELIBERATE DESIGN, stated for reviewers: values are collected
|
|
7
|
+
overlappingly with k and then SUMMARISED per area - this is
|
|
8
|
+
"individualised context, reported per area", not a recomputation of
|
|
9
|
+
areal statistics. The precedent is the block-level illustration of
|
|
10
|
+
EquiPop output in Östh, Clark & Malmberg (2015, fig. 1).
|
|
11
|
+
|
|
12
|
+
Three alternatives, one function:
|
|
13
|
+
Alt 1 by = a COLUMN NAME already on the output (belonging ID,
|
|
14
|
+
e.g. a municipality code carried through CellId/merge).
|
|
15
|
+
Alt 2 by = a POLYGON FILE path (shp/gpkg): origin cells are
|
|
16
|
+
point-in-polygon assigned (geopandas sjoin); `id_field`
|
|
17
|
+
names the polygon attribute to aggregate by. `points_epsg`
|
|
18
|
+
must be given if it differs from the polygons' CRS.
|
|
19
|
+
Alt 3 by = a NUMBER: a coarser grid size (e.g. 1000 for 1 km
|
|
20
|
+
super-cells over 100 m results), anchored at the minimum
|
|
21
|
+
X/Y of the data.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def aggregate_output(
|
|
29
|
+
df: pd.DataFrame,
|
|
30
|
+
by,
|
|
31
|
+
columns: list[str] | None = None,
|
|
32
|
+
how: str = "wmean",
|
|
33
|
+
weight_col: str = "N_local",
|
|
34
|
+
id_field: str | None = None,
|
|
35
|
+
points_epsg: int | None = None,
|
|
36
|
+
x_col: str = "EastWest",
|
|
37
|
+
y_col: str = "NorthSouth",
|
|
38
|
+
) -> pd.DataFrame:
|
|
39
|
+
"""
|
|
40
|
+
Aggregate k-NN output to areas. See module docstring for the three
|
|
41
|
+
`by` alternatives.
|
|
42
|
+
|
|
43
|
+
columns : which output columns to aggregate (default: all R_*,
|
|
44
|
+
Mean_*, Med_*, Gini_*, SD_* columns found).
|
|
45
|
+
how : 'wmean' population-weighted mean (weights = weight_col,
|
|
46
|
+
typically the origin population), 'mean', or 'median'.
|
|
47
|
+
Returns one row per area with the aggregated columns, the number
|
|
48
|
+
of origin cells, and the summed weight.
|
|
49
|
+
"""
|
|
50
|
+
df = df.copy()
|
|
51
|
+
if columns is None:
|
|
52
|
+
columns = [c for c in df.columns if c.split("_")[0] in
|
|
53
|
+
("R", "Mean", "Med", "Gini", "SD", "Ratio")]
|
|
54
|
+
if not columns:
|
|
55
|
+
raise ValueError("No aggregatable columns found - pass "
|
|
56
|
+
"columns=[...] explicitly.")
|
|
57
|
+
|
|
58
|
+
# ---- resolve `by` into a per-row area label ----
|
|
59
|
+
if isinstance(by, (int, float)): # Alt 3
|
|
60
|
+
u = float(by)
|
|
61
|
+
x0, y0 = df[x_col].min(), df[y_col].min()
|
|
62
|
+
df["_area"] = (
|
|
63
|
+
"SG" + (np.floor((df[x_col] - x0) / u)).astype(int).astype(str)
|
|
64
|
+
+ "_" + (np.floor((df[y_col] - y0) / u)).astype(int).astype(str))
|
|
65
|
+
label = f"supergrid_{int(u)}m"
|
|
66
|
+
elif isinstance(by, str) and by in df.columns: # Alt 1
|
|
67
|
+
df["_area"] = df[by]
|
|
68
|
+
label = by
|
|
69
|
+
elif isinstance(by, str): # Alt 2: file
|
|
70
|
+
try:
|
|
71
|
+
import geopandas as gpd
|
|
72
|
+
except ImportError:
|
|
73
|
+
raise ImportError("Polygon aggregation needs geopandas.")
|
|
74
|
+
polys = gpd.read_file(by)
|
|
75
|
+
if id_field is None:
|
|
76
|
+
raise ValueError("Give id_field=<polygon attribute>.")
|
|
77
|
+
pts = gpd.GeoDataFrame(
|
|
78
|
+
df[[x_col, y_col]],
|
|
79
|
+
geometry=gpd.points_from_xy(df[x_col], df[y_col]),
|
|
80
|
+
crs=f"EPSG:{points_epsg}" if points_epsg else polys.crs)
|
|
81
|
+
if points_epsg and pts.crs != polys.crs:
|
|
82
|
+
pts = pts.to_crs(polys.crs)
|
|
83
|
+
print(f"[area] points reprojected EPSG:{points_epsg} -> "
|
|
84
|
+
f"{polys.crs.to_epsg()} for the join")
|
|
85
|
+
joined = gpd.sjoin(pts, polys[[id_field, "geometry"]],
|
|
86
|
+
how="left", predicate="within")
|
|
87
|
+
df["_area"] = joined[id_field].to_numpy()
|
|
88
|
+
unmatched = df["_area"].isna().sum()
|
|
89
|
+
if unmatched:
|
|
90
|
+
print(f"[area] WARNING: {unmatched} origin cells fall outside "
|
|
91
|
+
f"all polygons (dropped from area output).")
|
|
92
|
+
df = df[df["_area"].notna()]
|
|
93
|
+
label = id_field
|
|
94
|
+
else:
|
|
95
|
+
raise ValueError("`by` must be a column name, a polygon file "
|
|
96
|
+
"path, or a super-grid size in metres.")
|
|
97
|
+
|
|
98
|
+
# ---- aggregate ----
|
|
99
|
+
w = df[weight_col].to_numpy(float)
|
|
100
|
+
out_rows = []
|
|
101
|
+
for area, g in df.groupby("_area"):
|
|
102
|
+
gw = g[weight_col].to_numpy(float)
|
|
103
|
+
row = {label: area, "n_origin_cells": len(g),
|
|
104
|
+
weight_col + "_sum": gw.sum()}
|
|
105
|
+
for c in columns:
|
|
106
|
+
v = g[c].to_numpy(float)
|
|
107
|
+
ok = np.isfinite(v)
|
|
108
|
+
if not ok.any():
|
|
109
|
+
row[c] = np.nan
|
|
110
|
+
elif how == "wmean":
|
|
111
|
+
row[c] = np.average(v[ok], weights=gw[ok]) \
|
|
112
|
+
if gw[ok].sum() > 0 else np.nan
|
|
113
|
+
elif how == "mean":
|
|
114
|
+
row[c] = v[ok].mean()
|
|
115
|
+
elif how == "median":
|
|
116
|
+
row[c] = np.median(v[ok])
|
|
117
|
+
out_rows.append(row)
|
|
118
|
+
out = pd.DataFrame(out_rows)
|
|
119
|
+
print(f"[area] {len(df)} origin cells -> {len(out)} areas "
|
|
120
|
+
f"('{label}', how={how})")
|
|
121
|
+
return out
|
equipop/cells.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cells.py - build cell-level data from INDIVIDUAL-level rows.
|
|
3
|
+
|
|
4
|
+
This is the entry point for "tier 3" data: one row per individual,
|
|
5
|
+
where several individuals may share the same coordinate. The builder
|
|
6
|
+
aggregates them into grid cells while keeping, per cell:
|
|
7
|
+
|
|
8
|
+
- n : the individual count (this is what k counts!)
|
|
9
|
+
- binary sums : one running sum per binary variable (0/1)
|
|
10
|
+
- value arrays : the raw individual values per continuous variable
|
|
11
|
+
(needed for exact median / Gini at k-level)
|
|
12
|
+
|
|
13
|
+
Missing handling (spec section 12):
|
|
14
|
+
- rows with missing coordinates are DROPPED with a printed warning
|
|
15
|
+
- missing values in a continuous variable: the individual still
|
|
16
|
+
counts towards k, but contributes no value to that variable's
|
|
17
|
+
statistics (a separate valid-n is reported as Nv_<var>_<k>)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CellData:
|
|
27
|
+
"""Aggregated per-cell data ready for run_knn_stats()."""
|
|
28
|
+
E: np.ndarray # cell midpoint eastings (m cells)
|
|
29
|
+
N: np.ndarray # cell midpoint northings
|
|
30
|
+
n: np.ndarray # individuals per cell
|
|
31
|
+
binary_sums: dict = field(default_factory=dict) # var -> array (m,)
|
|
32
|
+
value_arrays: dict = field(default_factory=dict) # var -> list of arrays
|
|
33
|
+
unit_size: float = 100.0
|
|
34
|
+
labels: list | None = None # optional per-cell ID/label (e.g. place, year)
|
|
35
|
+
|
|
36
|
+
def __len__(self):
|
|
37
|
+
return len(self.n)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_cells(
|
|
41
|
+
df: pd.DataFrame,
|
|
42
|
+
e_col: str,
|
|
43
|
+
n_col: str,
|
|
44
|
+
binary_vars: list[str] | None = None,
|
|
45
|
+
value_vars: list[str] | None = None,
|
|
46
|
+
unit_size: float = 100.0,
|
|
47
|
+
snap: bool = True,
|
|
48
|
+
label_col: str | None = None,
|
|
49
|
+
) -> CellData:
|
|
50
|
+
"""
|
|
51
|
+
Aggregate an individual-level DataFrame into CellData.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
df : one row per individual.
|
|
56
|
+
e_col, n_col : METRIC coordinate columns (already projected).
|
|
57
|
+
binary_vars : 0/1 columns (each becomes a treatment with exact
|
|
58
|
+
count-based statistics).
|
|
59
|
+
value_vars : continuous columns (individual values are stored
|
|
60
|
+
per cell for exact median/Gini/etc.).
|
|
61
|
+
unit_size : grid size in metres.
|
|
62
|
+
snap : snap coordinates to grid midpoints. Idempotent - if the
|
|
63
|
+
data is already midpoint-snapped (like 100m register data
|
|
64
|
+
ending in ...50), snapping changes nothing.
|
|
65
|
+
label_col : optional ID column carried through to the output
|
|
66
|
+
(e.g. a place code or a year). If a cell contains SEVERAL
|
|
67
|
+
distinct labels they are joined with '|' and a warning is
|
|
68
|
+
printed - a good ID should be constant within a cell.
|
|
69
|
+
"""
|
|
70
|
+
binary_vars = binary_vars or []
|
|
71
|
+
value_vars = value_vars or []
|
|
72
|
+
df = df.copy()
|
|
73
|
+
|
|
74
|
+
# --- coerce to numeric; blanks become NaN ---
|
|
75
|
+
for c in [e_col, n_col] + binary_vars + value_vars:
|
|
76
|
+
df[c] = pd.to_numeric(df[c], errors="coerce")
|
|
77
|
+
|
|
78
|
+
# --- missing coordinates: drop with warning (spec 12) ---
|
|
79
|
+
bad = df[e_col].isna() | df[n_col].isna()
|
|
80
|
+
if bad.any():
|
|
81
|
+
print(f"[cells] WARNING: {bad.sum()} of {len(df)} rows have "
|
|
82
|
+
f"missing coordinates and are dropped.")
|
|
83
|
+
df = df[~bad]
|
|
84
|
+
|
|
85
|
+
# --- snap to grid midpoints ---
|
|
86
|
+
if snap:
|
|
87
|
+
half = unit_size / 2.0
|
|
88
|
+
df["_E"] = (np.floor(df[e_col] / unit_size) * unit_size + half).astype(int)
|
|
89
|
+
df["_N"] = (np.floor(df[n_col] / unit_size) * unit_size + half).astype(int)
|
|
90
|
+
else:
|
|
91
|
+
df["_E"] = df[e_col].astype(int)
|
|
92
|
+
df["_N"] = df[n_col].astype(int)
|
|
93
|
+
|
|
94
|
+
# --- report missing values in analysis variables ---
|
|
95
|
+
for v in value_vars:
|
|
96
|
+
miss = df[v].isna().sum()
|
|
97
|
+
if miss:
|
|
98
|
+
print(f"[cells] note: '{v}' has {miss} missing values - these "
|
|
99
|
+
f"individuals count towards k but not towards {v} statistics.")
|
|
100
|
+
|
|
101
|
+
# --- aggregate ---
|
|
102
|
+
groups = df.groupby(["_E", "_N"], sort=True)
|
|
103
|
+
E, N, n = [], [], []
|
|
104
|
+
bsums = {v: [] for v in binary_vars}
|
|
105
|
+
varrs = {v: [] for v in value_vars}
|
|
106
|
+
labels = [] if label_col else None
|
|
107
|
+
mixed = 0
|
|
108
|
+
|
|
109
|
+
for (e, nn), g in groups:
|
|
110
|
+
E.append(e)
|
|
111
|
+
N.append(nn)
|
|
112
|
+
n.append(len(g))
|
|
113
|
+
for v in binary_vars:
|
|
114
|
+
bsums[v].append(g[v].sum())
|
|
115
|
+
for v in value_vars:
|
|
116
|
+
varrs[v].append(g[v].dropna().to_numpy(dtype=float))
|
|
117
|
+
if label_col:
|
|
118
|
+
uniq = g[label_col].astype(str).unique()
|
|
119
|
+
if len(uniq) > 1:
|
|
120
|
+
mixed += 1
|
|
121
|
+
labels.append("|".join(sorted(uniq)))
|
|
122
|
+
|
|
123
|
+
if label_col and mixed:
|
|
124
|
+
print(f"[cells] WARNING: {mixed} cells contain several distinct "
|
|
125
|
+
f"'{label_col}' values (joined with '|'). A good cell ID "
|
|
126
|
+
f"should be constant within a cell.")
|
|
127
|
+
|
|
128
|
+
cd = CellData(
|
|
129
|
+
E=np.array(E, dtype=np.int64),
|
|
130
|
+
N=np.array(N, dtype=np.int64),
|
|
131
|
+
n=np.array(n, dtype=np.int64),
|
|
132
|
+
binary_sums={v: np.array(a, dtype=float) for v, a in bsums.items()},
|
|
133
|
+
value_arrays=varrs,
|
|
134
|
+
unit_size=unit_size,
|
|
135
|
+
labels=labels,
|
|
136
|
+
)
|
|
137
|
+
print(f"[cells] {len(df)} individuals -> {len(cd)} cells "
|
|
138
|
+
f"(unit {unit_size} m, global N = {cd.n.sum()})")
|
|
139
|
+
return cd
|