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.
- retailgravity/__init__.py +120 -0
- retailgravity/__main__.py +7 -0
- retailgravity/_arrays.py +80 -0
- retailgravity/_version.py +1 -0
- retailgravity/calibrate.py +279 -0
- retailgravity/cli.py +349 -0
- retailgravity/distance.py +132 -0
- retailgravity/huff.py +243 -0
- retailgravity/io.py +207 -0
- retailgravity/mci.py +237 -0
- retailgravity/py.typed +0 -0
- retailgravity/reilly.py +188 -0
- retailgravity/scenario.py +304 -0
- retailgravity/tradearea.py +190 -0
- retailgravity/wilson.py +283 -0
- retailgravity-0.1.0.dist-info/METADATA +264 -0
- retailgravity-0.1.0.dist-info/RECORD +21 -0
- retailgravity-0.1.0.dist-info/WHEEL +5 -0
- retailgravity-0.1.0.dist-info/entry_points.txt +2 -0
- retailgravity-0.1.0.dist-info/licenses/LICENSE +21 -0
- retailgravity-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
retailgravity/_arrays.py
ADDED
|
@@ -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
|
+
)
|