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 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
@@ -0,0 +1,7 @@
1
+ """``python -m segstats``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
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