segstats 0.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.
- segstats/__init__.py +124 -0
- segstats/__main__.py +7 -0
- segstats/_arrays.py +84 -0
- segstats/_version.py +1 -0
- segstats/aspatial.py +203 -0
- segstats/cli.py +220 -0
- segstats/data.py +233 -0
- segstats/geometry.py +354 -0
- segstats/indices.py +327 -0
- segstats/inference.py +329 -0
- segstats/local.py +65 -0
- segstats/py.typed +0 -0
- segstats/spatial.py +375 -0
- segstats/weights.py +79 -0
- segstats-0.1.0.dist-info/METADATA +282 -0
- segstats-0.1.0.dist-info/RECORD +20 -0
- segstats-0.1.0.dist-info/WHEEL +5 -0
- segstats-0.1.0.dist-info/entry_points.txt +2 -0
- segstats-0.1.0.dist-info/licenses/LICENSE +21 -0
- segstats-0.1.0.dist-info/top_level.txt +1 -0
segstats/__init__.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""segstats: residential segregation and diversity indices for urban analysis.
|
|
2
|
+
|
|
3
|
+
Implements the five dimensions of Massey & Denton (1988) - evenness, exposure,
|
|
4
|
+
concentration, centralization and clustering - plus multigroup indices (Reardon &
|
|
5
|
+
Firebaugh 2002), spatial evenness measures (Morrill 1991; Wong 1993; Reardon &
|
|
6
|
+
O'Sullivan 2004), local diversity measures and Monte Carlo inference.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
import segstats as ss
|
|
11
|
+
|
|
12
|
+
data = ss.read_csv("tracts.csv", id_column="tract", groups=["black", "white"])
|
|
13
|
+
ss.dissimilarity(*data.two_group("black"))
|
|
14
|
+
ss.profile(data, group="black")
|
|
15
|
+
ss.permutation_test(data, "D", group="black", seed=1).p_value
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .aspatial import (
|
|
20
|
+
atkinson,
|
|
21
|
+
correlation_ratio,
|
|
22
|
+
dissimilarity,
|
|
23
|
+
entropy,
|
|
24
|
+
exposure_matrix,
|
|
25
|
+
gini,
|
|
26
|
+
interaction,
|
|
27
|
+
isolation,
|
|
28
|
+
multigroup_dissimilarity,
|
|
29
|
+
multigroup_entropy,
|
|
30
|
+
)
|
|
31
|
+
from .data import ZoneData, from_dataframe, read_csv, read_geojson
|
|
32
|
+
from .geometry import ZoneGeometry
|
|
33
|
+
from .indices import INDICES, IndexOptions, IndexSpec, Statistic, compute, profile, statistic
|
|
34
|
+
from .inference import (
|
|
35
|
+
BootstrapResult,
|
|
36
|
+
PermutationResult,
|
|
37
|
+
bootstrap_ci,
|
|
38
|
+
permutation_test,
|
|
39
|
+
random_allocation,
|
|
40
|
+
)
|
|
41
|
+
from .local import (
|
|
42
|
+
local_dissimilarity_contribution,
|
|
43
|
+
local_entropy,
|
|
44
|
+
local_spatial_entropy,
|
|
45
|
+
location_quotient,
|
|
46
|
+
)
|
|
47
|
+
from .spatial import (
|
|
48
|
+
absolute_centralization,
|
|
49
|
+
absolute_clustering,
|
|
50
|
+
absolute_concentration,
|
|
51
|
+
delta,
|
|
52
|
+
local_environment,
|
|
53
|
+
morrill_dissimilarity,
|
|
54
|
+
proximity_matrix,
|
|
55
|
+
relative_centralization,
|
|
56
|
+
relative_clustering,
|
|
57
|
+
relative_concentration,
|
|
58
|
+
spatial_dissimilarity,
|
|
59
|
+
spatial_entropy,
|
|
60
|
+
spatial_proximity,
|
|
61
|
+
wong_dissimilarity_s,
|
|
62
|
+
wong_dissimilarity_w,
|
|
63
|
+
)
|
|
64
|
+
from .weights import distance_matrix, kernel_weights, row_standardize, with_self
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"__version__",
|
|
68
|
+
# evenness / exposure
|
|
69
|
+
"dissimilarity",
|
|
70
|
+
"gini",
|
|
71
|
+
"entropy",
|
|
72
|
+
"atkinson",
|
|
73
|
+
"isolation",
|
|
74
|
+
"interaction",
|
|
75
|
+
"correlation_ratio",
|
|
76
|
+
"multigroup_dissimilarity",
|
|
77
|
+
"multigroup_entropy",
|
|
78
|
+
"exposure_matrix",
|
|
79
|
+
# concentration / centralization / clustering
|
|
80
|
+
"delta",
|
|
81
|
+
"absolute_concentration",
|
|
82
|
+
"relative_concentration",
|
|
83
|
+
"absolute_centralization",
|
|
84
|
+
"relative_centralization",
|
|
85
|
+
"proximity_matrix",
|
|
86
|
+
"absolute_clustering",
|
|
87
|
+
"relative_clustering",
|
|
88
|
+
"spatial_proximity",
|
|
89
|
+
# spatial evenness
|
|
90
|
+
"morrill_dissimilarity",
|
|
91
|
+
"wong_dissimilarity_w",
|
|
92
|
+
"wong_dissimilarity_s",
|
|
93
|
+
"local_environment",
|
|
94
|
+
"spatial_dissimilarity",
|
|
95
|
+
"spatial_entropy",
|
|
96
|
+
# local
|
|
97
|
+
"location_quotient",
|
|
98
|
+
"local_entropy",
|
|
99
|
+
"local_spatial_entropy",
|
|
100
|
+
"local_dissimilarity_contribution",
|
|
101
|
+
# data, geometry, weights
|
|
102
|
+
"ZoneData",
|
|
103
|
+
"ZoneGeometry",
|
|
104
|
+
"read_csv",
|
|
105
|
+
"read_geojson",
|
|
106
|
+
"from_dataframe",
|
|
107
|
+
"distance_matrix",
|
|
108
|
+
"kernel_weights",
|
|
109
|
+
"with_self",
|
|
110
|
+
"row_standardize",
|
|
111
|
+
# registry and inference
|
|
112
|
+
"INDICES",
|
|
113
|
+
"IndexSpec",
|
|
114
|
+
"IndexOptions",
|
|
115
|
+
"Statistic",
|
|
116
|
+
"statistic",
|
|
117
|
+
"compute",
|
|
118
|
+
"profile",
|
|
119
|
+
"permutation_test",
|
|
120
|
+
"bootstrap_ci",
|
|
121
|
+
"random_allocation",
|
|
122
|
+
"PermutationResult",
|
|
123
|
+
"BootstrapResult",
|
|
124
|
+
]
|
segstats/__main__.py
ADDED
segstats/_arrays.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Input validation helpers shared by every index module (internal)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
__all__ = ["as_counts", "as_vector", "as_square", "two_group", "safe_div"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def as_vector(values: Any, name: str, n: Optional[int] = None, nonneg: bool = True) -> np.ndarray:
|
|
13
|
+
"""Return ``values`` as a finite 1-D float array, optionally checking its length."""
|
|
14
|
+
arr = np.asarray(values, dtype=float)
|
|
15
|
+
if arr.ndim != 1:
|
|
16
|
+
raise ValueError(f"{name} must be one-dimensional, got shape {arr.shape}")
|
|
17
|
+
if n is not None and arr.shape[0] != n:
|
|
18
|
+
raise ValueError(f"{name} has length {arr.shape[0]}, expected {n}")
|
|
19
|
+
if not np.all(np.isfinite(arr)):
|
|
20
|
+
raise ValueError(f"{name} contains NaN or infinite values")
|
|
21
|
+
if nonneg and np.any(arr < 0):
|
|
22
|
+
raise ValueError(f"{name} contains negative values")
|
|
23
|
+
return arr
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def as_counts(counts: Any, min_groups: int = 1) -> np.ndarray:
|
|
27
|
+
"""Return a zones x groups count matrix as a validated 2-D float array."""
|
|
28
|
+
arr = np.asarray(counts, dtype=float)
|
|
29
|
+
if arr.ndim == 1:
|
|
30
|
+
arr = arr[:, None]
|
|
31
|
+
if arr.ndim != 2:
|
|
32
|
+
raise ValueError(f"counts must be a 2-D zones x groups table, got shape {arr.shape}")
|
|
33
|
+
if arr.shape[0] < 1:
|
|
34
|
+
raise ValueError("counts has no zones")
|
|
35
|
+
if arr.shape[1] < min_groups:
|
|
36
|
+
raise ValueError(f"counts needs at least {min_groups} group columns")
|
|
37
|
+
if not np.all(np.isfinite(arr)):
|
|
38
|
+
raise ValueError("counts contains NaN or infinite values")
|
|
39
|
+
if np.any(arr < 0):
|
|
40
|
+
raise ValueError("counts contains negative values")
|
|
41
|
+
if arr.sum() <= 0:
|
|
42
|
+
raise ValueError("counts sum to zero")
|
|
43
|
+
return arr
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def as_square(matrix: Any, name: str, n: int) -> np.ndarray:
|
|
47
|
+
"""Return an n x n finite, non-negative float matrix."""
|
|
48
|
+
arr = np.asarray(matrix, dtype=float)
|
|
49
|
+
if arr.shape != (n, n):
|
|
50
|
+
raise ValueError(f"{name} must have shape ({n}, {n}), got {arr.shape}")
|
|
51
|
+
if not np.all(np.isfinite(arr)):
|
|
52
|
+
raise ValueError(f"{name} contains NaN or infinite values")
|
|
53
|
+
if np.any(arr < 0):
|
|
54
|
+
raise ValueError(f"{name} contains negative values")
|
|
55
|
+
return arr
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def two_group(x: Any, t: Any) -> Tuple[np.ndarray, np.ndarray]:
|
|
59
|
+
"""Validate a minority-count vector ``x`` against zone totals ``t``.
|
|
60
|
+
|
|
61
|
+
Requires ``0 <= x_i <= t_i`` and ``0 < X < T`` so that both the group and the
|
|
62
|
+
rest of the population are present somewhere (otherwise every two-group index is
|
|
63
|
+
undefined).
|
|
64
|
+
"""
|
|
65
|
+
xa = as_vector(x, "x")
|
|
66
|
+
ta = as_vector(t, "t", n=xa.shape[0])
|
|
67
|
+
if np.any(xa > ta * (1 + 1e-12) + 1e-12):
|
|
68
|
+
raise ValueError("group count exceeds the zone total in at least one zone")
|
|
69
|
+
big_x = xa.sum()
|
|
70
|
+
big_t = ta.sum()
|
|
71
|
+
if big_x <= 0:
|
|
72
|
+
raise ValueError("the group of interest has zero population")
|
|
73
|
+
if big_x >= big_t:
|
|
74
|
+
raise ValueError("the group of interest is the whole population; index undefined")
|
|
75
|
+
return xa, ta
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def safe_div(num: np.ndarray, den: np.ndarray) -> np.ndarray:
|
|
79
|
+
"""Element-wise ``num / den`` with 0 where ``den == 0``."""
|
|
80
|
+
num = np.asarray(num, dtype=float)
|
|
81
|
+
den = np.asarray(den, dtype=float)
|
|
82
|
+
out = np.zeros(np.broadcast(num, den).shape, dtype=float)
|
|
83
|
+
np.divide(num, den, out=out, where=den != 0)
|
|
84
|
+
return out
|
segstats/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
segstats/aspatial.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Aspatial evenness and exposure indices (Massey & Denton 1988, dimensions 1-2).
|
|
2
|
+
|
|
3
|
+
Notation follows Massey & Denton (1988): for zone ``i``, ``x_i`` is the count of the
|
|
4
|
+
group of interest ("minority"), ``t_i`` the zone total, ``y_i = t_i - x_i`` the rest
|
|
5
|
+
of the population, ``p_i = x_i / t_i``; capitals are city-wide totals and
|
|
6
|
+
``P = X / T``. Zones with ``t_i = 0`` have no weight in any index.
|
|
7
|
+
|
|
8
|
+
Multigroup forms follow Reardon & Firebaugh (2002): with ``M`` groups, ``pi_m`` is
|
|
9
|
+
the city-wide share of group ``m`` and ``pi_im`` its share in zone ``i``.
|
|
10
|
+
|
|
11
|
+
References
|
|
12
|
+
----------
|
|
13
|
+
- Massey, D. S. & Denton, N. A. (1988). The dimensions of residential segregation.
|
|
14
|
+
*Social Forces* 67(2), 281-315. https://doi.org/10.2307/2579183
|
|
15
|
+
- Duncan, O. D. & Duncan, B. (1955). A methodological analysis of segregation
|
|
16
|
+
indexes. *American Sociological Review* 20(2), 210-217.
|
|
17
|
+
- Atkinson, A. B. (1970). On the measurement of inequality. *Journal of Economic
|
|
18
|
+
Theory* 2(3), 244-263.
|
|
19
|
+
- Theil, H. & Finizza, A. J. (1971). A note on the measurement of racial integration
|
|
20
|
+
of schools by means of informational concepts. *Journal of Mathematical Sociology*
|
|
21
|
+
1(2), 187-193.
|
|
22
|
+
- Reardon, S. F. & Firebaugh, G. (2002). Measures of multigroup segregation.
|
|
23
|
+
*Sociological Methodology* 32, 33-67. https://doi.org/10.1111/1467-9531.00110
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from ._arrays import as_counts, safe_div, two_group
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"dissimilarity",
|
|
36
|
+
"gini",
|
|
37
|
+
"entropy",
|
|
38
|
+
"atkinson",
|
|
39
|
+
"isolation",
|
|
40
|
+
"interaction",
|
|
41
|
+
"correlation_ratio",
|
|
42
|
+
"multigroup_dissimilarity",
|
|
43
|
+
"multigroup_entropy",
|
|
44
|
+
"exposure_matrix",
|
|
45
|
+
"shannon_entropy",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def shannon_entropy(shares: Any, axis: int = -1) -> np.ndarray:
|
|
50
|
+
"""Shannon entropy ``-sum(s ln s)`` (natural log) with the convention ``0 ln 0 = 0``."""
|
|
51
|
+
s = np.asarray(shares, dtype=float)
|
|
52
|
+
logs = np.zeros_like(s)
|
|
53
|
+
np.log(s, out=logs, where=s > 0)
|
|
54
|
+
return np.asarray(-(s * logs).sum(axis=axis))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------- evenness
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def dissimilarity(x: Any, t: Any) -> float:
|
|
61
|
+
"""Index of dissimilarity ``D`` (Duncan & Duncan 1955; Massey & Denton 1988 eq. 1).
|
|
62
|
+
|
|
63
|
+
``D = 1/2 * sum_i |x_i / X - y_i / Y|``, the share of either group that would have
|
|
64
|
+
to move to reach an even distribution. 0 = even, 1 = complete segregation.
|
|
65
|
+
"""
|
|
66
|
+
xa, ta = two_group(x, t)
|
|
67
|
+
ya = ta - xa
|
|
68
|
+
return float(0.5 * np.abs(xa / xa.sum() - ya / ya.sum()).sum())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def gini(x: Any, t: Any) -> float:
|
|
72
|
+
"""Gini coefficient of segregation (Massey & Denton 1988 eq. 2).
|
|
73
|
+
|
|
74
|
+
``G = sum_i sum_j t_i t_j |p_i - p_j| / (2 T^2 P (1 - P))``; computed in
|
|
75
|
+
``O(n log n)`` from the sorted zone proportions rather than the full pair matrix.
|
|
76
|
+
"""
|
|
77
|
+
xa, ta = two_group(x, t)
|
|
78
|
+
big_t = ta.sum()
|
|
79
|
+
big_p = xa.sum() / big_t
|
|
80
|
+
p = safe_div(xa, ta)
|
|
81
|
+
order = np.argsort(p, kind="stable")
|
|
82
|
+
ps, ts = p[order], ta[order]
|
|
83
|
+
cum_t = np.concatenate(([0.0], np.cumsum(ts)[:-1]))
|
|
84
|
+
cum_tp = np.concatenate(([0.0], np.cumsum(ts * ps)[:-1]))
|
|
85
|
+
pair_sum = 2.0 * float((ts * (ps * cum_t - cum_tp)).sum()) # ordered pairs
|
|
86
|
+
return pair_sum / (2.0 * big_t**2 * big_p * (1.0 - big_p))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def entropy(x: Any, t: Any) -> float:
|
|
90
|
+
"""Two-group entropy (information theory) index ``H`` (Massey & Denton 1988 eq. 3).
|
|
91
|
+
|
|
92
|
+
``H = sum_i t_i (E - E_i) / (E T)`` with ``E = P ln(1/P) + (1-P) ln(1/(1-P))``.
|
|
93
|
+
Equal to :func:`multigroup_entropy` applied to ``[x, t - x]``.
|
|
94
|
+
"""
|
|
95
|
+
xa, ta = two_group(x, t)
|
|
96
|
+
return multigroup_entropy(np.column_stack([xa, ta - xa]))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def atkinson(x: Any, t: Any, b: float = 0.5) -> float:
|
|
100
|
+
"""Atkinson index ``A_b`` (Atkinson 1970; Massey & Denton 1988 eq. 4).
|
|
101
|
+
|
|
102
|
+
``A = 1 - P/(1-P) * |sum_i (1-p_i)^(1-b) p_i^b t_i / (P T)|^(1/(1-b))``.
|
|
103
|
+
The shape parameter ``0 < b < 1`` weights zones where the group is
|
|
104
|
+
under-represented (``b < 0.5``) or over-represented (``b > 0.5``).
|
|
105
|
+
"""
|
|
106
|
+
if not 0.0 < b < 1.0:
|
|
107
|
+
raise ValueError("Atkinson shape parameter b must lie strictly between 0 and 1")
|
|
108
|
+
xa, ta = two_group(x, t)
|
|
109
|
+
big_t = ta.sum()
|
|
110
|
+
big_p = xa.sum() / big_t
|
|
111
|
+
p = safe_div(xa, ta)
|
|
112
|
+
inner = ((1.0 - p) ** (1.0 - b) * p**b * ta).sum() / (big_p * big_t)
|
|
113
|
+
return float(1.0 - big_p / (1.0 - big_p) * abs(inner) ** (1.0 / (1.0 - b)))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --------------------------------------------------------------------------- exposure
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def isolation(x: Any, t: Any) -> float:
|
|
120
|
+
"""Isolation index ``xPx`` (Massey & Denton 1988 eq. 7).
|
|
121
|
+
|
|
122
|
+
``xPx = sum_i (x_i / X)(x_i / t_i)``: the group share in the zone of the average
|
|
123
|
+
group member. Its lower bound is ``P`` (not 0), so compare with
|
|
124
|
+
:func:`correlation_ratio` when groups differ in size.
|
|
125
|
+
"""
|
|
126
|
+
xa, ta = two_group(x, t)
|
|
127
|
+
return float((xa / xa.sum() * safe_div(xa, ta)).sum())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def interaction(x: Any, t: Any) -> float:
|
|
131
|
+
"""Interaction (exposure) index ``xPy`` (Massey & Denton 1988 eq. 6).
|
|
132
|
+
|
|
133
|
+
``xPy = sum_i (x_i / X)(y_i / t_i)``. In the two-group case ``xPy + xPx = 1``.
|
|
134
|
+
"""
|
|
135
|
+
xa, ta = two_group(x, t)
|
|
136
|
+
return float((xa / xa.sum() * safe_div(ta - xa, ta)).sum())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def correlation_ratio(x: Any, t: Any) -> float:
|
|
140
|
+
"""Correlation ratio, eta squared ``V = (xPx - P) / (1 - P)`` (Massey & Denton 1988 eq. 8).
|
|
141
|
+
|
|
142
|
+
Isolation corrected for group size; 0 = no excess isolation, 1 = complete
|
|
143
|
+
segregation (Bell 1954; White 1986).
|
|
144
|
+
"""
|
|
145
|
+
xa, ta = two_group(x, t)
|
|
146
|
+
big_p = xa.sum() / ta.sum()
|
|
147
|
+
return (isolation(xa, ta) - big_p) / (1.0 - big_p)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------- multigroup
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _shares(counts: Any) -> "tuple[np.ndarray, np.ndarray, np.ndarray, float]":
|
|
154
|
+
c = as_counts(counts, min_groups=2)
|
|
155
|
+
t = c.sum(axis=1)
|
|
156
|
+
big_t = float(t.sum())
|
|
157
|
+
pi = c.sum(axis=0) / big_t
|
|
158
|
+
pi_i = safe_div(c, t[:, None])
|
|
159
|
+
return t, pi, pi_i, big_t
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def multigroup_dissimilarity(counts: Any) -> float:
|
|
163
|
+
"""Multigroup dissimilarity ``D`` (Reardon & Firebaugh 2002, eq. for D; Morgan 1975).
|
|
164
|
+
|
|
165
|
+
``D = sum_m sum_i t_i |pi_im - pi_m| / (2 T I)`` with the interaction (Simpson)
|
|
166
|
+
index ``I = sum_m pi_m (1 - pi_m)``. Reduces to :func:`dissimilarity` for two
|
|
167
|
+
groups.
|
|
168
|
+
"""
|
|
169
|
+
t, pi, pi_i, big_t = _shares(counts)
|
|
170
|
+
simpson = float((pi * (1.0 - pi)).sum())
|
|
171
|
+
if simpson <= 0:
|
|
172
|
+
raise ValueError("only one group is present; multigroup D undefined")
|
|
173
|
+
return float((t[:, None] * np.abs(pi_i - pi)).sum() / (2.0 * big_t * simpson))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def multigroup_entropy(counts: Any) -> float:
|
|
177
|
+
"""Multigroup information theory index (Theil's ``H``; Reardon & Firebaugh 2002).
|
|
178
|
+
|
|
179
|
+
``H = sum_i t_i (E - E_i) / (T E)`` with ``E = -sum_m pi_m ln pi_m`` and
|
|
180
|
+
``E_i = -sum_m pi_im ln pi_im``. 0 = every zone mirrors the city composition,
|
|
181
|
+
1 = every zone holds a single group.
|
|
182
|
+
"""
|
|
183
|
+
t, pi, pi_i, big_t = _shares(counts)
|
|
184
|
+
big_e = float(shannon_entropy(pi))
|
|
185
|
+
if big_e <= 0:
|
|
186
|
+
raise ValueError("only one group is present; entropy index undefined")
|
|
187
|
+
e_i = shannon_entropy(pi_i, axis=1)
|
|
188
|
+
return float((t * (big_e - e_i)).sum() / (big_t * big_e))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def exposure_matrix(counts: Any) -> np.ndarray:
|
|
192
|
+
"""Matrix of exposure indices ``mPn = sum_i (x_im / X_m)(x_in / t_i)``.
|
|
193
|
+
|
|
194
|
+
Entry ``[m, n]`` is the average share of group ``n`` in the zone of a member of
|
|
195
|
+
group ``m`` (Massey & Denton 1988 eq. 6 generalised; Bell 1954). The diagonal holds
|
|
196
|
+
the isolation indices and each row sums to 1.
|
|
197
|
+
"""
|
|
198
|
+
c = as_counts(counts)
|
|
199
|
+
t = c.sum(axis=1)
|
|
200
|
+
totals = c.sum(axis=0)
|
|
201
|
+
weights = safe_div(c, totals[None, :]) # x_im / X_m
|
|
202
|
+
shares = safe_div(c, t[:, None]) # x_in / t_i
|
|
203
|
+
return np.asarray(weights.T @ shares)
|
segstats/cli.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Command-line interface: ``segstats indices|local|list``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .data import ZoneData, read_csv, read_geojson
|
|
14
|
+
from .indices import INDICES, IndexOptions, profile
|
|
15
|
+
from .inference import bootstrap_ci, permutation_test
|
|
16
|
+
from .local import local_entropy, local_spatial_entropy, location_quotient
|
|
17
|
+
from .weights import kernel_weights, with_self
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _split(value: Optional[str]) -> Optional[List[str]]:
|
|
21
|
+
if not value:
|
|
22
|
+
return None
|
|
23
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load(args: argparse.Namespace) -> ZoneData:
|
|
27
|
+
groups = _split(args.groups)
|
|
28
|
+
src = str(args.counts)
|
|
29
|
+
is_geo = src.lower().endswith((".geojson", ".json"))
|
|
30
|
+
if is_geo:
|
|
31
|
+
return read_geojson(
|
|
32
|
+
src, args.id, groups, args.total, project=args.project
|
|
33
|
+
) # counts from properties
|
|
34
|
+
data = read_csv(src, args.id, groups, args.total, delimiter=args.delimiter)
|
|
35
|
+
if args.geojson:
|
|
36
|
+
return read_geojson(args.geojson, args.geo_id or args.id, project=args.project, counts=data)
|
|
37
|
+
return data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _options(args: argparse.Namespace) -> IndexOptions:
|
|
41
|
+
center = None
|
|
42
|
+
if args.center:
|
|
43
|
+
parts = [float(v) for v in args.center.split(",")]
|
|
44
|
+
if len(parts) != 2:
|
|
45
|
+
raise SystemExit("--center expects X,Y")
|
|
46
|
+
center = parts
|
|
47
|
+
return IndexOptions(
|
|
48
|
+
b=args.atkinson_b,
|
|
49
|
+
center=center,
|
|
50
|
+
decay_scale=args.decay_scale,
|
|
51
|
+
clustering=args.clustering,
|
|
52
|
+
bandwidth=args.bandwidth,
|
|
53
|
+
kernel=args.kernel,
|
|
54
|
+
contiguity=args.contiguity,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fmt(v: Any) -> str:
|
|
59
|
+
if isinstance(v, float):
|
|
60
|
+
return "nan" if math.isnan(v) else f"{v:.6g}"
|
|
61
|
+
return str(v)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _emit(rows: List[Dict[str, Any]], fmt: str, out: Any) -> None:
|
|
65
|
+
if fmt == "json":
|
|
66
|
+
json.dump(rows, out, indent=2, allow_nan=True)
|
|
67
|
+
out.write("\n")
|
|
68
|
+
return
|
|
69
|
+
keys: List[str] = []
|
|
70
|
+
for r in rows:
|
|
71
|
+
for k in r:
|
|
72
|
+
if k not in keys:
|
|
73
|
+
keys.append(k)
|
|
74
|
+
if fmt == "csv":
|
|
75
|
+
w = csv.DictWriter(out, fieldnames=keys, lineterminator="\n")
|
|
76
|
+
w.writeheader()
|
|
77
|
+
w.writerows(rows)
|
|
78
|
+
return
|
|
79
|
+
table = [keys] + [[_fmt(r.get(k, "")) for k in keys] for r in rows]
|
|
80
|
+
widths = [max(len(row[i]) for row in table) for i in range(len(keys))]
|
|
81
|
+
for line_no, row in enumerate(table):
|
|
82
|
+
out.write(" ".join(c.ljust(w) for c, w in zip(row, widths)).rstrip() + "\n")
|
|
83
|
+
if line_no == 0:
|
|
84
|
+
out.write(" ".join("-" * w for w in widths) + "\n")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cmd_indices(args: argparse.Namespace) -> int:
|
|
88
|
+
data = _load(args)
|
|
89
|
+
opts = _options(args)
|
|
90
|
+
group = args.group
|
|
91
|
+
names = _split(args.index)
|
|
92
|
+
values = profile(data, group, names, opts)
|
|
93
|
+
rows: List[Dict[str, Any]] = []
|
|
94
|
+
for name, value in values.items():
|
|
95
|
+
spec = INDICES[name]
|
|
96
|
+
row: Dict[str, Any] = {"index": name, "label": spec.label, "dimension": spec.dimension}
|
|
97
|
+
row["value"] = value
|
|
98
|
+
if args.permutations:
|
|
99
|
+
pt = permutation_test(
|
|
100
|
+
data, name, group, args.permutations, args.null, seed=args.seed, options=opts
|
|
101
|
+
)
|
|
102
|
+
row.update(null_mean=pt.null_mean, p_value=pt.p_value)
|
|
103
|
+
if args.bootstrap:
|
|
104
|
+
bs = bootstrap_ci(
|
|
105
|
+
data, name, group, args.bootstrap, args.level, seed=args.seed, options=opts
|
|
106
|
+
)
|
|
107
|
+
row.update(ci_low=bs.ci_low, ci_high=bs.ci_high)
|
|
108
|
+
rows.append(row)
|
|
109
|
+
_emit(rows, args.format, sys.stdout)
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cmd_local(args: argparse.Namespace) -> int:
|
|
114
|
+
data = _load(args)
|
|
115
|
+
lq = location_quotient(data.counts)
|
|
116
|
+
ent = local_entropy(data.counts)
|
|
117
|
+
spatial = None
|
|
118
|
+
if data.geometry is not None:
|
|
119
|
+
if args.bandwidth:
|
|
120
|
+
w = kernel_weights(data.geometry.distances(), args.bandwidth, args.kernel)
|
|
121
|
+
else:
|
|
122
|
+
w = with_self(data.geometry.contiguity(args.contiguity))
|
|
123
|
+
spatial = local_spatial_entropy(data.counts, w)
|
|
124
|
+
rows: List[Dict[str, Any]] = []
|
|
125
|
+
for i, zid in enumerate(data.ids or []):
|
|
126
|
+
row: Dict[str, Any] = {"id": zid, "total": float(data.totals[i])}
|
|
127
|
+
for j, g in enumerate(data.groups):
|
|
128
|
+
row[f"lq_{g}"] = float(lq[i, j])
|
|
129
|
+
row["entropy"] = float(ent[i])
|
|
130
|
+
if spatial is not None:
|
|
131
|
+
row["spatial_entropy"] = float(spatial[i])
|
|
132
|
+
rows.append(row)
|
|
133
|
+
if args.output:
|
|
134
|
+
with open(args.output, "w", encoding="utf-8", newline="") as fh:
|
|
135
|
+
_emit(rows, args.format if args.format != "table" else "csv", fh)
|
|
136
|
+
else:
|
|
137
|
+
_emit(rows, args.format, sys.stdout)
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cmd_list(args: argparse.Namespace) -> int:
|
|
142
|
+
rows = [
|
|
143
|
+
{
|
|
144
|
+
"index": s.name,
|
|
145
|
+
"label": s.label,
|
|
146
|
+
"dimension": s.dimension,
|
|
147
|
+
"groups": "multi" if s.multigroup else "two",
|
|
148
|
+
"needs": ",".join(s.needs) or "-",
|
|
149
|
+
"reference": s.reference,
|
|
150
|
+
}
|
|
151
|
+
for s in INDICES.values()
|
|
152
|
+
]
|
|
153
|
+
_emit(rows, args.format, sys.stdout)
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _add_input(p: argparse.ArgumentParser) -> None:
|
|
158
|
+
p.add_argument("counts", help="CSV count table or GeoJSON with counts in properties")
|
|
159
|
+
p.add_argument("--id", help="zone id column / property")
|
|
160
|
+
p.add_argument("--groups", help="comma-separated group columns (default: all numeric)")
|
|
161
|
+
p.add_argument("--total", help="total column; adds an 'other' group for the remainder")
|
|
162
|
+
p.add_argument("--delimiter", default=",", help="CSV delimiter (default ',')")
|
|
163
|
+
p.add_argument("--geojson", help="zone polygons to join to a CSV table by id")
|
|
164
|
+
p.add_argument("--geo-id", help="id property in --geojson (default: --id)")
|
|
165
|
+
p.add_argument(
|
|
166
|
+
"--project",
|
|
167
|
+
choices=["auto", "lonlat", "none"],
|
|
168
|
+
default="auto",
|
|
169
|
+
help="project lon/lat coordinates to metres (default: auto-detect)",
|
|
170
|
+
)
|
|
171
|
+
p.add_argument("--bandwidth", type=float, help="kernel bandwidth for spatial H/D")
|
|
172
|
+
p.add_argument("--kernel", default="gaussian", help="gaussian|exponential|biweight|box")
|
|
173
|
+
p.add_argument("--contiguity", choices=["queen", "rook"], default="queen")
|
|
174
|
+
p.add_argument("--format", choices=["table", "json", "csv"], default="table")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
178
|
+
"""Argument parser for the ``segstats`` command."""
|
|
179
|
+
parser = argparse.ArgumentParser(
|
|
180
|
+
prog="segstats",
|
|
181
|
+
description="Residential segregation and diversity indices for urban zones.",
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument("--version", action="version", version=f"segstats {__version__}")
|
|
184
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
185
|
+
|
|
186
|
+
p = sub.add_parser("indices", help="compute global segregation indices")
|
|
187
|
+
_add_input(p)
|
|
188
|
+
p.add_argument("--group", help="group of interest for two-group indices")
|
|
189
|
+
p.add_argument("--index", help="comma-separated index names (default: all available)")
|
|
190
|
+
p.add_argument("--center", help="centre X,Y for centralization (input coordinates)")
|
|
191
|
+
p.add_argument("--decay-scale", type=float, default=1000.0, help="distance unit of exp(-d)")
|
|
192
|
+
p.add_argument("--clustering", choices=["proximity", "contiguity"], default="proximity")
|
|
193
|
+
p.add_argument("--atkinson-b", type=float, default=0.5)
|
|
194
|
+
p.add_argument("--permutations", type=int, default=0, help="random-allocation test runs")
|
|
195
|
+
p.add_argument("--null", choices=["labels", "zones"], default="labels")
|
|
196
|
+
p.add_argument("--bootstrap", type=int, default=0, help="bootstrap resamples for CIs")
|
|
197
|
+
p.add_argument("--level", type=float, default=0.95, help="confidence level")
|
|
198
|
+
p.add_argument("--seed", type=int, help="random seed")
|
|
199
|
+
p.set_defaults(func=_cmd_indices)
|
|
200
|
+
|
|
201
|
+
p = sub.add_parser("local", help="per-zone location quotients and entropy")
|
|
202
|
+
_add_input(p)
|
|
203
|
+
p.add_argument("-o", "--output", help="write to this file instead of stdout")
|
|
204
|
+
p.set_defaults(func=_cmd_local)
|
|
205
|
+
|
|
206
|
+
p = sub.add_parser("list", help="list available indices")
|
|
207
|
+
p.add_argument("--format", choices=["table", "json", "csv"], default="table")
|
|
208
|
+
p.set_defaults(func=_cmd_list)
|
|
209
|
+
return parser
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
213
|
+
"""Entry point; returns the process exit code."""
|
|
214
|
+
parser = build_parser()
|
|
215
|
+
args = parser.parse_args(argv)
|
|
216
|
+
try:
|
|
217
|
+
return int(args.func(args))
|
|
218
|
+
except (ValueError, KeyError, OSError) as exc:
|
|
219
|
+
print(f"segstats: error: {exc}", file=sys.stderr)
|
|
220
|
+
return 2
|