funcsim 0.2.6__tar.gz → 0.2.8__tar.gz

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.
Files changed (38) hide show
  1. {funcsim-0.2.6/funcsim.egg-info → funcsim-0.2.8}/PKG-INFO +1 -1
  2. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/__init__.py +2 -2
  3. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/copcompare.py +5 -1
  4. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/copfit.py +126 -8
  5. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/core.py +91 -23
  6. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/dependence.py +528 -43
  7. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/distfit.py +76 -10
  8. funcsim-0.2.8/funcsim/kde.py +268 -0
  9. {funcsim-0.2.6 → funcsim-0.2.8/funcsim.egg-info}/PKG-INFO +1 -1
  10. {funcsim-0.2.6 → funcsim-0.2.8}/setup.py +1 -1
  11. funcsim-0.2.6/funcsim/kde.py +0 -183
  12. {funcsim-0.2.6 → funcsim-0.2.8}/LICENSE +0 -0
  13. {funcsim-0.2.6 → funcsim-0.2.8}/MANIFEST.in +0 -0
  14. {funcsim-0.2.6 → funcsim-0.2.8}/README.md +0 -0
  15. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/addist.py +0 -0
  16. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/conversions.py +0 -0
  17. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/cpt.py +0 -0
  18. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/cvmdist.py +0 -0
  19. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/ecdfgof.py +0 -0
  20. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/edf.py +0 -0
  21. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/eut.py +0 -0
  22. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/imanconover.py +0 -0
  23. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/ksdist.py +0 -0
  24. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/multicore.py +0 -0
  25. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/nearby.py +0 -0
  26. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/plotting.py +0 -0
  27. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/rdarrays.py +0 -0
  28. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/screen.py +0 -0
  29. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/shapiro.py +0 -0
  30. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/shrinkage.py +0 -0
  31. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/testsim.py +0 -0
  32. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/utests.py +0 -0
  33. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim/vect.py +0 -0
  34. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim.egg-info/SOURCES.txt +0 -0
  35. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim.egg-info/dependency_links.txt +0 -0
  36. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim.egg-info/requires.txt +0 -0
  37. {funcsim-0.2.6 → funcsim-0.2.8}/funcsim.egg-info/top_level.txt +0 -0
  38. {funcsim-0.2.6 → funcsim-0.2.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: funcsim
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: functional, simple stochastic simulation
5
5
  Home-page: https://github.com/h-bryant/funcsim
6
6
  Author: Henry Bryant
@@ -48,6 +48,6 @@ def version():
48
48
  Returns
49
49
  -------
50
50
  str
51
- The version string of the package (e.g., '0.2.6').
51
+ The version string of the package (e.g., '0.2.8').
52
52
  """
53
- return "0.2.6"
53
+ return "0.2.8"
@@ -56,7 +56,11 @@ def copcompare(udata: conversions.ArrayLike) -> pd.DataFrame:
56
56
  and maximum pseudo-likelihood for the Archimedean families. The
57
57
  Gaussian and Student's t log-likelihoods are therefore evaluated at
58
58
  moment-type estimates rather than at the exact maximum, so their
59
- criteria are conservative by a small amount. Standard information
59
+ criteria are conservative by a small amount. A Student's t row with
60
+ ``nu=inf`` means the profile likelihood peaked at the ceiling of the
61
+ degrees-of-freedom search (200), so the fitted copula is the Gaussian
62
+ copula with the Kendall's-tau correlation matrix; it still pays for the
63
+ extra parameter in the criteria. Standard information
60
64
  criteria applied to pseudo-likelihoods are approximate (Grønneberg and
61
65
  Hjort, 2014); treat differences of a few points as ties. Warnings
62
66
  issued while fitting a family (for example, negative dependence in data
@@ -41,9 +41,13 @@ THETA_MIN_FRANK = 1e-6
41
41
  # largest admissible theta (tau approx. 0.96-0.99 depending on family)
42
42
  _THETA_MAX = 100.0
43
43
 
44
- # search bounds for the Student's t degrees of freedom
44
+ # search bounds for the Student's t degrees of freedom. An optimum within
45
+ # _NU_GAUSS_FRAC of the ceiling is reported as nu = inf (the Gaussian
46
+ # copula with the same correlation matrix): the profile likelihood is flat
47
+ # there and the ceiling itself is an artifact of the search, not an estimate
45
48
  _NU_MIN = 2.0
46
49
  _NU_MAX = 200.0
50
+ _NU_GAUSS_FRAC = 0.99
47
51
 
48
52
 
49
53
  def _clipu(u: np.ndarray) -> np.ndarray:
@@ -135,12 +139,45 @@ def _eulerian(n: int) -> list:
135
139
  return row
136
140
 
137
141
 
142
+ def _log_expm1(x: np.ndarray) -> np.ndarray:
143
+ # log(exp(x) - 1) for x > 0, without overflow for large x
144
+ x = np.asarray(x, dtype=float)
145
+ small = np.log(np.expm1(np.minimum(x, 30.0)))
146
+ large = x + np.log1p(-np.exp(-np.maximum(x, 30.0)))
147
+ return np.where(x > 30.0, large, small)
148
+
149
+
150
+ def _frank_loglik_neg2(theta: float, u: np.ndarray) -> float:
151
+ # total bivariate Frank log-density for theta < 0. The density is the
152
+ # same expression as for theta > 0 (Nelsen, 2006, table 4.1, family 5);
153
+ # with s = -theta > 0 it reads, every factor positive,
154
+ # c(u, v) = s (e^s - 1) e^{s(u+v)}
155
+ # / [(e^s - 1) + (e^{su} - 1)(e^{sv} - 1)]^2,
156
+ # which is evaluated in log space
157
+ s = -theta
158
+ (M, d) = u.shape
159
+ log_es1 = float(_log_expm1(np.array(s)))
160
+ log_den = np.logaddexp(log_es1,
161
+ _log_expm1(s * u[:, 0]) + _log_expm1(s * u[:, 1]))
162
+ return float(M * (math.log(s) + log_es1) + s * np.sum(u)
163
+ - 2.0 * np.sum(log_den))
164
+
165
+
138
166
  def frank_loglik(theta: float, u: np.ndarray) -> float:
139
- # total Frank copula log-density over the rows of u, for theta > 0:
167
+ # total Frank copula log-density over the rows of u. theta = 0 is the
168
+ # independence limit (log-density zero); theta < 0 is admissible for two
169
+ # variables only. For theta > 0 and any d:
140
170
  # log c = (d-1) log(theta) + log Li_{-(d-1)}(z)
141
171
  # + sum_j [-theta u_j - log(1 - exp(-theta u_j))]
142
172
  # with log z = sum_j log(1 - exp(-theta u_j)) - (d-1) log(1 - exp(-theta))
143
173
  # and Li_{-n}(z) evaluated via Eulerian numbers, in log space
174
+ if theta == 0.0:
175
+ return 0.0
176
+ if theta < 0.0:
177
+ if u.shape[1] != 2:
178
+ raise ValueError("the Frank copula with theta < 0 (negative "
179
+ "dependence) is defined only for two variables")
180
+ return _frank_loglik_neg2(theta, u)
144
181
  (M, d) = u.shape
145
182
  n = d - 1
146
183
  log1me_u = np.log(-np.expm1(-theta * u)) # log(1 - exp(-theta u))
@@ -160,11 +197,72 @@ def _debye1(x: float) -> float:
160
197
  # Debye function of order 1: (1/x) * integral_0^x t/(e^t - 1) dt
161
198
  if x < 1e-8:
162
199
  return 1.0 - x / 4.0
200
+ if x > 200.0:
201
+ # beyond the quadrature's range (never reached by the fitting code):
202
+ # the integrand's mass lies below t ~ 40, so the integral is pi^2/6
203
+ # less the tail integral_x^inf t/(e^t - 1) dt = (x + 1) e^-x (to
204
+ # within e^-2x). This agrees with the quadrature to one ulp at
205
+ # x = 200 and stays finite where expm1 would overflow
206
+ return (math.pi ** 2 / 6.0 - (x + 1.0) * math.exp(-x)) / x
163
207
  val = integrate.quad(lambda t: t / math.expm1(t) if t > 0.0 else 1.0,
164
208
  0.0, x)[0]
165
209
  return val / x
166
210
 
167
211
 
212
+ # Bernoulli numbers B_2, B_4, ..., B_14, for the small-theta series of
213
+ # Frank's Kendall's tau
214
+ _BERNOULLI_EVEN = (1.0 / 6.0, -1.0 / 30.0, 1.0 / 42.0, -1.0 / 30.0,
215
+ 5.0 / 66.0, -691.0 / 2730.0, 7.0 / 6.0)
216
+
217
+
218
+ def frank_tau(theta: float) -> float:
219
+ # Kendall's tau of the Frank copula, tau = 1 - (4/theta)(1 - D_1(theta)),
220
+ # an odd function of theta with tau(0) = 0 (Nelsen, 2006, eq. 5.1.5).
221
+ # Below |theta| = 0.5 the direct formula loses digits to cancellation
222
+ # (1 - D_1 ~ theta/4), so the power series
223
+ # tau = 4 sum_k B_2k theta^(2k-1) / ((2k+1)(2k)!)
224
+ # = theta/9 - theta^3/900 + theta^5/52920 - ...
225
+ # is used there; its truncation error is below 1e-16 at |theta| = 0.5
226
+ s = abs(float(theta))
227
+ if s == 0.0:
228
+ return 0.0
229
+ if s <= 0.5:
230
+ t = 4.0 * sum(b * s ** (2 * k - 1)
231
+ / ((2 * k + 1) * math.factorial(2 * k))
232
+ for k, b in enumerate(_BERNOULLI_EVEN, start=1))
233
+ else:
234
+ t = 1.0 - (4.0 / s) * (1.0 - _debye1(s))
235
+ return math.copysign(t, theta)
236
+
237
+
238
+ def frank_theta_from_tau(tau: float) -> float:
239
+ # exact inverse of frank_tau for tau in (-1, 0) or (0, 1): theta takes
240
+ # the sign of tau, and |theta| is found by bracketed root-finding on
241
+ # |tau|. tau(theta) is concave with slope 1/9 at the origin, so
242
+ # theta = 9 |tau| is a lower bracket; the upper bracket is doubled until
243
+ # it overshoots. Unlike frank_theta0 (the fitting start value, which
244
+ # clamps tau at 0.96 and theta at _THETA_MAX) nothing is clamped here
245
+ t = abs(float(tau))
246
+ if not (0.0 < t < 1.0):
247
+ raise ValueError("Kendall's tau for a Frank copula must lie strictly "
248
+ "between -1 and 1 and be nonzero (tau = 0 is "
249
+ "independence, which the Frank copula reaches only "
250
+ f"as theta -> 0); got {tau}")
251
+
252
+ def f(th):
253
+ return frank_tau(th) - t
254
+
255
+ lo = 9.0 * t
256
+ hi = max(2.0 * lo, 1.0)
257
+ while f(hi) < 0.0:
258
+ hi *= 2.0
259
+ if hi > 1e300:
260
+ raise ValueError(f"cannot represent a Frank copula with "
261
+ f"Kendall's tau {tau}")
262
+ theta = optimize.brentq(f, lo, hi, xtol=1e-14, maxiter=500)
263
+ return math.copysign(theta, tau)
264
+
265
+
168
266
  def clayton_theta0(tau: float) -> float:
169
267
  # Kendall's-tau inversion for Clayton: theta = 2 tau / (1 - tau)
170
268
  t = min(tau, 0.97)
@@ -257,9 +355,19 @@ def fit_gumbel(u: np.ndarray) -> Tuple[float, float, bool]:
257
355
 
258
356
 
259
357
  def fit_frank(u: np.ndarray) -> Tuple[float, float, bool]:
260
- # fit Frank theta; returns (theta, mean pairwise tau, clamped flag)
358
+ # fit Frank theta; returns (theta, mean pairwise tau, clamped flag).
359
+ # With two variables negative dependence is admissible (theta < 0): the
360
+ # log-likelihood is mirrored so that the positive-side machinery (start
361
+ # value, bracket, floor and ceiling) applies to -theta. With more
362
+ # variables the Frank copula exists only for theta > 0, and non-positive
363
+ # dependence is clamped to the floor
261
364
  uc = _clipu(u)
262
365
  tb = taubar(uc)
366
+ if tb < 0.0 and uc.shape[1] == 2:
367
+ theta0 = frank_theta0(-tb)
368
+ theta = _fit_theta(lambda th, uu: frank_loglik(-th, uu), uc, theta0,
369
+ THETA_MIN_FRANK, _THETA_MAX)
370
+ return (-theta, tb, False)
263
371
  if tb <= 0.0:
264
372
  return (THETA_MIN_FRANK, tb, True)
265
373
  theta0 = frank_theta0(tb)
@@ -284,7 +392,10 @@ def gauss_loglik(R: np.ndarray, u: np.ndarray) -> float:
284
392
  def student_loglik(R: np.ndarray, nu: float, u: np.ndarray) -> float:
285
393
  # total Student's t copula log-density over the rows of u for the
286
394
  # correlation matrix R and degrees of freedom nu:
287
- # log c(u) = log t_{nu,R}(x) - sum_k log t_nu(x_k), with x = t_nu^-1(u)
395
+ # log c(u) = log t_{nu,R}(x) - sum_k log t_nu(x_k), with x = t_nu^-1(u).
396
+ # nu = inf is the Gaussian copula with the same R
397
+ if math.isinf(nu):
398
+ return gauss_loglik(R, u)
288
399
  uc = _clipu(u)
289
400
  x = stats.t.ppf(uc, df=nu)
290
401
  d = R.shape[0]
@@ -292,12 +403,15 @@ def student_loglik(R: np.ndarray, nu: float, u: np.ndarray) -> float:
292
403
  return float(np.sum(joint) - np.sum(stats.t.logpdf(x, df=nu)))
293
404
 
294
405
 
295
- def fit_student(u: np.ndarray) -> Tuple[np.ndarray, float]:
296
- # fit a Student's t copula; returns (correlation matrix, deg. of freedom).
406
+ def fit_student(u: np.ndarray) -> Tuple[np.ndarray, float, float]:
407
+ # fit a Student's t copula; returns (correlation matrix, deg. of freedom,
408
+ # log-likelihood gain of the fitted nu over the Gaussian limit nu = inf
409
+ # at the same correlation matrix).
297
410
  # stage 1: pairwise Kendall's tau -> rho_ij = sin(pi tau_ij / 2),
298
411
  # corrected to the nearest positive definite correlation matrix.
299
412
  # stage 2: profile maximum pseudo-likelihood for nu, searching on a
300
- # log scale (the likelihood is flat in nu near the Gaussian limit)
413
+ # log scale (the likelihood is flat in nu near the Gaussian limit). An
414
+ # optimum at the search ceiling is reported as nu = inf, with zero gain
301
415
  uc = _clipu(u)
302
416
  R = np.sin(0.5 * np.pi * taumatrix(uc))
303
417
  np.fill_diagonal(R, 1.0)
@@ -321,4 +435,8 @@ def fit_student(u: np.ndarray) -> Tuple[np.ndarray, float]:
321
435
  math.log(_NU_MAX)),
322
436
  method="bounded")
323
437
  nu = float(math.exp(res.x)) if res.success else _NU_MAX
324
- return (R, nu)
438
+ if nu > _NU_GAUSS_FRAC * _NU_MAX:
439
+ return (R, math.inf, 0.0)
440
+ ll_gauss = gauss_loglik(R, uc)
441
+ gain = -negll(math.log(nu)) - ll_gauss
442
+ return (R, nu, float(gain) if np.isfinite(gain) else math.nan)
@@ -12,18 +12,55 @@ from typing import Optional
12
12
  import inspect
13
13
 
14
14
 
15
- def _get_arg_count(func):
16
- sig = inspect.signature(func)
17
- params = sig.parameters.values()
18
-
19
- # Count only parameters that are positional or keyword
20
- # (excluding *args and **kwargs)
21
- return sum(
22
- 1 for p in params
23
- if p.kind in (inspect.Parameter.POSITIONAL_ONLY,
24
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
25
- inspect.Parameter.KEYWORD_ONLY)
26
- )
15
+ def _stepf_arity(func):
16
+ # how many positional arguments simulate passes to the user's trial or
17
+ # step function: 1 (the draw generator) or 2 (the generator and the
18
+ # per-trial history). The rule, stated in the simulate docstring:
19
+ # * the first parameter receives the generator;
20
+ # * a second parameter receives the history if it has no default or
21
+ # is named 'hist'. A second parameter with a default and another
22
+ # name is a setting (e.g., 'scale=1.0') left at its default, and the
23
+ # function is called with the generator alone;
24
+ # * any further parameters must have defaults; keyword-only
25
+ # parameters (as functools.partial produces) must have defaults;
26
+ # * a parameter named 'hist' anywhere but second is ambiguous.
27
+ # Formerly every parameter was counted, so 'def f(ugen, scale=1.0)'
28
+ # received the history object as 'scale' and failed far from the cause
29
+ P = inspect.Parameter
30
+ try:
31
+ params = list(inspect.signature(func).parameters.values())
32
+ except (TypeError, ValueError):
33
+ raise ValueError('"f" must be a function whose signature can be '
34
+ 'inspected') from None
35
+ positional = [p for p in params
36
+ if p.kind in (P.POSITIONAL_ONLY, P.POSITIONAL_OR_KEYWORD)]
37
+ required_kw = [p.name for p in params
38
+ if p.kind is P.KEYWORD_ONLY and p.default is P.empty]
39
+ if required_kw:
40
+ raise ValueError(f'"f" has keyword-only parameter(s) without a '
41
+ f'default ({", ".join(required_kw)}); simulate '
42
+ f'supplies only the draw generator and the history, '
43
+ f'positionally. Bind such parameters with '
44
+ f'functools.partial or give them defaults')
45
+ if not positional:
46
+ raise ValueError('"f" must take the draw generator as its first '
47
+ 'parameter')
48
+ required = [p.name for p in positional if p.default is P.empty]
49
+ if len(required) > 2:
50
+ raise ValueError(f'"f" has {len(required)} parameters without '
51
+ f'defaults ({", ".join(required)}); simulate '
52
+ f'supplies at most two: the draw generator and the '
53
+ f'history. Give the others defaults or bind them '
54
+ f'with functools.partial')
55
+ if any(p.name == "hist" and i != 1 for i, p in enumerate(positional)):
56
+ raise ValueError('a parameter of "f" named "hist" receives the '
57
+ 'per-trial history and must therefore be the second '
58
+ 'parameter')
59
+ if len(required) == 2:
60
+ return 2
61
+ if len(positional) >= 2 and positional[1].name == "hist":
62
+ return 2
63
+ return 1
27
64
 
28
65
 
29
66
  def _checkhist0(hist0):
@@ -197,6 +234,17 @@ def simulate(f: Callable[[Generator[int, float, None],
197
234
  times `f` calls ``next(ugen)`` must be the same in every trial and
198
235
  step, because draws are pre-allocated across trials for stratified
199
236
  sampling.
237
+
238
+ Which parameters receive what is decided from the signature of `f`:
239
+ the first parameter receives the generator; a second parameter
240
+ receives the history if it has no default or is named ``hist``,
241
+ whereas a second parameter with a default and another name (for
242
+ example ``scale=1.0``) is treated as a setting, left at its default,
243
+ and `f` is called with the generator alone. Any further parameters
244
+ must have defaults, as must keyword-only parameters (which is what
245
+ :func:`functools.partial` produces when binding a scenario
246
+ parameter by name). A parameter named ``hist`` in any position but
247
+ the second is rejected as ambiguous.
200
248
  ntrials : int, optional
201
249
  The number of trials to perform. Default is 500.
202
250
  nsteps : int, optional
@@ -234,14 +282,37 @@ def simulate(f: Callable[[Generator[int, float, None],
234
282
  Raises
235
283
  ------
236
284
  ValueError
237
- If `hist0` is malformed, `f` is not callable or takes more than two
238
- arguments, `f` does not return a dict keyed by strings, or `sampling`
239
- is not one of 'lh' or 'mc'.
285
+ If `hist0` is malformed; if `f` is not callable, has more than two
286
+ parameters without defaults, has a keyword-only parameter without a
287
+ default, or has a parameter named ``hist`` that is not its second;
288
+ if `f` does not return a dict keyed by strings; or if `sampling` is
289
+ not one of 'lh' or 'mc'.
240
290
  RuntimeError
241
291
  If `f` consumes a different number of draws in some trial or step
242
292
  than it did when first probed.
243
293
  funcsim.rdarrays.MissingValue
244
294
  If `f` recalls a lagged value that is not available.
295
+
296
+ Notes
297
+ -----
298
+ **Draw allocation and common random numbers.** Before any trial runs,
299
+ the draws are allocated as one column of `ntrials` values per uniform
300
+ that `f` consumes over all steps of a trial, generated in consumption
301
+ order from a single :class:`numpy.random.Generator` seeded with `seed`
302
+ (stratified across trials when `sampling` is ``'lh'``). The j-th
303
+ uniform consumed in trial r therefore depends only on `seed`, `ntrials`,
304
+ `sampling`, j, and r: not on how many uniforms `f` consumes in total,
305
+ on what `f` computes from them, or on `multi`. This is a documented
306
+ guarantee. Its consequence is common random numbers across scenarios:
307
+ two trial functions that consume K and K + 1 uniforms (with `nsteps` of
308
+ 1) receive identical first K uniforms in every trial under the same
309
+ `seed` and `ntrials`, so differences in their outputs are due to the
310
+ extra draw and the model alone. With several steps, step s of trial r
311
+ receives uniforms sK + 1 through (s + 1)K of that trial's stream, where
312
+ K is the number consumed per step; two step functions with different
313
+ per-step counts therefore share their draws only in the first step.
314
+ With `stdnorm` True the same statements hold for the standard normal
315
+ draws, which are an elementwise transform of the uniforms.
245
316
  """
246
317
  if hist0 is not None:
247
318
  _checkhist0(hist0)
@@ -276,16 +347,13 @@ def simulate(f: Callable[[Generator[int, float, None],
276
347
  if not isinstance(f, Callable):
277
348
  raise ValueError('"f" must be a callable function')
278
349
 
279
- # infer number of arguments in 'f'. If it takes only a single arg, wrap it
280
- # in an outer func that takes "hist" as a second arg
281
- numb_f_args = _get_arg_count(f)
282
- if numb_f_args == 1:
283
- stepf = functools.partial(_stepf_1arg, f)
284
- elif numb_f_args == 2:
350
+ # decide from the signature of 'f' whether it takes the history as a
351
+ # second argument; if not, wrap it in an outer func that does
352
+ if _stepf_arity(f) == 2:
285
353
  stepf = f
286
354
  else:
287
- raise ValueError('"f" should take two arguments at most')
288
-
355
+ stepf = functools.partial(_stepf_1arg, f)
356
+
289
357
  # indexes for the final output xr.DataArray
290
358
  varNames = hist0.indexes['variables']
291
359
  namePositionsPrelim = {nm: i for i, nm in enumerate(varNames)}