quantmet-tools 0.4.4__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.
quantmet/verify.py ADDED
@@ -0,0 +1,425 @@
1
+ """Forecast verification: CRPS (ensemble, fair and Gaussian), rank histograms and PIT, the Brier score with its
2
+ decomposition and reliability curve, the Diebold-Mariano test of equal skill, and strict pairing of dated series.
3
+
4
+ Numerical notes that matter in practice:
5
+ - THE PLAIN ENSEMBLE CRPS PENALISES SMALL ENSEMBLES. crps_ensemble scores the m-member empirical distribution,
6
+ E|X - y| - 1/2 E|X - X'|. For members drawn from the true distribution its expectation is the true CRPS plus
7
+ E|X - X'| / (2m): +20 % for a 5-member, +2 % for a 50-member ensemble of a Gaussian, so it ranks a big ensemble
8
+ ahead of an equally good small one. crps_fair (Ferro 2014) divides the spread term by m(m - 1) instead of m^2 and
9
+ is unbiased for the CRPS of the distribution the members are drawn from, at any m >= 2 (tested from m = 2 to 50).
10
+ Use it to compare systems of different sizes; use the plain one to score the ensemble as issued.
11
+ - WEIGHTED MIXTURES (a multi-model blend). With member weights and group labels (one group per model), crps_fair
12
+ estimates the CRPS of the mixture sum_k W_k F_k: cross-model pairs are already unbiased, within-model pairs are
13
+ rescaled by W_k^2 / (W_k^2 - sum w_i^2), the weighted form of m/(m-1). A one-member group is a point mass (a
14
+ deterministic model) and needs no correction.
15
+ - RANKS NEED TIE-BREAKING. Precipitation (zeros), rounded temperatures and bounded variables tie the observation with
16
+ members; counting ties as "below" piles the histogram into one end and reads as bias. Ranks, the rank histogram and
17
+ the PIT here spread a tie uniformly over the tied positions (seedable).
18
+ - THE BRIER DECOMPOSITION IS EXACT ONLY WITH ITS TWO EXTRA TERMS. Murphy's BS = REL - RES + UNC holds when the
19
+ forecasts take one value per bin; binning continuous probabilities adds the within-bin variance and covariance
20
+ (Stephenson et al. 2008), returned as 'wbv' and 'wbc' so that bs == rel - res + unc + wbv - wbc to rounding.
21
+ - DIEBOLD-MARIANO NEEDS A HAC VARIANCE. Daily loss differentials are autocorrelated (a persistent regime favours one
22
+ model for a week); the naive t-test then rejects equal skill far too often (35 % at a nominal 5 % in the test
23
+ suite's AR(1) case). The variance here is Newey-West (Bartlett weights, autocovariances over n, so never negative)
24
+ after AR(1) prewhitening (Andrews & Monahan 1992), with the Andrews (1991) plug-in bandwidth, at least h - 1 lags
25
+ for h-step forecasts, and the Harvey-Leybourne-Newbold (1997) small-sample factor with Student-t(n-1) p-values.
26
+ Without the prewhitening the Bartlett estimate is biased low for persistent differentials and the test still
27
+ over-rejects (8-10 %). quantmet.stats.acf_inflation is the same Bartlett sum but with lag-wise 1/(n-k) means, which
28
+ can go negative in short samples; hence a separate HAC here.
29
+ - PAIR BEFORE YOU COMPARE. Two dated series that stop on different days give "the last N days" two different meanings;
30
+ a difference or ratio taken over the window then charges one leg for days the other has not published, always in
31
+ the direction that makes the newer quantity look low. ``paired`` keeps only dates where BOTH legs are finite and
32
+ returns how many days that is, to be shown with the result ("6/7 d"). Missing is NaN, never 0.
33
+
34
+ References: Gneiting & Raftery (2007, J. Amer. Stat. Assoc. 102, 359-378); Hersbach (2000, Wea. Forecasting 15,
35
+ 559-570); Ferro (2014, Q. J. R. Meteorol. Soc. 140, 1917-1923); Gneiting, Raftery, Westveld & Goldman (2005, Mon.
36
+ Wea. Rev. 133, 1098-1118); Hamill (2001, Mon. Wea. Rev. 129, 550-560); Murphy (1973, J. Appl. Meteor. 12, 595-600);
37
+ Stephenson, Coelho & Jolliffe (2008, Wea. Forecasting 23, 752-757); Diebold & Mariano (1995, J. Bus. Econ. Stat. 13,
38
+ 253-263); Harvey, Leybourne & Newbold (1997, Int. J. Forecasting 13, 281-291); Newey & West (1987, Econometrica 55,
39
+ 703-708); Andrews (1991, Econometrica 59, 817-858); Andrews & Monahan (1992, Econometrica 60, 953-966).
40
+
41
+ Requires: numpy, scipy (normal and t distributions).
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from dataclasses import dataclass
47
+
48
+ import numpy as np
49
+
50
+ __all__ = [
51
+ "crps_ensemble",
52
+ "crps_fair",
53
+ "crps_gaussian",
54
+ "ranks",
55
+ "rank_histogram",
56
+ "pit",
57
+ "brier_score",
58
+ "brier_decomposition",
59
+ "reliability_curve",
60
+ "diebold_mariano",
61
+ "DieboldMariano",
62
+ "paired",
63
+ "Paired",
64
+ ]
65
+
66
+
67
+ # --------------------------------------------------------------------------
68
+ # CRPS
69
+ # --------------------------------------------------------------------------
70
+ def _members_last(ens, obs, axis, weights):
71
+ x = np.moveaxis(np.asarray(ens, float), axis, -1)
72
+ y = np.asarray(obs, float)
73
+ x, y = np.broadcast_arrays(x, y[..., None])
74
+ y = y[..., 0]
75
+ m = x.shape[-1]
76
+ w = np.ones(m) if weights is None else np.asarray(weights, float)
77
+ if w.shape != (m,) or (w < 0).any():
78
+ raise ValueError("weights: one non-negative weight per member")
79
+ w = np.broadcast_to(w, x.shape).copy()
80
+ bad = ~np.isfinite(x)
81
+ w[bad] = 0.0
82
+ x = np.where(bad, 0.0, x)
83
+ return x, y, w
84
+
85
+
86
+ def _pair_sum(x, w):
87
+ """sum_ij w_i w_j |x_i - x_j| along the last axis, O(m log m) via the sorted cumulative-weight identity
88
+ sum_ij w_i w_j |x_i - x_j| = 2 sum_i w_(i) x_(i) (2 C_i - w_(i) - W), C_i the cumulative weight, W the total."""
89
+ o = np.argsort(x, axis=-1)
90
+ xs = np.take_along_axis(x, o, -1)
91
+ ws = np.take_along_axis(w, o, -1)
92
+ c = np.cumsum(ws, -1)
93
+ tot = c[..., -1:]
94
+ return 2.0 * np.sum(ws * xs * (2.0 * c - ws - tot), -1)
95
+
96
+
97
+ def crps_ensemble(ens, obs, axis: int = -1, weights=None):
98
+ """CRPS of the (weighted) empirical ensemble distribution, E|X - y| - 1/2 E|X - X'| (Gneiting & Raftery 2007),
99
+ exact for the discrete distribution, in O(m log m).
100
+
101
+ ens : (..., m, ...) members along `axis`; obs broadcasts against the other axes
102
+ weights : optional (m,) member weights (normalised per case); NaN members get zero weight, all-NaN -> NaN.
103
+ A one-member ensemble scores its absolute error."""
104
+ x, y, w = _members_last(ens, obs, axis, weights)
105
+ W = w.sum(-1)
106
+ with np.errstate(invalid="ignore", divide="ignore"):
107
+ t1 = np.sum(w * np.abs(x - y[..., None]), -1) / W
108
+ t2 = _pair_sum(x, w) / W**2
109
+ out = t1 - 0.5 * t2
110
+ return np.where((W > 0) & np.isfinite(y), out, np.nan)
111
+
112
+
113
+ def crps_fair(ens, obs, axis: int = -1, weights=None, groups=None):
114
+ """Fair CRPS (Ferro 2014): E|X - y| - 1/2 E|X - X'| with the spread term estimated without bias,
115
+ (1/m) sum_i |x_i - y| - 1/(2 m (m - 1)) sum_ij |x_i - x_j|,
116
+ unbiased for the CRPS of the distribution the members are drawn from, whatever m (>= 2).
117
+
118
+ weights : optional (m,) member weights; members of one group are then treated as exchangeable draws with
119
+ unequal weights (a weighted U-statistic, factor W^2 / (W^2 - sum w^2)).
120
+ groups : optional (m,) labels, one per source distribution (e.g. per model of a multi-model blend): the target is
121
+ the mixture sum_k W_k F_k, cross-group pairs enter as they are and within-group pairs are rescaled by
122
+ W_k^2 / (W_k^2 - sum_{i in k} w_i^2). A one-member group is a point mass (no correction).
123
+ NaN members get zero weight; a group left with one valid member is then treated as a point mass."""
124
+ x, y, w = _members_last(ens, obs, axis, weights)
125
+ W = w.sum(-1)
126
+ lab = np.zeros(x.shape[-1], int) if groups is None else np.asarray(groups)
127
+ if lab.shape != (x.shape[-1],):
128
+ raise ValueError("groups: one label per member")
129
+ with np.errstate(invalid="ignore", divide="ignore"):
130
+ t1 = np.sum(w * np.abs(x - y[..., None]), -1) / W
131
+ spread = _pair_sum(x, w)
132
+ for g in np.unique(lab):
133
+ sel = lab == g
134
+ wg = w[..., sel]
135
+ within = _pair_sum(x[..., sel], wg)
136
+ Wg = wg.sum(-1)
137
+ S2 = (wg**2).sum(-1)
138
+ den = Wg**2 - S2
139
+ corr = np.where(den > 1e-12 * np.maximum(Wg**2, 1e-300), Wg**2 / np.where(den > 0, den, 1.0) - 1.0, 0.0)
140
+ spread = spread + within * corr
141
+ out = t1 - 0.5 * spread / W**2
142
+ return np.where((W > 0) & np.isfinite(y), out, np.nan)
143
+
144
+
145
+ def crps_gaussian(mu, sigma, obs):
146
+ """Closed-form CRPS of N(mu, sigma^2) (Gneiting et al. 2005, the EMOS objective):
147
+ sigma [z (2 Phi(z) - 1) + 2 phi(z) - 1/sqrt(pi)], z = (y - mu)/sigma; |y - mu| at sigma = 0."""
148
+ from scipy.stats import norm
149
+
150
+ mu, sigma, y = np.broadcast_arrays(np.asarray(mu, float), np.asarray(sigma, float), np.asarray(obs, float))
151
+ if (sigma < 0).any():
152
+ raise ValueError("sigma must be non-negative")
153
+ s = np.where(sigma > 0, sigma, 1.0)
154
+ z = (y - mu) / s
155
+ c = s * (z * (2 * norm.cdf(z) - 1) + 2 * norm.pdf(z) - 1 / np.sqrt(np.pi))
156
+ return np.where(sigma > 0, c, np.abs(y - mu))
157
+
158
+
159
+ # --------------------------------------------------------------------------
160
+ # Ranks and PIT
161
+ # --------------------------------------------------------------------------
162
+ def ranks(ens, obs, axis: int = -1, rng=None):
163
+ """Rank of the observation among the m members, 0..m, ties spread uniformly over the tied positions
164
+ (rank = #below + U{0..#tied}). Cases with any non-finite member or observation get -1."""
165
+ rng = np.random.default_rng(rng)
166
+ x = np.moveaxis(np.asarray(ens, float), axis, -1)
167
+ y = np.asarray(obs, float)
168
+ x, yb = np.broadcast_arrays(x, y[..., None])
169
+ y = yb[..., 0]
170
+ below = (x < y[..., None]).sum(-1)
171
+ tied = (x == y[..., None]).sum(-1)
172
+ r = below + np.floor(rng.random(y.shape) * (tied + 1)).astype(int)
173
+ ok = np.isfinite(x).all(-1) & np.isfinite(y)
174
+ return np.where(ok, r, -1)
175
+
176
+
177
+ def rank_histogram(ens, obs, axis: int = -1, rng=None) -> np.ndarray:
178
+ """Counts of the observation's rank, (m + 1,), over all valid cases (see ranks). Flat for a calibrated ensemble
179
+ (Hamill 2001); U-shaped = under-dispersive, dome = over-dispersive, sloped = biased."""
180
+ r = ranks(ens, obs, axis, rng)
181
+ m = np.asarray(ens).shape[axis]
182
+ return np.bincount(r[r >= 0].ravel(), minlength=m + 1)
183
+
184
+
185
+ def pit(ens, obs, axis: int = -1, rng=None):
186
+ """Randomised probability integral transform of an ensemble, u = (rank + V)/(m + 1), V ~ U(0, 1): exactly U(0, 1)
187
+ for a calibrated ensemble, ties included. NaN for invalid cases. For a parametric forecast the PIT is simply its
188
+ CDF at the observation (e.g. scipy.stats.norm.cdf(obs, mu, sigma))."""
189
+ rng = np.random.default_rng(rng)
190
+ r = ranks(ens, obs, axis, rng)
191
+ m = np.asarray(ens).shape[axis]
192
+ u = (r + rng.random(r.shape)) / (m + 1)
193
+ return np.where(r >= 0, u, np.nan)
194
+
195
+
196
+ # --------------------------------------------------------------------------
197
+ # Brier score
198
+ # --------------------------------------------------------------------------
199
+ def _po(p, o):
200
+ p = np.asarray(p, float).ravel()
201
+ o = np.asarray(o, float).ravel()
202
+ if p.shape != o.shape:
203
+ raise ValueError("p and o must have the same size")
204
+ ok = np.isfinite(p) & np.isfinite(o)
205
+ p, o = p[ok], o[ok]
206
+ if ((p < 0) | (p > 1)).any() or not np.isin(o, (0.0, 1.0)).all():
207
+ raise ValueError("p in [0, 1], o in {0, 1}")
208
+ return p, o
209
+
210
+
211
+ def _bins(p, bins):
212
+ edges = np.linspace(0.0, 1.0, int(bins) + 1) if np.ndim(bins) == 0 else np.asarray(bins, float)
213
+ k = np.clip(np.searchsorted(edges, p, side="right") - 1, 0, edges.size - 2)
214
+ return edges, k
215
+
216
+
217
+ def brier_score(p, o) -> float:
218
+ """Mean of (p - o)^2 over the finite pairs; p probabilities, o 0/1 outcomes."""
219
+ p, o = _po(p, o)
220
+ return float(np.mean((p - o) ** 2)) if p.size else float("nan")
221
+
222
+
223
+ def brier_decomposition(p, o, bins=10) -> dict:
224
+ """Murphy (1973) decomposition with the within-bin terms of Stephenson et al. (2008):
225
+ bs = reliability - resolution + uncertainty + wbv - wbc (exact)
226
+ reliability = sum n_k (pbar_k - obar_k)^2 / N, resolution = sum n_k (obar_k - obar)^2 / N,
227
+ uncertainty = obar (1 - obar), wbv = within-bin forecast variance, wbc = 2 x within-bin covariance of p and o.
228
+ bins: number of equal-width bins on [0, 1] or explicit edges (last bin closed). Also returns n."""
229
+ p, o = _po(p, o)
230
+ n = p.size
231
+ edges, k = _bins(p, bins)
232
+ nb = edges.size - 1
233
+ cnt = np.bincount(k, minlength=nb).astype(float)
234
+ with np.errstate(invalid="ignore", divide="ignore"):
235
+ pk = np.bincount(k, p, nb) / cnt
236
+ ok_ = np.bincount(k, o, nb) / cnt
237
+ has = cnt > 0
238
+ obar = o.mean()
239
+ rel = np.sum(cnt[has] * (pk[has] - ok_[has]) ** 2) / n
240
+ res = np.sum(cnt[has] * (ok_[has] - obar) ** 2) / n
241
+ unc = obar * (1 - obar)
242
+ wbv = np.sum((p - pk[k]) ** 2) / n
243
+ wbc = 2.0 * np.sum((p - pk[k]) * (o - ok_[k])) / n
244
+ return {
245
+ "bs": float(np.mean((p - o) ** 2)),
246
+ "reliability": float(rel),
247
+ "resolution": float(res),
248
+ "uncertainty": float(unc),
249
+ "wbv": float(wbv),
250
+ "wbc": float(wbc),
251
+ "n": int(n),
252
+ }
253
+
254
+
255
+ def reliability_curve(p, o, bins=10) -> dict:
256
+ """Reliability diagram data: per bin the mean forecast probability ('forecast'), the observed frequency
257
+ ('observed') and the number of forecasts ('count'); empty bins are NaN. 'edges' are the bin edges."""
258
+ p, o = _po(p, o)
259
+ edges, k = _bins(p, bins)
260
+ nb = edges.size - 1
261
+ cnt = np.bincount(k, minlength=nb)
262
+ with np.errstate(invalid="ignore", divide="ignore"):
263
+ f = np.bincount(k, p, nb) / cnt
264
+ ob = np.bincount(k, o, nb) / cnt
265
+ return {"edges": edges, "forecast": f, "observed": ob, "count": cnt}
266
+
267
+
268
+ # --------------------------------------------------------------------------
269
+ # Diebold-Mariano
270
+ # --------------------------------------------------------------------------
271
+ @dataclass
272
+ class DieboldMariano:
273
+ """statistic (HLN-corrected unless hln=False), pvalue, mean_diff (mean of loss_a - loss_b: negative = a better),
274
+ lags (HAC truncation), n, and lrv, the long-run variance of the differential."""
275
+
276
+ statistic: float
277
+ pvalue: float
278
+ mean_diff: float
279
+ lags: int
280
+ n: int
281
+ lrv: float
282
+
283
+
284
+ def _hac(u, lags):
285
+ """Newey-West long-run variance of a centred series: gamma_0 + 2 sum_j (1 - j/(L+1)) gamma_j, gamma_j over n."""
286
+ n = u.size
287
+ v = u @ u / n
288
+ for j in range(1, lags + 1):
289
+ v += 2.0 * (1.0 - j / (lags + 1.0)) * (u[j:] @ u[:-j]) / n
290
+ return v
291
+
292
+
293
+ def _andrews_lags(u):
294
+ """Andrews (1991) AR(1) plug-in bandwidth for the Bartlett kernel, floor(1.1447 (alpha n)^(1/3))."""
295
+ den = u @ u
296
+ rho = float(np.clip((u[1:] @ u[:-1]) / den, -0.97, 0.97)) if den > 0 else 0.0
297
+ alpha = 4 * rho**2 / ((1 - rho) ** 2 * (1 + rho) ** 2)
298
+ return int(np.floor(1.1447 * (alpha * u.size) ** (1.0 / 3.0)))
299
+
300
+
301
+ def diebold_mariano(
302
+ loss_a, loss_b, h: int = 1, lags=None, prewhiten: bool = True, hln: bool = True, alternative: str = "two-sided"
303
+ ) -> DieboldMariano:
304
+ """Diebold-Mariano (1995) test of equal expected loss for two forecasts of the same targets.
305
+
306
+ loss_a, loss_b : (n,) losses on the SAME cases in time order (align them with `paired` first); non-finite values
307
+ raise rather than being dropped, because dropping them would shift the lags.
308
+ h : forecast horizon in time steps; the HAC uses at least h - 1 lags.
309
+ lags : Bartlett (Newey-West) truncation; None = the Andrews (1991) AR(1) plug-in,
310
+ floor(1.1447 (alpha n)^(1/3)), alpha = 4 rho^2 / ((1 - rho)^2 (1 + rho)^2).
311
+ prewhiten : AR(1) prewhitening (Andrews & Monahan 1992): fit d_t = phi d_t-1 + u_t (phi clipped to
312
+ +-0.97), take the HAC variance of u and recolour it by 1/(1 - phi)^2. Without it the Bartlett
313
+ estimate is biased low for persistent differentials and the test over-rejects (about 8-10 % at a
314
+ nominal 5 % for a lag-1 autocorrelation of 0.64 and n = 200-500; ~5-6 % with it).
315
+ hln : Harvey-Leybourne-Newbold (1997) factor sqrt((n + 1 - 2h + h(h - 1)/n) / n) and Student-t(n - 1)
316
+ p-values; False gives the original statistic with normal p-values.
317
+ alternative : "two-sided", "less" (a has lower loss), "greater"."""
318
+ from scipy import stats
319
+
320
+ a = np.asarray(loss_a, float).ravel()
321
+ b = np.asarray(loss_b, float).ravel()
322
+ if a.shape != b.shape:
323
+ raise ValueError("loss_a and loss_b must be paired (same length)")
324
+ if not (np.isfinite(a).all() and np.isfinite(b).all()):
325
+ raise ValueError("non-finite losses: pair the series first (verify.paired)")
326
+ d = a - b
327
+ n = d.size
328
+ if n < 3:
329
+ raise ValueError("need at least 3 paired cases")
330
+ u = d - d.mean()
331
+ phi = 0.0
332
+ if prewhiten:
333
+ den = u[:-1] @ u[:-1]
334
+ phi = float(np.clip((u[1:] @ u[:-1]) / den, -0.97, 0.97)) if den > 0 else 0.0
335
+ e = u[1:] - phi * u[:-1]
336
+ e = e - e.mean()
337
+ else:
338
+ e = u
339
+ if lags is None:
340
+ lags = _andrews_lags(e)
341
+ lags = int(min(max(lags, h - 1, 0), e.size - 1))
342
+ lrv = _hac(e, lags) / (1.0 - phi) ** 2
343
+ dbar = float(d.mean())
344
+ stat = dbar / np.sqrt(lrv / n) if lrv > 0 else (0.0 if dbar == 0 else np.sign(dbar) * np.inf)
345
+ if hln:
346
+ stat *= np.sqrt((n + 1 - 2 * h + h * (h - 1) / n) / n)
347
+ dist = stats.t(n - 1)
348
+ else:
349
+ dist = stats.norm
350
+ if alternative == "two-sided":
351
+ p = 2 * dist.sf(abs(stat))
352
+ elif alternative == "less":
353
+ p = dist.cdf(stat)
354
+ elif alternative == "greater":
355
+ p = dist.sf(stat)
356
+ else:
357
+ raise ValueError("alternative is 'two-sided', 'less' or 'greater'")
358
+ return DieboldMariano(float(stat), float(p), dbar, lags, n, float(lrv))
359
+
360
+
361
+ # --------------------------------------------------------------------------
362
+ # Pairing dated series
363
+ # --------------------------------------------------------------------------
364
+ @dataclass
365
+ class Paired:
366
+ """dates (datetime64) where both legs are finite, the two aligned value arrays, n = len(dates), and window = the
367
+ number of dates the comparison was meant to cover (calendar steps from start to end when both are given, else the
368
+ dates where either leg is finite). Show n/window with any statistic computed from a and b."""
369
+
370
+ dates: np.ndarray
371
+ a: np.ndarray
372
+ b: np.ndarray
373
+ n: int
374
+ window: int
375
+
376
+
377
+ def _series(s, unit):
378
+ if isinstance(s, dict):
379
+ d, v = list(s.keys()), list(s.values())
380
+ elif hasattr(s, "index") and hasattr(s, "values") and not isinstance(s, (tuple, list)):
381
+ d, v = list(s.index), list(np.asarray(s.values))
382
+ else:
383
+ d, v = s
384
+ d = np.array(
385
+ [np.datetime64(x, unit) if not isinstance(x, np.datetime64) else x.astype(f"datetime64[{unit}]") for x in d],
386
+ dtype=f"datetime64[{unit}]",
387
+ )
388
+ v = np.array([np.nan if x is None else x for x in v], dtype=float)
389
+ if d.shape != v.shape:
390
+ raise ValueError("dates and values differ in length")
391
+ if np.unique(d).size != d.size:
392
+ raise ValueError(f"duplicate dates at {unit} resolution")
393
+ ok = np.isfinite(v)
394
+ return d[ok], v[ok]
395
+
396
+
397
+ def paired(a, b, start=None, end=None, unit: str = "D") -> Paired:
398
+ """Align two dated series on the dates where BOTH have a finite value.
399
+
400
+ a, b : {date: value} mappings, (dates, values) pairs, or objects with .index and .values (a pandas Series);
401
+ dates anything numpy.datetime64 accepts (str, datetime, datetime64); None/NaN = missing
402
+ start, end : optional inclusive window
403
+ unit : date resolution ("D" days by default; "h" for hourly series)
404
+ Never difference or ratio two series over a window without this: each leg stops on its own day."""
405
+ da, va = _series(a, unit)
406
+ db, vb = _series(b, unit)
407
+ lo = None if start is None else np.datetime64(start, unit)
408
+ hi = None if end is None else np.datetime64(end, unit)
409
+
410
+ def inwin(d):
411
+ m = np.ones(d.shape, bool)
412
+ if lo is not None:
413
+ m &= d >= lo
414
+ if hi is not None:
415
+ m &= d <= hi
416
+ return m
417
+
418
+ ma, mb = inwin(da), inwin(db)
419
+ da, va, db, vb = da[ma], va[ma], db[mb], vb[mb]
420
+ common, ia, ib = np.intersect1d(da, db, return_indices=True)
421
+ if lo is not None and hi is not None:
422
+ window = int((hi - lo) / np.timedelta64(1, unit)) + 1
423
+ else:
424
+ window = int(np.union1d(da, db).size)
425
+ return Paired(common, va[ia], vb[ib], int(common.size), window)