crashmap 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.
- crashmap/__init__.py +174 -0
- crashmap/_special.py +91 -0
- crashmap/before_after.py +251 -0
- crashmap/cli.py +334 -0
- crashmap/data.py +467 -0
- crashmap/geometry.py +161 -0
- crashmap/nb2.py +281 -0
- crashmap/py.typed +0 -0
- crashmap/ranking.py +91 -0
- crashmap/report.py +247 -0
- crashmap/screening.py +592 -0
- crashmap/segmentation.py +223 -0
- crashmap/severity.py +186 -0
- crashmap/sites.py +248 -0
- crashmap/snapping.py +333 -0
- crashmap/spatial.py +404 -0
- crashmap/spf.py +473 -0
- crashmap/synthetic.py +150 -0
- crashmap/timeparse.py +109 -0
- crashmap-0.1.0.dist-info/METADATA +222 -0
- crashmap-0.1.0.dist-info/RECORD +25 -0
- crashmap-0.1.0.dist-info/WHEEL +5 -0
- crashmap-0.1.0.dist-info/entry_points.txt +2 -0
- crashmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- crashmap-0.1.0.dist-info/top_level.txt +1 -0
crashmap/__init__.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""crashmap: road-safety network screening and crash hotspot analysis.
|
|
2
|
+
|
|
3
|
+
Implements the network screening performance measures of the Highway Safety
|
|
4
|
+
Manual (AASHTO 2010, Part B, Chapter 4), negative binomial safety performance
|
|
5
|
+
functions, Empirical Bayes estimation, before-after evaluation (Hauer 1997)
|
|
6
|
+
and spatial hotspot tools, on top of NumPy only.
|
|
7
|
+
|
|
8
|
+
Typical workflow::
|
|
9
|
+
|
|
10
|
+
import crashmap as cm
|
|
11
|
+
|
|
12
|
+
crashes = cm.read_crashes_csv("crashes.csv")
|
|
13
|
+
roads = cm.read_roads_geojson("roads.geojson")
|
|
14
|
+
segments = cm.fixed_length_segments(roads, 500)
|
|
15
|
+
snap = cm.snap_to_lines(crashes, segments, max_distance=25)
|
|
16
|
+
sites = cm.build_segment_sites(segments, crashes, snap, years=5)
|
|
17
|
+
spf = cm.SPF.segment(a=-6.2, b=0.75, k=0.5, length_unit="km")
|
|
18
|
+
result = cm.screen(sites, "eb", spf=spf)
|
|
19
|
+
cm.write_geojson(result, "ranked.geojson")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
from .before_after import (
|
|
27
|
+
BeforeAfterResult,
|
|
28
|
+
comparison_group_before_after,
|
|
29
|
+
eb_before_after,
|
|
30
|
+
naive_before_after,
|
|
31
|
+
)
|
|
32
|
+
from .data import (
|
|
33
|
+
CrashSet,
|
|
34
|
+
Road,
|
|
35
|
+
crashes_from_dataframe,
|
|
36
|
+
read_crashes_csv,
|
|
37
|
+
read_crashes_geojson,
|
|
38
|
+
read_roads_geojson,
|
|
39
|
+
roads_from_geodataframe,
|
|
40
|
+
write_crashes_csv,
|
|
41
|
+
write_roads_geojson,
|
|
42
|
+
)
|
|
43
|
+
from .geometry import LocalProjection
|
|
44
|
+
from .nb2 import NB2Result, fit_nb2, fit_poisson, nb2_loglik
|
|
45
|
+
from .ranking import rank, top_n
|
|
46
|
+
from .report import ranked_geojson, to_html, to_markdown, write_geojson, write_report
|
|
47
|
+
from .screening import (
|
|
48
|
+
METHODS,
|
|
49
|
+
ScreeningResult,
|
|
50
|
+
average_frequency,
|
|
51
|
+
crash_rate,
|
|
52
|
+
critical_rate,
|
|
53
|
+
eb_expected,
|
|
54
|
+
eb_weight,
|
|
55
|
+
epdo_score,
|
|
56
|
+
excess_predicted,
|
|
57
|
+
exposure_mev,
|
|
58
|
+
exposure_mvkt,
|
|
59
|
+
proportion_exceeding,
|
|
60
|
+
relative_severity_index,
|
|
61
|
+
screen,
|
|
62
|
+
)
|
|
63
|
+
from .segmentation import (
|
|
64
|
+
Segment,
|
|
65
|
+
Window,
|
|
66
|
+
fixed_length_segments,
|
|
67
|
+
homogeneous_segments,
|
|
68
|
+
roads_as_segments,
|
|
69
|
+
sliding_window_counts,
|
|
70
|
+
sliding_windows,
|
|
71
|
+
)
|
|
72
|
+
from .severity import (
|
|
73
|
+
DEFAULT_EPDO_WEIGHTS,
|
|
74
|
+
HSM_CRASH_COSTS,
|
|
75
|
+
KABCO,
|
|
76
|
+
epdo_weights_from_costs,
|
|
77
|
+
normalize_severity,
|
|
78
|
+
to_fss,
|
|
79
|
+
)
|
|
80
|
+
from .sites import Site, build_intersection_sites, build_segment_sites, build_window_sites
|
|
81
|
+
from .snapping import Intersection, SnapResult, extract_intersections, snap_to_lines, snap_to_points
|
|
82
|
+
from .spatial import (
|
|
83
|
+
distance_band_weights,
|
|
84
|
+
getis_ord_gi_star,
|
|
85
|
+
kde_at,
|
|
86
|
+
kde_grid,
|
|
87
|
+
network_kde,
|
|
88
|
+
touching_weights,
|
|
89
|
+
)
|
|
90
|
+
from .spf import SPF, CUREResult, FittedSPF, SPFTerm, cure_plot, fit_spf, load_spf
|
|
91
|
+
from .synthetic import simulate_nb_counts, synthetic_network, synthetic_sites
|
|
92
|
+
from .timeparse import parse_datetime
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
"DEFAULT_EPDO_WEIGHTS",
|
|
96
|
+
"HSM_CRASH_COSTS",
|
|
97
|
+
"KABCO",
|
|
98
|
+
"METHODS",
|
|
99
|
+
"SPF",
|
|
100
|
+
"BeforeAfterResult",
|
|
101
|
+
"CUREResult",
|
|
102
|
+
"CrashSet",
|
|
103
|
+
"FittedSPF",
|
|
104
|
+
"Intersection",
|
|
105
|
+
"LocalProjection",
|
|
106
|
+
"NB2Result",
|
|
107
|
+
"Road",
|
|
108
|
+
"SPFTerm",
|
|
109
|
+
"ScreeningResult",
|
|
110
|
+
"Segment",
|
|
111
|
+
"Site",
|
|
112
|
+
"SnapResult",
|
|
113
|
+
"Window",
|
|
114
|
+
"__version__",
|
|
115
|
+
"average_frequency",
|
|
116
|
+
"build_intersection_sites",
|
|
117
|
+
"build_segment_sites",
|
|
118
|
+
"build_window_sites",
|
|
119
|
+
"comparison_group_before_after",
|
|
120
|
+
"crash_rate",
|
|
121
|
+
"crashes_from_dataframe",
|
|
122
|
+
"critical_rate",
|
|
123
|
+
"cure_plot",
|
|
124
|
+
"distance_band_weights",
|
|
125
|
+
"eb_before_after",
|
|
126
|
+
"eb_expected",
|
|
127
|
+
"eb_weight",
|
|
128
|
+
"epdo_score",
|
|
129
|
+
"epdo_weights_from_costs",
|
|
130
|
+
"excess_predicted",
|
|
131
|
+
"exposure_mev",
|
|
132
|
+
"exposure_mvkt",
|
|
133
|
+
"extract_intersections",
|
|
134
|
+
"fit_nb2",
|
|
135
|
+
"fit_poisson",
|
|
136
|
+
"fit_spf",
|
|
137
|
+
"fixed_length_segments",
|
|
138
|
+
"getis_ord_gi_star",
|
|
139
|
+
"homogeneous_segments",
|
|
140
|
+
"kde_at",
|
|
141
|
+
"kde_grid",
|
|
142
|
+
"load_spf",
|
|
143
|
+
"naive_before_after",
|
|
144
|
+
"nb2_loglik",
|
|
145
|
+
"network_kde",
|
|
146
|
+
"normalize_severity",
|
|
147
|
+
"parse_datetime",
|
|
148
|
+
"proportion_exceeding",
|
|
149
|
+
"rank",
|
|
150
|
+
"ranked_geojson",
|
|
151
|
+
"read_crashes_csv",
|
|
152
|
+
"read_crashes_geojson",
|
|
153
|
+
"read_roads_geojson",
|
|
154
|
+
"relative_severity_index",
|
|
155
|
+
"roads_as_segments",
|
|
156
|
+
"roads_from_geodataframe",
|
|
157
|
+
"screen",
|
|
158
|
+
"simulate_nb_counts",
|
|
159
|
+
"sliding_window_counts",
|
|
160
|
+
"sliding_windows",
|
|
161
|
+
"snap_to_lines",
|
|
162
|
+
"snap_to_points",
|
|
163
|
+
"synthetic_network",
|
|
164
|
+
"synthetic_sites",
|
|
165
|
+
"to_fss",
|
|
166
|
+
"to_html",
|
|
167
|
+
"to_markdown",
|
|
168
|
+
"top_n",
|
|
169
|
+
"touching_weights",
|
|
170
|
+
"write_crashes_csv",
|
|
171
|
+
"write_geojson",
|
|
172
|
+
"write_report",
|
|
173
|
+
"write_roads_geojson",
|
|
174
|
+
]
|
crashmap/_special.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Special functions without a hard SciPy dependency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from statistics import NormalDist
|
|
7
|
+
|
|
8
|
+
__all__ = ["betainc", "norm_cdf", "norm_ppf", "norm_sf"]
|
|
9
|
+
|
|
10
|
+
_STD = NormalDist()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def norm_ppf(p: float) -> float:
|
|
14
|
+
"""Standard normal quantile."""
|
|
15
|
+
if not 0.0 < p < 1.0:
|
|
16
|
+
raise ValueError("probability must be within (0, 1)")
|
|
17
|
+
return float(_STD.inv_cdf(p))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def norm_cdf(z: float) -> float:
|
|
21
|
+
"""Standard normal CDF."""
|
|
22
|
+
return 0.5 * math.erfc(-z / math.sqrt(2.0))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def norm_sf(z: float) -> float:
|
|
26
|
+
"""Standard normal survival function (accurate in the upper tail)."""
|
|
27
|
+
return 0.5 * math.erfc(z / math.sqrt(2.0))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _betacf(a: float, b: float, x: float) -> float:
|
|
31
|
+
# Modified Lentz continued fraction (Press et al., Numerical Recipes, 6.4).
|
|
32
|
+
fpmin = 1e-300
|
|
33
|
+
qab, qap, qam = a + b, a + 1.0, a - 1.0
|
|
34
|
+
c = 1.0
|
|
35
|
+
d = 1.0 - qab * x / qap
|
|
36
|
+
if abs(d) < fpmin:
|
|
37
|
+
d = fpmin
|
|
38
|
+
d = 1.0 / d
|
|
39
|
+
h = d
|
|
40
|
+
for m in range(1, 10_001):
|
|
41
|
+
m2 = 2 * m
|
|
42
|
+
aa = m * (b - m) * x / ((qam + m2) * (a + m2))
|
|
43
|
+
d = 1.0 + aa * d
|
|
44
|
+
d = fpmin if abs(d) < fpmin else d
|
|
45
|
+
c = 1.0 + aa / c
|
|
46
|
+
c = fpmin if abs(c) < fpmin else c
|
|
47
|
+
d = 1.0 / d
|
|
48
|
+
h *= d * c
|
|
49
|
+
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
|
|
50
|
+
d = 1.0 + aa * d
|
|
51
|
+
d = fpmin if abs(d) < fpmin else d
|
|
52
|
+
c = 1.0 + aa / c
|
|
53
|
+
c = fpmin if abs(c) < fpmin else c
|
|
54
|
+
d = 1.0 / d
|
|
55
|
+
de = d * c
|
|
56
|
+
h *= de
|
|
57
|
+
if abs(de - 1.0) < 1e-15:
|
|
58
|
+
return h
|
|
59
|
+
raise ArithmeticError("incomplete beta continued fraction did not converge")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _betainc_pure(a: float, b: float, x: float) -> float:
|
|
63
|
+
if x <= 0.0:
|
|
64
|
+
return 0.0
|
|
65
|
+
if x >= 1.0:
|
|
66
|
+
return 1.0
|
|
67
|
+
lbt = (
|
|
68
|
+
math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b) + a * math.log(x) + b * math.log1p(-x)
|
|
69
|
+
)
|
|
70
|
+
bt = math.exp(lbt)
|
|
71
|
+
if x < (a + 1.0) / (a + b + 2.0):
|
|
72
|
+
return bt * _betacf(a, b, x) / a
|
|
73
|
+
return 1.0 - bt * _betacf(b, a, 1.0 - x) / b
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def betainc(a: float, b: float, x: float, *, use_scipy: bool = True) -> float:
|
|
77
|
+
"""Regularised incomplete beta function :math:`I_x(a, b)` (the Beta CDF).
|
|
78
|
+
|
|
79
|
+
Uses :func:`scipy.special.betainc` when SciPy is installed and
|
|
80
|
+
``use_scipy`` is true, otherwise a continued-fraction evaluation.
|
|
81
|
+
"""
|
|
82
|
+
if a <= 0 or b <= 0:
|
|
83
|
+
raise ValueError("beta parameters must be positive")
|
|
84
|
+
if use_scipy:
|
|
85
|
+
try:
|
|
86
|
+
from scipy.special import betainc as _sp_betainc
|
|
87
|
+
except ImportError: # pragma: no cover - depends on the environment
|
|
88
|
+
pass
|
|
89
|
+
else:
|
|
90
|
+
return float(_sp_betainc(a, b, x))
|
|
91
|
+
return _betainc_pure(a, b, x)
|
crashmap/before_after.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Before-after evaluation of safety treatments (Hauer 1997).
|
|
2
|
+
|
|
3
|
+
All three designs share Hauer's four-step framework: estimate ``pi``, the
|
|
4
|
+
crashes expected in the after period had the treatment not been applied, and
|
|
5
|
+
compare it with ``lambda``, the crashes observed after treatment. The index of
|
|
6
|
+
effectiveness (crash modification factor, CMF) corrected for the bias of a
|
|
7
|
+
ratio of random variables is::
|
|
8
|
+
|
|
9
|
+
theta = (lambda / pi) / (1 + Var(pi) / pi**2)
|
|
10
|
+
Var(theta) = theta**2 * (Var(lambda)/lambda**2 + Var(pi)/pi**2) / (1 + Var(pi)/pi**2)**2
|
|
11
|
+
|
|
12
|
+
with ``Var(lambda) = lambda`` (Poisson). A CMF below 1 means fewer crashes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Union
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from ._special import norm_ppf
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"BeforeAfterResult",
|
|
28
|
+
"comparison_group_before_after",
|
|
29
|
+
"eb_before_after",
|
|
30
|
+
"naive_before_after",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
ArrayLike = Union[Sequence[float], np.ndarray, float]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BeforeAfterResult:
|
|
38
|
+
"""Outcome of a before-after study.
|
|
39
|
+
|
|
40
|
+
Attributes
|
|
41
|
+
----------
|
|
42
|
+
method:
|
|
43
|
+
``"naive"``, ``"comparison-group"`` or ``"empirical-bayes"``.
|
|
44
|
+
observed_after:
|
|
45
|
+
``lambda``, crashes observed after treatment (all sites).
|
|
46
|
+
expected_after:
|
|
47
|
+
``pi``, crashes expected after without treatment.
|
|
48
|
+
var_expected_after:
|
|
49
|
+
``Var(pi)``.
|
|
50
|
+
cmf:
|
|
51
|
+
Bias-corrected index of effectiveness ``theta``.
|
|
52
|
+
se:
|
|
53
|
+
Standard error of ``theta``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
method: str
|
|
57
|
+
observed_after: float
|
|
58
|
+
expected_after: float
|
|
59
|
+
var_expected_after: float
|
|
60
|
+
cmf: float
|
|
61
|
+
se: float
|
|
62
|
+
n_sites: int
|
|
63
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def percent_change(self) -> float:
|
|
67
|
+
"""``100 * (theta - 1)``; negative = crash reduction."""
|
|
68
|
+
return 100.0 * (self.cmf - 1.0)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def crash_reduction(self) -> float:
|
|
72
|
+
"""Crash reduction factor ``100 * (1 - theta)`` in percent."""
|
|
73
|
+
return 100.0 * (1.0 - self.cmf)
|
|
74
|
+
|
|
75
|
+
def confidence_interval(self, level: float = 0.95) -> tuple[float, float]:
|
|
76
|
+
"""Normal-approximation confidence interval of the CMF."""
|
|
77
|
+
z = norm_ppf(0.5 + level / 2.0)
|
|
78
|
+
return (self.cmf - z * self.se, self.cmf + z * self.se)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def significant(self) -> bool:
|
|
82
|
+
"""True when the 95 % confidence interval excludes 1."""
|
|
83
|
+
lo, hi = self.confidence_interval(0.95)
|
|
84
|
+
return hi < 1.0 or lo > 1.0
|
|
85
|
+
|
|
86
|
+
def as_dict(self) -> dict[str, Any]:
|
|
87
|
+
"""Flat dictionary for reports and JSON."""
|
|
88
|
+
lo, hi = self.confidence_interval()
|
|
89
|
+
return {
|
|
90
|
+
"method": self.method,
|
|
91
|
+
"n_sites": self.n_sites,
|
|
92
|
+
"observed_after": self.observed_after,
|
|
93
|
+
"expected_after": self.expected_after,
|
|
94
|
+
"var_expected_after": self.var_expected_after,
|
|
95
|
+
"cmf": self.cmf,
|
|
96
|
+
"se": self.se,
|
|
97
|
+
"ci95_low": lo,
|
|
98
|
+
"ci95_high": hi,
|
|
99
|
+
"percent_change": self.percent_change,
|
|
100
|
+
"significant": self.significant,
|
|
101
|
+
**self.details,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _hauer(lam: float, pi: float, var_pi: float) -> tuple[float, float]:
|
|
106
|
+
if pi <= 0:
|
|
107
|
+
raise ValueError("expected after-period crashes must be positive")
|
|
108
|
+
rel = var_pi / pi**2
|
|
109
|
+
theta = (lam / pi) / (1.0 + rel)
|
|
110
|
+
if lam > 0:
|
|
111
|
+
var_t = theta**2 * (1.0 / lam + rel) / (1.0 + rel) ** 2
|
|
112
|
+
else:
|
|
113
|
+
# lambda = 0 makes the Poisson estimate Var(lambda) = lambda vanish;
|
|
114
|
+
# use Var(lambda) = 1 (one crash) so the standard error stays honest.
|
|
115
|
+
var_t = (1.0 / pi**2) / (1.0 + rel) ** 2
|
|
116
|
+
return float(theta), float(math.sqrt(var_t))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _total(v: ArrayLike, name: str) -> tuple[float, int]:
|
|
120
|
+
a = np.asarray(v, dtype=float).ravel()
|
|
121
|
+
if a.size == 0 or np.any(a < 0) or not np.all(np.isfinite(a)):
|
|
122
|
+
raise ValueError(f"{name} must be non-negative and finite")
|
|
123
|
+
return float(a.sum()), int(a.size)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def naive_before_after(
|
|
127
|
+
observed_before: ArrayLike,
|
|
128
|
+
observed_after: ArrayLike,
|
|
129
|
+
*,
|
|
130
|
+
years_before: float = 1.0,
|
|
131
|
+
years_after: float = 1.0,
|
|
132
|
+
) -> BeforeAfterResult:
|
|
133
|
+
"""Naive (simple) before-after study.
|
|
134
|
+
|
|
135
|
+
``pi = r_d * K`` with ``K`` the before count and ``r_d`` the ratio of
|
|
136
|
+
after to before durations; ``Var(pi) = r_d**2 * K``. Ignores regression to
|
|
137
|
+
the mean and general trends, so it overstates benefits at sites chosen for
|
|
138
|
+
their high crash counts.
|
|
139
|
+
"""
|
|
140
|
+
k, n_sites = _total(observed_before, "observed_before")
|
|
141
|
+
lam, n2 = _total(observed_after, "observed_after")
|
|
142
|
+
if n_sites != n2:
|
|
143
|
+
raise ValueError("before and after counts must cover the same sites")
|
|
144
|
+
if years_before <= 0 or years_after <= 0:
|
|
145
|
+
raise ValueError("period lengths must be positive")
|
|
146
|
+
rd = years_after / years_before
|
|
147
|
+
pi = rd * k
|
|
148
|
+
var_pi = rd**2 * k
|
|
149
|
+
theta, se = _hauer(lam, pi, var_pi)
|
|
150
|
+
return BeforeAfterResult("naive", lam, pi, var_pi, theta, se, n_sites, {"duration_ratio": rd})
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def comparison_group_before_after(
|
|
154
|
+
observed_before: ArrayLike,
|
|
155
|
+
observed_after: ArrayLike,
|
|
156
|
+
comparison_before: ArrayLike,
|
|
157
|
+
comparison_after: ArrayLike,
|
|
158
|
+
*,
|
|
159
|
+
comparison_ratio_variance: float | None = None,
|
|
160
|
+
) -> BeforeAfterResult:
|
|
161
|
+
"""Before-after study with a comparison group (Hauer 1997, ch. 9).
|
|
162
|
+
|
|
163
|
+
The comparison ratio ``r_C = N / M`` (comparison after over before
|
|
164
|
+
crashes) scales the before count: ``pi = r_C * K``. Unless given,
|
|
165
|
+
``Var(r_C) / r_C**2`` is approximated by ``1/M + 1/N`` (comparison counts
|
|
166
|
+
Poisson, no extra variation between treated and comparison ratios) and
|
|
167
|
+
``Var(pi) = pi**2 * (1/K + Var(r_C)/r_C**2)``.
|
|
168
|
+
"""
|
|
169
|
+
k, n_sites = _total(observed_before, "observed_before")
|
|
170
|
+
lam, n2 = _total(observed_after, "observed_after")
|
|
171
|
+
if n_sites != n2:
|
|
172
|
+
raise ValueError("before and after counts must cover the same sites")
|
|
173
|
+
m, _ = _total(comparison_before, "comparison_before")
|
|
174
|
+
nn, _ = _total(comparison_after, "comparison_after")
|
|
175
|
+
if m <= 0 or nn <= 0 or k <= 0:
|
|
176
|
+
raise ValueError("comparison counts and treated before count must be positive")
|
|
177
|
+
rc = nn / m
|
|
178
|
+
rel_rc = (
|
|
179
|
+
(1.0 / m + 1.0 / nn)
|
|
180
|
+
if comparison_ratio_variance is None
|
|
181
|
+
else (comparison_ratio_variance / rc**2)
|
|
182
|
+
)
|
|
183
|
+
pi = rc * k
|
|
184
|
+
var_pi = pi**2 * (1.0 / k + rel_rc)
|
|
185
|
+
theta, se = _hauer(lam, pi, var_pi)
|
|
186
|
+
return BeforeAfterResult(
|
|
187
|
+
"comparison-group",
|
|
188
|
+
lam,
|
|
189
|
+
pi,
|
|
190
|
+
var_pi,
|
|
191
|
+
theta,
|
|
192
|
+
se,
|
|
193
|
+
n_sites,
|
|
194
|
+
{"comparison_ratio": rc, "comparison_ratio_rel_var": rel_rc},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def eb_before_after(
|
|
199
|
+
observed_before: ArrayLike,
|
|
200
|
+
observed_after: ArrayLike,
|
|
201
|
+
predicted_before: ArrayLike,
|
|
202
|
+
predicted_after: ArrayLike,
|
|
203
|
+
k: ArrayLike,
|
|
204
|
+
) -> BeforeAfterResult:
|
|
205
|
+
"""Empirical Bayes before-after study (Hauer 1997; Persaud & Lyon 2007).
|
|
206
|
+
|
|
207
|
+
For every treated site ``i``, with SPF predictions summed over the before
|
|
208
|
+
(``P_b``) and after (``P_a``) periods::
|
|
209
|
+
|
|
210
|
+
w_i = 1 / (1 + k_i * P_b)
|
|
211
|
+
E_b = w_i * P_b + (1 - w_i) * K_i EB estimate, before period
|
|
212
|
+
pi_i = E_b * P_a / P_b expected after, untreated
|
|
213
|
+
Var(pi_i) = (P_a / P_b)**2 * (1 - w_i) * E_b
|
|
214
|
+
|
|
215
|
+
Sums over sites give ``pi`` and ``Var(pi)``; ``lambda`` is the summed
|
|
216
|
+
after count. ``k`` may be one value or one per site (e.g. ``k / L_i``).
|
|
217
|
+
"""
|
|
218
|
+
kb = np.asarray(observed_before, dtype=float).ravel()
|
|
219
|
+
la = np.asarray(observed_after, dtype=float).ravel()
|
|
220
|
+
pb = np.asarray(predicted_before, dtype=float).ravel()
|
|
221
|
+
pa = np.asarray(predicted_after, dtype=float).ravel()
|
|
222
|
+
kk = np.broadcast_to(np.asarray(k, dtype=float), kb.shape)
|
|
223
|
+
if not (kb.shape == la.shape == pb.shape == pa.shape):
|
|
224
|
+
raise ValueError("all per-site arrays must have the same length")
|
|
225
|
+
if np.any(pb <= 0) or np.any(pa <= 0):
|
|
226
|
+
raise ValueError("predicted crashes must be positive")
|
|
227
|
+
if np.any(kb < 0) or np.any(la < 0) or np.any(kk < 0):
|
|
228
|
+
raise ValueError("counts and k must not be negative")
|
|
229
|
+
w = 1.0 / (1.0 + kk * pb)
|
|
230
|
+
eb_before = w * pb + (1.0 - w) * kb
|
|
231
|
+
ratio = pa / pb
|
|
232
|
+
pi_i = eb_before * ratio
|
|
233
|
+
var_i = ratio**2 * (1.0 - w) * eb_before
|
|
234
|
+
pi = float(pi_i.sum())
|
|
235
|
+
var_pi = float(var_i.sum())
|
|
236
|
+
lam = float(la.sum())
|
|
237
|
+
theta, se = _hauer(lam, pi, var_pi)
|
|
238
|
+
return BeforeAfterResult(
|
|
239
|
+
"empirical-bayes",
|
|
240
|
+
lam,
|
|
241
|
+
pi,
|
|
242
|
+
var_pi,
|
|
243
|
+
theta,
|
|
244
|
+
se,
|
|
245
|
+
int(kb.size),
|
|
246
|
+
{
|
|
247
|
+
"weights": w.tolist(),
|
|
248
|
+
"eb_before": eb_before.tolist(),
|
|
249
|
+
"expected_after_by_site": pi_i.tolist(),
|
|
250
|
+
},
|
|
251
|
+
)
|