tripdist 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.
- tripdist/__init__.py +131 -0
- tripdist/__main__.py +7 -0
- tripdist/_common.py +106 -0
- tripdist/_version.py +1 -0
- tripdist/balancing.py +219 -0
- tripdist/calibration.py +296 -0
- tripdist/cli.py +282 -0
- tripdist/deterrence.py +233 -0
- tripdist/diagnostics.py +138 -0
- tripdist/generation.py +282 -0
- tripdist/gravity.py +280 -0
- tripdist/growth.py +290 -0
- tripdist/io.py +208 -0
- tripdist/py.typed +0 -0
- tripdist/tlfd.py +178 -0
- tripdist-0.1.0.dist-info/METADATA +251 -0
- tripdist-0.1.0.dist-info/RECORD +21 -0
- tripdist-0.1.0.dist-info/WHEEL +5 -0
- tripdist-0.1.0.dist-info/entry_points.txt +2 -0
- tripdist-0.1.0.dist-info/licenses/LICENSE +21 -0
- tripdist-0.1.0.dist-info/top_level.txt +1 -0
tripdist/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""tripdist: trip generation and distribution (stages 1-2 of the four-step model).
|
|
2
|
+
|
|
3
|
+
Trip generation by regression and cross-classification with production/attraction
|
|
4
|
+
balancing; growth-factor methods (uniform, singly constrained, average, Detroit, Fratar,
|
|
5
|
+
Furness); gravity models (unconstrained, production-, attraction- and doubly constrained)
|
|
6
|
+
with exponential, power, combined (Tanner), gamma and tabulated deterrence functions;
|
|
7
|
+
Hyman's (1969) calibration to an observed mean trip cost; trip length frequency
|
|
8
|
+
distributions, K-factors, intrazonal handling and convergence diagnostics. Methods follow
|
|
9
|
+
Ortúzar & Willumsen, *Modelling Transport* (4th ed., 2011).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._common import TripDistError
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .balancing import BalanceResult, furness, margin_error
|
|
15
|
+
from .calibration import (
|
|
16
|
+
CalibrationResult,
|
|
17
|
+
FrictionCalibration,
|
|
18
|
+
calibrate_friction_factors,
|
|
19
|
+
hyman,
|
|
20
|
+
k_factors,
|
|
21
|
+
observed_mean_cost,
|
|
22
|
+
)
|
|
23
|
+
from .deterrence import (
|
|
24
|
+
DETERRENCE_FORMS,
|
|
25
|
+
Deterrence,
|
|
26
|
+
combined,
|
|
27
|
+
deterrence_from_dict,
|
|
28
|
+
exponential,
|
|
29
|
+
gamma,
|
|
30
|
+
power,
|
|
31
|
+
tabular,
|
|
32
|
+
tanner,
|
|
33
|
+
)
|
|
34
|
+
from .diagnostics import MatrixFit, MatrixSummary, aggregate, compare_matrices, geh, summarize
|
|
35
|
+
from .generation import (
|
|
36
|
+
CategoryRates,
|
|
37
|
+
RegressionModel,
|
|
38
|
+
apply_category_rates,
|
|
39
|
+
balance_pa,
|
|
40
|
+
cross_classification_rates,
|
|
41
|
+
fit_regression,
|
|
42
|
+
)
|
|
43
|
+
from .gravity import (
|
|
44
|
+
CONSTRAINTS,
|
|
45
|
+
GravityResult,
|
|
46
|
+
gravity,
|
|
47
|
+
intrazonal_costs,
|
|
48
|
+
intrazonal_share,
|
|
49
|
+
mean_cost,
|
|
50
|
+
)
|
|
51
|
+
from .growth import (
|
|
52
|
+
GROWTH_METHODS,
|
|
53
|
+
GrowthResult,
|
|
54
|
+
average_factor,
|
|
55
|
+
detroit,
|
|
56
|
+
fratar,
|
|
57
|
+
furness_growth,
|
|
58
|
+
growth_factor,
|
|
59
|
+
singly_constrained_growth,
|
|
60
|
+
uniform_growth,
|
|
61
|
+
)
|
|
62
|
+
from .io import read_json, read_long, read_matrix, read_zone_table, write_json, write_matrix
|
|
63
|
+
from .tlfd import TLFD, TLFDComparison, coincidence_ratio, compare_tlfd, cost_bins, tlfd
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"__version__",
|
|
67
|
+
"TripDistError",
|
|
68
|
+
# generation
|
|
69
|
+
"fit_regression",
|
|
70
|
+
"RegressionModel",
|
|
71
|
+
"cross_classification_rates",
|
|
72
|
+
"apply_category_rates",
|
|
73
|
+
"CategoryRates",
|
|
74
|
+
"balance_pa",
|
|
75
|
+
# balancing and growth factors
|
|
76
|
+
"furness",
|
|
77
|
+
"margin_error",
|
|
78
|
+
"BalanceResult",
|
|
79
|
+
"uniform_growth",
|
|
80
|
+
"singly_constrained_growth",
|
|
81
|
+
"average_factor",
|
|
82
|
+
"detroit",
|
|
83
|
+
"fratar",
|
|
84
|
+
"furness_growth",
|
|
85
|
+
"growth_factor",
|
|
86
|
+
"GrowthResult",
|
|
87
|
+
"GROWTH_METHODS",
|
|
88
|
+
# deterrence and gravity
|
|
89
|
+
"Deterrence",
|
|
90
|
+
"exponential",
|
|
91
|
+
"power",
|
|
92
|
+
"combined",
|
|
93
|
+
"tanner",
|
|
94
|
+
"gamma",
|
|
95
|
+
"tabular",
|
|
96
|
+
"deterrence_from_dict",
|
|
97
|
+
"DETERRENCE_FORMS",
|
|
98
|
+
"gravity",
|
|
99
|
+
"GravityResult",
|
|
100
|
+
"CONSTRAINTS",
|
|
101
|
+
"mean_cost",
|
|
102
|
+
"intrazonal_costs",
|
|
103
|
+
"intrazonal_share",
|
|
104
|
+
# calibration
|
|
105
|
+
"hyman",
|
|
106
|
+
"observed_mean_cost",
|
|
107
|
+
"CalibrationResult",
|
|
108
|
+
"calibrate_friction_factors",
|
|
109
|
+
"FrictionCalibration",
|
|
110
|
+
"k_factors",
|
|
111
|
+
# TLFD and diagnostics
|
|
112
|
+
"tlfd",
|
|
113
|
+
"TLFD",
|
|
114
|
+
"compare_tlfd",
|
|
115
|
+
"TLFDComparison",
|
|
116
|
+
"coincidence_ratio",
|
|
117
|
+
"cost_bins",
|
|
118
|
+
"summarize",
|
|
119
|
+
"MatrixSummary",
|
|
120
|
+
"compare_matrices",
|
|
121
|
+
"MatrixFit",
|
|
122
|
+
"geh",
|
|
123
|
+
"aggregate",
|
|
124
|
+
# I/O
|
|
125
|
+
"read_matrix",
|
|
126
|
+
"write_matrix",
|
|
127
|
+
"read_zone_table",
|
|
128
|
+
"read_long",
|
|
129
|
+
"read_json",
|
|
130
|
+
"write_json",
|
|
131
|
+
]
|
tripdist/__main__.py
ADDED
tripdist/_common.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Shared validation helpers and the package exception."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import ArrayLike, NDArray
|
|
10
|
+
|
|
11
|
+
FloatArray = NDArray[np.float64]
|
|
12
|
+
Number = Union[int, float]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TripDistError(ValueError):
|
|
16
|
+
"""Raised for invalid input to a tripdist function."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def as_vector(values: ArrayLike, name: str, *, allow_negative: bool = False) -> FloatArray:
|
|
20
|
+
"""Return ``values`` as a finite 1-D float array or raise :class:`TripDistError`."""
|
|
21
|
+
try:
|
|
22
|
+
arr = np.asarray(values, dtype=np.float64)
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
raise TripDistError(f"{name} must be numeric") from None
|
|
25
|
+
if arr.ndim == 0:
|
|
26
|
+
arr = arr.reshape(1)
|
|
27
|
+
if arr.ndim != 1:
|
|
28
|
+
raise TripDistError(f"{name} must be one-dimensional, got shape {arr.shape}")
|
|
29
|
+
if arr.size == 0:
|
|
30
|
+
raise TripDistError(f"{name} is empty")
|
|
31
|
+
if not np.all(np.isfinite(arr)):
|
|
32
|
+
raise TripDistError(f"{name} contains NaN or infinite values")
|
|
33
|
+
if not allow_negative and np.any(arr < 0):
|
|
34
|
+
raise TripDistError(f"{name} contains negative values")
|
|
35
|
+
return arr
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def as_matrix(
|
|
39
|
+
values: ArrayLike,
|
|
40
|
+
name: str,
|
|
41
|
+
*,
|
|
42
|
+
square: bool = False,
|
|
43
|
+
allow_negative: bool = False,
|
|
44
|
+
allow_inf: bool = False,
|
|
45
|
+
) -> FloatArray:
|
|
46
|
+
"""Return ``values`` as a 2-D float array or raise :class:`TripDistError`.
|
|
47
|
+
|
|
48
|
+
``allow_inf`` permits ``+inf`` entries, used for costs of impossible movements.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
arr = np.asarray(values, dtype=np.float64)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
raise TripDistError(f"{name} must be numeric") from None
|
|
54
|
+
if arr.ndim != 2:
|
|
55
|
+
raise TripDistError(f"{name} must be two-dimensional, got shape {arr.shape}")
|
|
56
|
+
if arr.size == 0:
|
|
57
|
+
raise TripDistError(f"{name} is empty")
|
|
58
|
+
if square and arr.shape[0] != arr.shape[1]:
|
|
59
|
+
raise TripDistError(f"{name} must be square, got shape {arr.shape}")
|
|
60
|
+
if np.any(np.isnan(arr)):
|
|
61
|
+
raise TripDistError(f"{name} contains NaN values")
|
|
62
|
+
if allow_inf:
|
|
63
|
+
if np.any(arr == -np.inf):
|
|
64
|
+
raise TripDistError(f"{name} contains -inf")
|
|
65
|
+
elif not np.all(np.isfinite(arr)):
|
|
66
|
+
raise TripDistError(f"{name} contains infinite values")
|
|
67
|
+
if not allow_negative and np.any(arr < 0):
|
|
68
|
+
raise TripDistError(f"{name} contains negative values")
|
|
69
|
+
return arr
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_length(arr: FloatArray, n: int, name: str) -> None:
|
|
73
|
+
if arr.shape[0] != n:
|
|
74
|
+
raise TripDistError(f"{name} has {arr.shape[0]} entries, expected {n}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def clean(value: Any) -> Any:
|
|
78
|
+
"""Make ``value`` JSON-safe: arrays to lists, NaN/inf to ``None``."""
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
return {str(k): clean(v) for k, v in value.items()}
|
|
81
|
+
if isinstance(value, (list, tuple)):
|
|
82
|
+
return [clean(v) for v in value]
|
|
83
|
+
if isinstance(value, np.ndarray):
|
|
84
|
+
return clean(value.tolist())
|
|
85
|
+
if isinstance(value, (np.bool_, bool)):
|
|
86
|
+
return bool(value)
|
|
87
|
+
if isinstance(value, (np.integer,)):
|
|
88
|
+
return int(value)
|
|
89
|
+
if isinstance(value, (float, np.floating)):
|
|
90
|
+
f = float(value)
|
|
91
|
+
return f if math.isfinite(f) else None
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def safe_div(num: FloatArray, den: FloatArray) -> FloatArray:
|
|
96
|
+
"""Element-wise ``num / den`` with 0 where ``den == 0``."""
|
|
97
|
+
out = np.zeros(np.broadcast(num, den).shape, dtype=np.float64)
|
|
98
|
+
np.divide(num, den, out=out, where=den != 0)
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def rel_error(actual: FloatArray, target: FloatArray) -> float:
|
|
103
|
+
"""Largest ``|actual - target| / target`` (absolute difference where target is 0)."""
|
|
104
|
+
diff = np.abs(actual - target)
|
|
105
|
+
err = np.where(target > 0, diff / np.where(target > 0, target, 1.0), diff)
|
|
106
|
+
return float(err.max()) if err.size else 0.0
|
tripdist/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tripdist/balancing.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Biproportional matrix balancing (Furness method / iterative proportional fitting).
|
|
2
|
+
|
|
3
|
+
The Furness (1965) iteration alternately scales the rows and the columns of a seed matrix
|
|
4
|
+
until its margins match target origin and destination totals:
|
|
5
|
+
|
|
6
|
+
T_ij = A_i O_i B_j D_j t_ij (Furness 1965; Ortúzar & Willumsen 2011, section 5.2)
|
|
7
|
+
|
|
8
|
+
The same algorithm is known as iterative proportional fitting (Deming & Stephan 1940) and
|
|
9
|
+
RAS (Bacharach 1970). It is the balancing engine for the Furness growth-factor method and
|
|
10
|
+
for the doubly constrained gravity model.
|
|
11
|
+
|
|
12
|
+
References
|
|
13
|
+
----------
|
|
14
|
+
Bacharach, M. (1970). *Biproportional Matrices and Input-Output Change*. Cambridge
|
|
15
|
+
University Press.
|
|
16
|
+
|
|
17
|
+
Deming, W. E. & Stephan, F. F. (1940). On a least squares adjustment of a sampled frequency
|
|
18
|
+
table when the expected marginal totals are known. *Annals of Mathematical Statistics*
|
|
19
|
+
11(4), 427-444.
|
|
20
|
+
|
|
21
|
+
Furness, K. P. (1965). Time function iteration. *Traffic Engineering and Control* 7(7),
|
|
22
|
+
458-460.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
from numpy.typing import ArrayLike
|
|
32
|
+
|
|
33
|
+
from ._common import FloatArray, TripDistError, as_matrix, as_vector, check_length, clean, rel_error
|
|
34
|
+
|
|
35
|
+
__all__ = ["BalanceResult", "furness", "margin_error"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class BalanceResult:
|
|
40
|
+
"""Outcome of a biproportional balancing run.
|
|
41
|
+
|
|
42
|
+
``history`` holds the largest relative margin error after each iteration, which is
|
|
43
|
+
the convergence diagnostic usually plotted for Furness runs.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
matrix: FloatArray
|
|
47
|
+
row_factors: FloatArray
|
|
48
|
+
col_factors: FloatArray
|
|
49
|
+
iterations: int
|
|
50
|
+
converged: bool
|
|
51
|
+
max_error: float
|
|
52
|
+
history: List[float] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def total(self) -> float:
|
|
56
|
+
return float(self.matrix.sum())
|
|
57
|
+
|
|
58
|
+
def to_dict(self, include_matrix: bool = True) -> Dict[str, Any]:
|
|
59
|
+
out: Dict[str, Any] = {
|
|
60
|
+
"iterations": self.iterations,
|
|
61
|
+
"converged": self.converged,
|
|
62
|
+
"max_error": self.max_error,
|
|
63
|
+
"total": self.total,
|
|
64
|
+
"row_factors": self.row_factors,
|
|
65
|
+
"col_factors": self.col_factors,
|
|
66
|
+
"history": self.history,
|
|
67
|
+
}
|
|
68
|
+
if include_matrix:
|
|
69
|
+
out["matrix"] = self.matrix
|
|
70
|
+
return clean(out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def margin_error(matrix: ArrayLike, row_targets: ArrayLike, col_targets: ArrayLike) -> float:
|
|
74
|
+
"""Largest relative difference between a matrix's margins and the targets.
|
|
75
|
+
|
|
76
|
+
Each error is ``|sum - target| / target``; targets of zero use the absolute sum.
|
|
77
|
+
"""
|
|
78
|
+
m = as_matrix(matrix, "matrix")
|
|
79
|
+
rt = as_vector(row_targets, "row_targets")
|
|
80
|
+
ct = as_vector(col_targets, "col_targets")
|
|
81
|
+
check_length(rt, m.shape[0], "row_targets")
|
|
82
|
+
check_length(ct, m.shape[1], "col_targets")
|
|
83
|
+
return _margin_error(m, rt, ct)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _margin_error(m: FloatArray, rt: FloatArray, ct: FloatArray) -> float:
|
|
87
|
+
return max(rel_error(m.sum(axis=1), rt), rel_error(m.sum(axis=0), ct))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def furness(
|
|
91
|
+
seed: ArrayLike,
|
|
92
|
+
row_targets: ArrayLike,
|
|
93
|
+
col_targets: ArrayLike,
|
|
94
|
+
*,
|
|
95
|
+
tol: float = 1e-6,
|
|
96
|
+
max_iter: int = 1000,
|
|
97
|
+
total_mismatch: str = "error",
|
|
98
|
+
rel_tol_totals: float = 1e-6,
|
|
99
|
+
raise_on_fail: bool = False,
|
|
100
|
+
) -> BalanceResult:
|
|
101
|
+
"""Scale ``seed`` so that its row and column sums match the targets.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
seed:
|
|
106
|
+
Non-negative seed matrix (base-year trips, or ``O_i D_j f(c_ij)`` for a gravity
|
|
107
|
+
model). Cells that are zero stay zero.
|
|
108
|
+
row_targets, col_targets:
|
|
109
|
+
Target origin (row) and destination (column) totals.
|
|
110
|
+
tol:
|
|
111
|
+
Stop when the largest relative margin error falls below this value.
|
|
112
|
+
max_iter:
|
|
113
|
+
Maximum number of row-plus-column passes.
|
|
114
|
+
total_mismatch:
|
|
115
|
+
What to do when the two target vectors have different totals (the problem then has
|
|
116
|
+
no exact solution): ``"error"`` (default), ``"rows"`` scale the column targets to
|
|
117
|
+
the row total, ``"cols"`` scale the row targets to the column total, or
|
|
118
|
+
``"average"`` scale both to their mean.
|
|
119
|
+
raise_on_fail:
|
|
120
|
+
Raise :class:`TripDistError` if the run has not converged after ``max_iter``.
|
|
121
|
+
|
|
122
|
+
The iteration converges when a solution exists, i.e. when the zero pattern of the seed
|
|
123
|
+
allows the targets (Bacharach 1970). Rows or columns of the seed that are entirely zero
|
|
124
|
+
but have a positive target make that impossible and raise an error.
|
|
125
|
+
"""
|
|
126
|
+
t = as_matrix(seed, "seed")
|
|
127
|
+
rt = as_vector(row_targets, "row_targets")
|
|
128
|
+
ct = as_vector(col_targets, "col_targets")
|
|
129
|
+
check_length(rt, t.shape[0], "row_targets")
|
|
130
|
+
check_length(ct, t.shape[1], "col_targets")
|
|
131
|
+
if tol <= 0:
|
|
132
|
+
raise TripDistError("tol must be positive")
|
|
133
|
+
if max_iter < 1:
|
|
134
|
+
raise TripDistError("max_iter must be at least 1")
|
|
135
|
+
sr, sc = float(rt.sum()), float(ct.sum())
|
|
136
|
+
if sr <= 0 or sc <= 0:
|
|
137
|
+
raise TripDistError("targets must have positive totals")
|
|
138
|
+
if abs(sr - sc) > rel_tol_totals * max(sr, sc):
|
|
139
|
+
if total_mismatch == "error":
|
|
140
|
+
raise TripDistError(
|
|
141
|
+
f"row targets total {sr:g} but column targets total {sc:g}; "
|
|
142
|
+
"balance them first or pass total_mismatch"
|
|
143
|
+
)
|
|
144
|
+
if total_mismatch == "rows":
|
|
145
|
+
ct = ct * (sr / sc)
|
|
146
|
+
elif total_mismatch == "cols":
|
|
147
|
+
rt = rt * (sc / sr)
|
|
148
|
+
elif total_mismatch == "average":
|
|
149
|
+
avg = 0.5 * (sr + sc)
|
|
150
|
+
rt = rt * (avg / sr)
|
|
151
|
+
ct = ct * (avg / sc)
|
|
152
|
+
else:
|
|
153
|
+
raise TripDistError(f"unknown total_mismatch {total_mismatch!r}")
|
|
154
|
+
if np.any((t.sum(axis=1) == 0) & (rt > 0)):
|
|
155
|
+
bad = np.flatnonzero((t.sum(axis=1) == 0) & (rt > 0)).tolist()
|
|
156
|
+
raise TripDistError(f"seed rows {bad} are all zero but have positive targets")
|
|
157
|
+
if np.any((t.sum(axis=0) == 0) & (ct > 0)):
|
|
158
|
+
bad = np.flatnonzero((t.sum(axis=0) == 0) & (ct > 0)).tolist()
|
|
159
|
+
raise TripDistError(f"seed columns {bad} are all zero but have positive targets")
|
|
160
|
+
|
|
161
|
+
a = np.ones(t.shape[0])
|
|
162
|
+
b = np.ones(t.shape[1])
|
|
163
|
+
history: List[float] = []
|
|
164
|
+
converged = False
|
|
165
|
+
err = _margin_error(t, rt, ct)
|
|
166
|
+
it = 0
|
|
167
|
+
for it in range(1, max_iter + 1): # noqa: B007 - it is reported after the loop
|
|
168
|
+
rows = t @ b
|
|
169
|
+
a = np.divide(rt, rows, out=np.zeros_like(rt), where=rows > 0)
|
|
170
|
+
cols = a @ t
|
|
171
|
+
b = np.divide(ct, cols, out=np.zeros_like(ct), where=cols > 0)
|
|
172
|
+
m = a[:, None] * t * b[None, :]
|
|
173
|
+
err = _margin_error(m, rt, ct)
|
|
174
|
+
history.append(err)
|
|
175
|
+
if err < tol:
|
|
176
|
+
converged = True
|
|
177
|
+
break
|
|
178
|
+
matrix = a[:, None] * t * b[None, :]
|
|
179
|
+
if not converged and raise_on_fail:
|
|
180
|
+
raise TripDistError(f"Furness did not converge in {max_iter} iterations (error {err:g})")
|
|
181
|
+
return BalanceResult(
|
|
182
|
+
matrix=matrix,
|
|
183
|
+
row_factors=a,
|
|
184
|
+
col_factors=b,
|
|
185
|
+
iterations=it,
|
|
186
|
+
converged=converged,
|
|
187
|
+
max_error=err,
|
|
188
|
+
history=history,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def furness_from(
|
|
193
|
+
seed: ArrayLike,
|
|
194
|
+
row_targets: Optional[ArrayLike],
|
|
195
|
+
col_targets: Optional[ArrayLike],
|
|
196
|
+
**kwargs: Any,
|
|
197
|
+
) -> BalanceResult:
|
|
198
|
+
"""Furness with optional margins: a missing side is left free (single constraint)."""
|
|
199
|
+
t = as_matrix(seed, "seed")
|
|
200
|
+
if row_targets is not None and col_targets is not None:
|
|
201
|
+
return furness(t, row_targets, col_targets, **kwargs)
|
|
202
|
+
if row_targets is None and col_targets is None:
|
|
203
|
+
raise TripDistError("give at least one of row_targets and col_targets")
|
|
204
|
+
if row_targets is not None:
|
|
205
|
+
rt = as_vector(row_targets, "row_targets")
|
|
206
|
+
check_length(rt, t.shape[0], "row_targets")
|
|
207
|
+
rows = t.sum(axis=1)
|
|
208
|
+
a = np.divide(rt, rows, out=np.zeros_like(rt), where=rows > 0)
|
|
209
|
+
m = a[:, None] * t
|
|
210
|
+
err = rel_error(m.sum(axis=1), rt)
|
|
211
|
+
return BalanceResult(m, a, np.ones(t.shape[1]), 1, True, err, [err])
|
|
212
|
+
assert col_targets is not None
|
|
213
|
+
ct = as_vector(col_targets, "col_targets")
|
|
214
|
+
check_length(ct, t.shape[1], "col_targets")
|
|
215
|
+
cols = t.sum(axis=0)
|
|
216
|
+
b = np.divide(ct, cols, out=np.zeros_like(ct), where=cols > 0)
|
|
217
|
+
m = t * b[None, :]
|
|
218
|
+
err = rel_error(m.sum(axis=0), ct)
|
|
219
|
+
return BalanceResult(m, np.ones(t.shape[0]), b, 1, True, err, [err])
|