retailgravity 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.
@@ -0,0 +1,120 @@
1
+ """retailgravity: spatial interaction and retail catchment models for site analysis.
2
+
3
+ Quick start::
4
+
5
+ from retailgravity import huff, distance_matrix
6
+
7
+ d = distance_matrix(origins_lonlat, stores_lonlat) # km
8
+ res = huff(d, attractiveness=[5000, 12000, 3000], demand=population, beta=2)
9
+ res.probabilities # (origins, stores), rows sum to 1
10
+ res.sales, res.market_share
11
+
12
+ Modules: :mod:`~retailgravity.huff` (Huff), :mod:`~retailgravity.mci` (MCI and its
13
+ log-centering calibration), :mod:`~retailgravity.reilly` (Reilly / Converse),
14
+ :mod:`~retailgravity.wilson` (Wilson family, Furness balancing),
15
+ :mod:`~retailgravity.calibrate` (distance-decay calibration),
16
+ :mod:`~retailgravity.tradearea` (trade areas) and :mod:`~retailgravity.scenario`
17
+ (what-if analysis and cannibalisation).
18
+ """
19
+
20
+ from ._arrays import deterrence
21
+ from ._version import __version__
22
+ from .calibrate import (
23
+ CalibrationResult,
24
+ calibrate_beta,
25
+ goodness_of_fit,
26
+ poisson_log_likelihood,
27
+ )
28
+ from .distance import (
29
+ EARTH_RADIUS_KM,
30
+ distance_matrix,
31
+ euclidean_matrix,
32
+ haversine_matrix,
33
+ interpolate,
34
+ )
35
+ from .huff import HuffResult, huff, huff_probabilities, utilities
36
+ from .io import (
37
+ Location,
38
+ attribute_array,
39
+ coordinates,
40
+ locations_from_records,
41
+ read_locations,
42
+ write_csv,
43
+ )
44
+ from .mci import HuffFit, MCIFit, fit_huff, fit_mci, mci_probabilities
45
+ from .reilly import (
46
+ BreakingPoint,
47
+ breaking_point,
48
+ breaking_point_location,
49
+ breaking_points,
50
+ reilly_ratio,
51
+ reilly_shares,
52
+ )
53
+ from .scenario import Market, ScenarioComparison, Store, compare
54
+ from .tradearea import (
55
+ DEFAULT_CUMULATIVE_LEVELS,
56
+ ProbabilitySurface,
57
+ contour_sets,
58
+ cumulative_trade_area,
59
+ dominant_store,
60
+ probability_bands,
61
+ probability_surface,
62
+ )
63
+ from .wilson import (
64
+ ConvergenceWarning,
65
+ SIResult,
66
+ furness,
67
+ mean_cost,
68
+ spatial_interaction,
69
+ )
70
+
71
+ __all__ = [
72
+ "__version__",
73
+ "EARTH_RADIUS_KM",
74
+ "DEFAULT_CUMULATIVE_LEVELS",
75
+ "BreakingPoint",
76
+ "CalibrationResult",
77
+ "ConvergenceWarning",
78
+ "HuffFit",
79
+ "HuffResult",
80
+ "Location",
81
+ "MCIFit",
82
+ "Market",
83
+ "ProbabilitySurface",
84
+ "SIResult",
85
+ "ScenarioComparison",
86
+ "Store",
87
+ "attribute_array",
88
+ "breaking_point",
89
+ "breaking_point_location",
90
+ "breaking_points",
91
+ "calibrate_beta",
92
+ "compare",
93
+ "contour_sets",
94
+ "coordinates",
95
+ "cumulative_trade_area",
96
+ "deterrence",
97
+ "distance_matrix",
98
+ "dominant_store",
99
+ "euclidean_matrix",
100
+ "fit_huff",
101
+ "fit_mci",
102
+ "furness",
103
+ "goodness_of_fit",
104
+ "haversine_matrix",
105
+ "huff",
106
+ "huff_probabilities",
107
+ "interpolate",
108
+ "locations_from_records",
109
+ "mci_probabilities",
110
+ "mean_cost",
111
+ "poisson_log_likelihood",
112
+ "probability_bands",
113
+ "probability_surface",
114
+ "read_locations",
115
+ "reilly_ratio",
116
+ "reilly_shares",
117
+ "spatial_interaction",
118
+ "utilities",
119
+ "write_csv",
120
+ ]
@@ -0,0 +1,7 @@
1
+ """``python -m retailgravity``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,80 @@
1
+ """Small array helpers shared by the models (internal)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+
10
+ FloatArray = npt.NDArray[np.float64]
11
+ IntArray = npt.NDArray[np.intp]
12
+
13
+ DETERRENCE_KINDS = ("power", "exponential")
14
+
15
+
16
+ def as_matrix(values: Any, name: str) -> FloatArray:
17
+ """Return ``values`` as a 2-D float array or raise ``ValueError``."""
18
+ arr = np.asarray(values, dtype=float)
19
+ if arr.ndim != 2:
20
+ raise ValueError(f"{name} must be a 2-D matrix, got shape {arr.shape}")
21
+ if arr.shape[0] == 0 or arr.shape[1] == 0:
22
+ raise ValueError(f"{name} must not be empty")
23
+ if np.isnan(arr).any():
24
+ raise ValueError(f"{name} contains NaN")
25
+ return arr
26
+
27
+
28
+ def as_vector(values: Any, name: str, length: int | None = None) -> FloatArray:
29
+ """Return ``values`` as a 1-D float array (optionally of a given length)."""
30
+ arr = np.asarray(values, dtype=float)
31
+ if arr.ndim == 0:
32
+ arr = arr.reshape(1)
33
+ if arr.ndim != 1:
34
+ raise ValueError(f"{name} must be 1-D, got shape {arr.shape}")
35
+ if length is not None and arr.shape[0] != length:
36
+ raise ValueError(f"{name} must have length {length}, got {arr.shape[0]}")
37
+ if not np.isfinite(arr).all():
38
+ raise ValueError(f"{name} must be finite")
39
+ return arr
40
+
41
+
42
+ def deterrence(
43
+ cost: Any,
44
+ beta: float,
45
+ kind: str = "power",
46
+ *,
47
+ min_cost: float = 0.0,
48
+ ) -> FloatArray:
49
+ """Distance-deterrence function ``f(c)``.
50
+
51
+ ``kind="power"`` gives ``f(c) = c ** -beta`` (Huff 1963; Wilson 1971, "gravity" form) and
52
+ ``kind="exponential"`` gives ``f(c) = exp(-beta * c)`` (Wilson 1971, "entropy" form).
53
+
54
+ An infinite cost means "not reachable / not in the choice set" and yields ``f = 0``.
55
+ Costs below ``min_cost`` are raised to ``min_cost`` first. With the power form a zero
56
+ cost is undefined (``0 ** -beta``); pass a small positive ``min_cost`` (for example a
57
+ half grid cell or an intra-zonal distance) in that case.
58
+ """
59
+ if kind not in DETERRENCE_KINDS:
60
+ raise ValueError(f"deterrence must be one of {DETERRENCE_KINDS}, got {kind!r}")
61
+ c = np.asarray(cost, dtype=float)
62
+ if np.isnan(c).any():
63
+ raise ValueError("cost contains NaN")
64
+ if (c < 0).any():
65
+ raise ValueError("cost must be non-negative")
66
+ if min_cost < 0:
67
+ raise ValueError("min_cost must be non-negative")
68
+ c = np.maximum(c, min_cost)
69
+ finite = np.isfinite(c)
70
+ out = np.zeros_like(c, dtype=float)
71
+ if kind == "power":
72
+ if (c[finite] == 0).any() and beta > 0:
73
+ raise ValueError(
74
+ "zero cost/distance with power deterrence; pass min_cost/min_distance > 0"
75
+ )
76
+ with np.errstate(divide="ignore"):
77
+ out[finite] = np.power(c[finite], -float(beta))
78
+ else:
79
+ out[finite] = np.exp(-float(beta) * c[finite])
80
+ return out
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,279 @@
1
+ """Calibration of the distance-decay parameter ``beta`` to observed flows.
2
+
3
+ For every member of Wilson's family (with the balancing factors, or ``k`` fitted to the
4
+ observed total, treated as free) the Poisson maximum-likelihood estimate of ``beta``
5
+ satisfies a single moment condition: the modelled flows must reproduce the observed
6
+ flow-weighted mean of the deterrence statistic ``g(c)``,
7
+
8
+ .. math::
9
+
10
+ \\frac{\\sum_{ij} \\hat T_{ij}(\\beta)\\, g(c_{ij})}{\\sum \\hat T_{ij}} =
11
+ \\frac{\\sum_{ij} T_{ij}\\, g(c_{ij})}{\\sum T_{ij}},
12
+
13
+ with ``g(c) = c`` for exponential and ``g(c) = ln c`` for power deterrence (Batty &
14
+ Mackie 1972; Fotheringham & O'Kelly 1989, ch. 3). ``method="mle"`` solves that equation;
15
+ ``method="mean_trip_length"`` always matches the mean cost ``g(c) = c`` (the classic
16
+ Hyman 1969 criterion), which coincides with the MLE for exponential deterrence. The
17
+ modelled statistic decreases monotonically in ``beta``, so the root is bracketed and
18
+ found by bisection.
19
+
20
+ References
21
+ ----------
22
+ * Hyman, G. M. (1969). The calibration of trip distribution models. *Environment and
23
+ Planning*, 1(1), 105-112.
24
+ * Batty, M., & Mackie, S. (1972). The calibration of gravity, entropy, and related
25
+ models of spatial interaction. *Environment and Planning*, 4(2), 205-233.
26
+ * Knudsen, D. C., & Fotheringham, A. S. (1986). Matrix comparison, goodness-of-fit, and
27
+ spatial interaction modeling. *International Regional Science Review*, 10(2), 127-147.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import math
33
+ from dataclasses import dataclass
34
+ from typing import Any, Dict, Optional, Tuple
35
+
36
+ import numpy as np
37
+
38
+ from ._arrays import FloatArray, as_matrix
39
+ from .wilson import SIResult, spatial_interaction
40
+
41
+ METHODS = ("mle", "mean_trip_length")
42
+
43
+
44
+ @dataclass
45
+ class CalibrationResult:
46
+ """Outcome of :func:`calibrate_beta`."""
47
+
48
+ beta: float
49
+ model: SIResult
50
+ method: str
51
+ statistic: str
52
+ observed_statistic: float
53
+ modelled_statistic: float
54
+ observed_mean_cost: float
55
+ modelled_mean_cost: float
56
+ log_likelihood: float
57
+ r_squared: float
58
+ srmse: float
59
+ iterations: int
60
+
61
+ def as_dict(self) -> Dict[str, Any]:
62
+ return {
63
+ "beta": self.beta,
64
+ "constraint": self.model.constraint,
65
+ "deterrence": self.model.deterrence,
66
+ "method": self.method,
67
+ "statistic": self.statistic,
68
+ "observed_statistic": self.observed_statistic,
69
+ "modelled_statistic": self.modelled_statistic,
70
+ "observed_mean_cost": self.observed_mean_cost,
71
+ "modelled_mean_cost": self.modelled_mean_cost,
72
+ "log_likelihood": self.log_likelihood,
73
+ "r_squared": self.r_squared,
74
+ "srmse": self.srmse,
75
+ "iterations": self.iterations,
76
+ }
77
+
78
+
79
+ def poisson_log_likelihood(observed: Any, predicted: Any) -> float:
80
+ """Poisson log-likelihood ``sum(T ln T_hat - T_hat - ln T!)`` over all cells.
81
+
82
+ Cells with ``T_hat = 0`` contribute 0 when ``T = 0`` and ``-inf`` otherwise.
83
+ """
84
+ t = np.asarray(observed, dtype=float)
85
+ p = np.asarray(predicted, dtype=float)
86
+ pos = t > 0
87
+ if (pos & (p <= 0)).any():
88
+ return -math.inf
89
+ ll = float((t[pos] * np.log(p[pos])).sum() - p.sum())
90
+ lgamma = np.vectorize(math.lgamma)
91
+ return ll - float(lgamma(t[pos] + 1.0).sum())
92
+
93
+
94
+ def goodness_of_fit(observed: Any, predicted: Any) -> Tuple[float, float]:
95
+ """``(r_squared, srmse)``: squared Pearson correlation and the standardised root mean
96
+ square error ``sqrt(sum (T - T_hat)^2 / nm) / (sum T / nm)`` (Knudsen & Fotheringham
97
+ 1986)."""
98
+ t = np.asarray(observed, dtype=float).ravel()
99
+ p = np.asarray(predicted, dtype=float).ravel()
100
+ nm = t.size
101
+ mean_t = t.sum() / nm
102
+ srmse = float(math.sqrt(((t - p) ** 2).sum() / nm) / mean_t) if mean_t > 0 else math.nan
103
+ if t.std() == 0 or p.std() == 0:
104
+ r2 = math.nan
105
+ else:
106
+ r2 = float(np.corrcoef(t, p)[0, 1] ** 2)
107
+ return r2, srmse
108
+
109
+
110
+ def _stat(flows: FloatArray, g: FloatArray, ok: Any) -> float:
111
+ tot = float(flows[ok].sum())
112
+ return float((flows[ok] * g[ok]).sum() / tot)
113
+
114
+
115
+ def calibrate_beta(
116
+ observed: Any,
117
+ cost: Any,
118
+ *,
119
+ constraint: str = "doubly",
120
+ deterrence_kind: str = "exponential",
121
+ method: str = "mle",
122
+ origin_mass: Any = None,
123
+ destination_mass: Any = None,
124
+ mu: float = 1.0,
125
+ alpha: float = 1.0,
126
+ min_cost: float = 0.0,
127
+ lower: float = 0.0,
128
+ upper: Optional[float] = None,
129
+ tol: float = 1e-10,
130
+ max_iter: int = 200,
131
+ ) -> CalibrationResult:
132
+ """Calibrate ``beta`` so that the model reproduces the observed flows' statistic.
133
+
134
+ Parameters
135
+ ----------
136
+ observed:
137
+ ``(n, m)`` observed flows ``T_ij`` (trips, customers, spending).
138
+ cost:
139
+ ``(n, m)`` costs; ``inf`` marks impossible pairs (their flows must be 0).
140
+ constraint:
141
+ Model family member; the constrained margins are taken from ``observed``
142
+ (``O_i`` = row sums, ``D_j`` = column sums). For ``"unconstrained"`` the scale
143
+ ``k`` is set so the modelled total equals the observed total.
144
+ deterrence_kind:
145
+ ``"exponential"`` or ``"power"``.
146
+ method:
147
+ ``"mle"`` (Poisson maximum likelihood) or ``"mean_trip_length"``.
148
+ origin_mass, destination_mass, mu, alpha:
149
+ Masses ``V_i``, ``W_j`` used by the unconstrained, production- (``W``) and
150
+ attraction-constrained (``V``) models. For a Huff-style retail calibration use
151
+ ``constraint="production"``, ``deterrence_kind="power"`` and store
152
+ attractiveness as ``destination_mass``.
153
+ lower, upper:
154
+ Search interval for ``beta`` (``upper`` is expanded automatically when not
155
+ given). Observed flows that are *less* distance-sensitive than ``beta=lower``
156
+ raise ``ValueError``.
157
+ """
158
+ if method not in METHODS:
159
+ raise ValueError(f"method must be one of {METHODS}, got {method!r}")
160
+ t = as_matrix(observed, "observed")
161
+ if (t < 0).any() or not np.isfinite(t).all():
162
+ raise ValueError("observed flows must be finite and non-negative")
163
+ c = np.maximum(np.asarray(cost, dtype=float), min_cost)
164
+ if c.shape != t.shape:
165
+ raise ValueError(f"cost shape {c.shape} differs from observed shape {t.shape}")
166
+ ok = np.isfinite(c)
167
+ if (t[~ok] > 0).any():
168
+ raise ValueError("observed flow on a pair with infinite cost")
169
+ if deterrence_kind == "power":
170
+ if (c[ok] <= 0).any():
171
+ raise ValueError("power deterrence needs positive costs; pass min_cost")
172
+ # Rescaling c by a constant multiplies f by a constant, which every model
173
+ # absorbs (A_i, B_j or k); it keeps c**-beta in floating-point range.
174
+ c_model = c / c[ok].min()
175
+ elif deterrence_kind == "exponential":
176
+ c_model = c - c[ok].min()
177
+ else:
178
+ raise ValueError(f"unknown deterrence {deterrence_kind!r}")
179
+ if method == "mle" and deterrence_kind == "power":
180
+ g = np.where(ok, np.log(np.where(ok, c, 1.0)), 0.0)
181
+ stat_name = "mean_log_cost"
182
+ else:
183
+ g = np.where(ok, c, 0.0)
184
+ stat_name = "mean_cost"
185
+
186
+ total = float(t.sum())
187
+ kwargs: Dict[str, Any] = {
188
+ "constraint": constraint,
189
+ "deterrence_kind": deterrence_kind,
190
+ "origin_mass": origin_mass,
191
+ "destination_mass": destination_mass,
192
+ "mu": mu,
193
+ "alpha": alpha,
194
+ }
195
+ if constraint in ("production", "doubly"):
196
+ kwargs["outflows"] = t.sum(axis=1)
197
+ if constraint in ("attraction", "doubly"):
198
+ kwargs["inflows"] = t.sum(axis=0)
199
+ if constraint == "unconstrained":
200
+ kwargs["total"] = total
201
+
202
+ def fit(b: float) -> SIResult:
203
+ return spatial_interaction(c_model, beta=b, **kwargs)
204
+
205
+ def model_stat(b: float) -> float:
206
+ try:
207
+ res = fit(b)
208
+ val = _stat(res.flows, g, ok)
209
+ except (ValueError, FloatingPointError, ZeroDivisionError):
210
+ return -math.inf
211
+ return val if math.isfinite(val) else -math.inf
212
+
213
+ target = _stat(t, g, ok)
214
+ lo = float(lower)
215
+ s_lo = model_stat(lo)
216
+ if not math.isfinite(s_lo):
217
+ raise ValueError(f"model cannot be evaluated at beta={lo}")
218
+ if target >= s_lo:
219
+ if target - s_lo <= 1e-12 * max(1.0, abs(target)):
220
+ hi = lo
221
+ else:
222
+ raise ValueError(
223
+ f"observed {stat_name} ({target:.6g}) is not below the model value at "
224
+ f"beta={lo} ({s_lo:.6g}); lower the search bound to allow beta < {lo}"
225
+ )
226
+ if upper is not None:
227
+ hi = float(upper)
228
+ if model_stat(hi) > target:
229
+ raise ValueError(f"beta exceeds upper bound {hi}")
230
+ elif target < s_lo:
231
+ span = (
232
+ float(np.ptp(c_model[ok]))
233
+ if deterrence_kind == "exponential"
234
+ else float(np.log(c_model[ok]).max())
235
+ )
236
+ hi = lo + (1.0 / span if span > 0 else 1.0)
237
+ for _ in range(200):
238
+ if model_stat(hi) <= target:
239
+ break
240
+ hi = lo + 2.0 * (hi - lo)
241
+ else: # pragma: no cover - pathological input
242
+ raise ValueError("could not bracket beta")
243
+ iterations = 0
244
+ if hi > lo:
245
+ for it in range(1, max_iter + 1):
246
+ iterations = it
247
+ mid = 0.5 * (lo + hi)
248
+ if model_stat(mid) > target:
249
+ lo = mid
250
+ else:
251
+ hi = mid
252
+ if hi - lo <= tol * max(1.0, abs(hi)):
253
+ break
254
+ beta = 0.5 * (lo + hi)
255
+ try:
256
+ # Refit on the original costs so A_i, B_j and k refer to them.
257
+ with np.errstate(over="raise", under="ignore"):
258
+ model = spatial_interaction(c, beta=beta, **kwargs)
259
+ if not np.isfinite(model.flows).all() or not np.allclose(
260
+ model.flows, fit(beta).flows, rtol=1e-6, atol=1e-9 * max(total, 1.0)
261
+ ):
262
+ raise FloatingPointError
263
+ except (ValueError, FloatingPointError):
264
+ model = fit(beta) # balancing factors then refer to the rescaled costs
265
+ r2, srmse = goodness_of_fit(t, model.flows)
266
+ return CalibrationResult(
267
+ beta=beta,
268
+ model=model,
269
+ method=method,
270
+ statistic=stat_name,
271
+ observed_statistic=target,
272
+ modelled_statistic=_stat(model.flows, g, ok),
273
+ observed_mean_cost=_stat(t, np.where(ok, c, 0.0), ok),
274
+ modelled_mean_cost=_stat(model.flows, np.where(ok, c, 0.0), ok),
275
+ log_likelihood=poisson_log_likelihood(t, model.flows),
276
+ r_squared=r2,
277
+ srmse=srmse,
278
+ iterations=iterations,
279
+ )