equipop 1.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.
- equipop-1.1.0/LICENSE +21 -0
- equipop-1.1.0/PKG-INFO +89 -0
- equipop-1.1.0/README.md +59 -0
- equipop-1.1.0/equipop/__init__.py +31 -0
- equipop-1.1.0/equipop/analysis.py +369 -0
- equipop-1.1.0/equipop/area.py +121 -0
- equipop-1.1.0/equipop/cells.py +139 -0
- equipop-1.1.0/equipop/decay.py +88 -0
- equipop-1.1.0/equipop/fastcounts.py +96 -0
- equipop-1.1.0/equipop/fetch.py +64 -0
- equipop-1.1.0/equipop/friction.py +265 -0
- equipop-1.1.0/equipop/hex.py +109 -0
- equipop-1.1.0/equipop/io.py +164 -0
- equipop-1.1.0/equipop/meta.py +186 -0
- equipop-1.1.0/equipop/projection.py +168 -0
- equipop-1.1.0/equipop/raster.py +106 -0
- equipop-1.1.0/equipop/segregation.py +103 -0
- equipop-1.1.0/equipop/stata_bridge.py +99 -0
- equipop-1.1.0/equipop/stats.py +86 -0
- equipop-1.1.0/equipop/transform.py +115 -0
- equipop-1.1.0/equipop/viz.py +113 -0
- equipop-1.1.0/equipop.egg-info/PKG-INFO +89 -0
- equipop-1.1.0/equipop.egg-info/SOURCES.txt +27 -0
- equipop-1.1.0/equipop.egg-info/dependency_links.txt +1 -0
- equipop-1.1.0/equipop.egg-info/requires.txt +22 -0
- equipop-1.1.0/equipop.egg-info/top_level.txt +1 -0
- equipop-1.1.0/pyproject.toml +25 -0
- equipop-1.1.0/setup.cfg +4 -0
- equipop-1.1.0/tests/test_equipop.py +219 -0
equipop-1.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Östh
|
|
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.
|
equipop-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: equipop
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: k-nearest neighbour contexts/neighbourhoods on gridded and hexagonal data: bespoke neighbourhoods, friction growth, distance decay, statistics and segregation measures
|
|
5
|
+
Author: John Östh
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/GeoJohnSwe/EquiPop
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: pandas>=2.0
|
|
12
|
+
Requires-Dist: numpy>=1.24
|
|
13
|
+
Requires-Dist: scipy>=1.10
|
|
14
|
+
Requires-Dist: pyproj>=3.4
|
|
15
|
+
Provides-Extra: geo
|
|
16
|
+
Requires-Dist: geopandas>=0.14; extra == "geo"
|
|
17
|
+
Requires-Dist: pyogrio; extra == "geo"
|
|
18
|
+
Requires-Dist: rasterio; extra == "geo"
|
|
19
|
+
Provides-Extra: io
|
|
20
|
+
Requires-Dist: openpyxl; extra == "io"
|
|
21
|
+
Requires-Dist: pyarrow; extra == "io"
|
|
22
|
+
Requires-Dist: pyreadstat; extra == "io"
|
|
23
|
+
Provides-Extra: viz
|
|
24
|
+
Requires-Dist: matplotlib; extra == "viz"
|
|
25
|
+
Requires-Dist: jenkspy; extra == "viz"
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest; extra == "test"
|
|
28
|
+
Requires-Dist: openpyxl; extra == "test"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# EquiPop (Python)
|
|
32
|
+
|
|
33
|
+
k-nearest neighbour contexts/neighbourhoods on gridded and hexagonal
|
|
34
|
+
data: bespoke neighbourhoods, friction-aware growth, distance decay,
|
|
35
|
+
neighbourhood statistics and segregation measures - for very large
|
|
36
|
+
datasets.
|
|
37
|
+
|
|
38
|
+
EquiPop finds, for every populated location, the k nearest individuals
|
|
39
|
+
(by cumulative population count, expanding over a uniform grid) and
|
|
40
|
+
reports the composition of that individualised neighbourhood. The
|
|
41
|
+
gridded design means one pre-computed distance ordering serves every
|
|
42
|
+
origin, which is what makes millions of locations feasible.
|
|
43
|
+
|
|
44
|
+
## The EquiPop family
|
|
45
|
+
This is the first Python implementation. Its relatives: the original
|
|
46
|
+
**C# EquiPop** (three generations, two public releases; radial and Flow
|
|
47
|
+
models, downloadable via Uppsala University) and an **R version**
|
|
48
|
+
covering I/O and the basic k-NN parts. This Python version additionally
|
|
49
|
+
carries friction growth, five decay models, exact neighbourhood
|
|
50
|
+
statistics (mean/median/SD/SE/Gini/entropy), segregation indices,
|
|
51
|
+
hexagonal grids, area aggregation and metadata logging.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
```
|
|
55
|
+
pip install equipop # core
|
|
56
|
+
pip install equipop[geo,io,viz] # shapefiles/rasters, Excel/SPSS, maps
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
```python
|
|
61
|
+
import pandas as pd
|
|
62
|
+
from equipop import build_cells, run_knn_stats
|
|
63
|
+
|
|
64
|
+
df = pd.read_csv("individuals.csv") # one row per individual
|
|
65
|
+
cd = build_cells(df, e_col="X", n_col="Y",
|
|
66
|
+
binary_vars=["treated"], value_vars=["income"],
|
|
67
|
+
unit_size=100)
|
|
68
|
+
res = run_knn_stats(cd, k_values=[100, 400, 1600, 6400],
|
|
69
|
+
stats={"treated": ["ratio"],
|
|
70
|
+
"income": ["mean", "median", "gini"]})
|
|
71
|
+
```
|
|
72
|
+
For aggregated counts at national scale use the vectorised
|
|
73
|
+
`run_knn_counts`; for friction-constrained growth `run_knn_friction`;
|
|
74
|
+
for segregation profiles `seg_profile`; for policy-area reporting
|
|
75
|
+
`aggregate_output`; for quick maps `map_output`. See MANUAL_TOPICS.md.
|
|
76
|
+
|
|
77
|
+
Repository: https://github.com/GeoJohnSwe/EquiPop
|
|
78
|
+
|
|
79
|
+
## Citing
|
|
80
|
+
Please cite the software and the methods you use - see CITATION.cff.
|
|
81
|
+
Core reference: Östh (2014), *Introducing the EquiPop software*,
|
|
82
|
+
Uppsala University. Segregation measures: Östh, Clark & Malmberg
|
|
83
|
+
(2015), *Geographical Analysis* 47(1). Decay parameters: Östh, Lyhagen
|
|
84
|
+
& Reggiani (2016), *EJTIR* 16(2). Friction growth: Östh & Türk (2020),
|
|
85
|
+
*Handbook of Urban Segregation*. Decay profiles in application:
|
|
86
|
+
Türk, Östh, Kourtit & Nijkamp (2026), *Journal of Urban Mobility* 9.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
MIT. Attribution through citation is warmly requested (see above).
|
equipop-1.1.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# EquiPop (Python)
|
|
2
|
+
|
|
3
|
+
k-nearest neighbour contexts/neighbourhoods on gridded and hexagonal
|
|
4
|
+
data: bespoke neighbourhoods, friction-aware growth, distance decay,
|
|
5
|
+
neighbourhood statistics and segregation measures - for very large
|
|
6
|
+
datasets.
|
|
7
|
+
|
|
8
|
+
EquiPop finds, for every populated location, the k nearest individuals
|
|
9
|
+
(by cumulative population count, expanding over a uniform grid) and
|
|
10
|
+
reports the composition of that individualised neighbourhood. The
|
|
11
|
+
gridded design means one pre-computed distance ordering serves every
|
|
12
|
+
origin, which is what makes millions of locations feasible.
|
|
13
|
+
|
|
14
|
+
## The EquiPop family
|
|
15
|
+
This is the first Python implementation. Its relatives: the original
|
|
16
|
+
**C# EquiPop** (three generations, two public releases; radial and Flow
|
|
17
|
+
models, downloadable via Uppsala University) and an **R version**
|
|
18
|
+
covering I/O and the basic k-NN parts. This Python version additionally
|
|
19
|
+
carries friction growth, five decay models, exact neighbourhood
|
|
20
|
+
statistics (mean/median/SD/SE/Gini/entropy), segregation indices,
|
|
21
|
+
hexagonal grids, area aggregation and metadata logging.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
```
|
|
25
|
+
pip install equipop # core
|
|
26
|
+
pip install equipop[geo,io,viz] # shapefiles/rasters, Excel/SPSS, maps
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
```python
|
|
31
|
+
import pandas as pd
|
|
32
|
+
from equipop import build_cells, run_knn_stats
|
|
33
|
+
|
|
34
|
+
df = pd.read_csv("individuals.csv") # one row per individual
|
|
35
|
+
cd = build_cells(df, e_col="X", n_col="Y",
|
|
36
|
+
binary_vars=["treated"], value_vars=["income"],
|
|
37
|
+
unit_size=100)
|
|
38
|
+
res = run_knn_stats(cd, k_values=[100, 400, 1600, 6400],
|
|
39
|
+
stats={"treated": ["ratio"],
|
|
40
|
+
"income": ["mean", "median", "gini"]})
|
|
41
|
+
```
|
|
42
|
+
For aggregated counts at national scale use the vectorised
|
|
43
|
+
`run_knn_counts`; for friction-constrained growth `run_knn_friction`;
|
|
44
|
+
for segregation profiles `seg_profile`; for policy-area reporting
|
|
45
|
+
`aggregate_output`; for quick maps `map_output`. See MANUAL_TOPICS.md.
|
|
46
|
+
|
|
47
|
+
Repository: https://github.com/GeoJohnSwe/EquiPop
|
|
48
|
+
|
|
49
|
+
## Citing
|
|
50
|
+
Please cite the software and the methods you use - see CITATION.cff.
|
|
51
|
+
Core reference: Östh (2014), *Introducing the EquiPop software*,
|
|
52
|
+
Uppsala University. Segregation measures: Östh, Clark & Malmberg
|
|
53
|
+
(2015), *Geographical Analysis* 47(1). Decay parameters: Östh, Lyhagen
|
|
54
|
+
& Reggiani (2016), *EJTIR* 16(2). Friction growth: Östh & Türk (2020),
|
|
55
|
+
*Handbook of Urban Segregation*. Decay profiles in application:
|
|
56
|
+
Türk, Östh, Kourtit & Nijkamp (2026), *Journal of Urban Mobility* 9.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
MIT. Attribution through citation is warmly requested (see above).
|
|
@@ -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"]
|
|
@@ -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)
|