Coverage for /usr/lib/python3/dist-packages/scipy/stats/_fit.py: 8%
416 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
1import warnings
2from collections import namedtuple
3import numpy as np
4from scipy import optimize, stats
5from scipy._lib._util import check_random_state
8def _combine_bounds(name, user_bounds, shape_domain, integral):
9 """Intersection of user-defined bounds and distribution PDF/PMF domain"""
11 user_bounds = np.atleast_1d(user_bounds)
13 if user_bounds[0] > user_bounds[1]:
14 message = (f"There are no values for `{name}` on the interval "
15 f"{list(user_bounds)}.")
16 raise ValueError(message)
18 bounds = (max(user_bounds[0], shape_domain[0]),
19 min(user_bounds[1], shape_domain[1]))
21 if integral and (np.ceil(bounds[0]) > np.floor(bounds[1])):
22 message = (f"There are no integer values for `{name}` on the interval "
23 f"defined by the user-provided bounds and the domain "
24 "of the distribution.")
25 raise ValueError(message)
26 elif not integral and (bounds[0] > bounds[1]):
27 message = (f"There are no values for `{name}` on the interval "
28 f"defined by the user-provided bounds and the domain "
29 "of the distribution.")
30 raise ValueError(message)
32 if not np.all(np.isfinite(bounds)):
33 message = (f"The intersection of user-provided bounds for `{name}` "
34 f"and the domain of the distribution is not finite. Please "
35 f"provide finite bounds for shape `{name}` in `bounds`.")
36 raise ValueError(message)
38 return bounds
41class FitResult:
42 r"""Result of fitting a discrete or continuous distribution to data
44 Attributes
45 ----------
46 params : namedtuple
47 A namedtuple containing the maximum likelihood estimates of the
48 shape parameters, location, and (if applicable) scale of the
49 distribution.
50 success : bool or None
51 Whether the optimizer considered the optimization to terminate
52 successfully or not.
53 message : str or None
54 Any status message provided by the optimizer.
56 """
58 def __init__(self, dist, data, discrete, res):
59 self._dist = dist
60 self._data = data
61 self.discrete = discrete
62 self.pxf = getattr(dist, "pmf", None) or getattr(dist, "pdf", None)
64 shape_names = [] if dist.shapes is None else dist.shapes.split(", ")
65 if not discrete:
66 FitParams = namedtuple('FitParams', shape_names + ['loc', 'scale'])
67 else:
68 FitParams = namedtuple('FitParams', shape_names + ['loc'])
70 self.params = FitParams(*res.x)
72 # Optimizer can report success even when nllf is infinite
73 if res.success and not np.isfinite(self.nllf()):
74 res.success = False
75 res.message = ("Optimization converged to parameter values that "
76 "are inconsistent with the data.")
77 self.success = getattr(res, "success", None)
78 self.message = getattr(res, "message", None)
80 def __repr__(self):
81 keys = ["params", "success", "message"]
82 m = max(map(len, keys)) + 1
83 return '\n'.join([key.rjust(m) + ': ' + repr(getattr(self, key))
84 for key in keys if getattr(self, key) is not None])
86 def nllf(self, params=None, data=None):
87 """Negative log-likelihood function
89 Evaluates the negative of the log-likelihood function of the provided
90 data at the provided parameters.
92 Parameters
93 ----------
94 params : tuple, optional
95 The shape parameters, location, and (if applicable) scale of the
96 distribution as a single tuple. Default is the maximum likelihood
97 estimates (``self.params``).
98 data : array_like, optional
99 The data for which the log-likelihood function is to be evaluated.
100 Default is the data to which the distribution was fit.
102 Returns
103 -------
104 nllf : float
105 The negative of the log-likelihood function.
107 """
108 params = params if params is not None else self.params
109 data = data if data is not None else self._data
110 return self._dist.nnlf(theta=params, x=data)
112 def plot(self, ax=None, *, plot_type="hist"):
113 """Visually compare the data against the fitted distribution.
115 Available only if ``matplotlib`` is installed.
117 Parameters
118 ----------
119 ax : matplotlib.axes.Axes
120 Axes object to draw the plot onto, otherwise uses the current Axes.
121 plot_type : {"hist", "qq", "pp", "cdf"}
122 Type of plot to draw. Options include:
124 - "hist": Superposes the PDF/PMF of the fitted distribution
125 over a normalized histogram of the data.
126 - "qq": Scatter plot of theoretical quantiles against the
127 empirical quantiles. Specifically, the x-coordinates are the
128 values of the fitted distribution PPF evaluated at the
129 percentiles ``(np.arange(1, n) - 0.5)/n``, where ``n`` is the
130 number of data points, and the y-coordinates are the sorted
131 data points.
132 - "pp": Scatter plot of theoretical percentiles against the
133 observed percentiles. Specifically, the x-coordinates are the
134 percentiles ``(np.arange(1, n) - 0.5)/n``, where ``n`` is
135 the number of data points, and the y-coordinates are the values
136 of the fitted distribution CDF evaluated at the sorted
137 data points.
138 - "cdf": Superposes the CDF of the fitted distribution over the
139 empirical CDF. Specifically, the x-coordinates of the empirical
140 CDF are the sorted data points, and the y-coordinates are the
141 percentiles ``(np.arange(1, n) - 0.5)/n``, where ``n`` is
142 the number of data points.
144 Returns
145 -------
146 ax : matplotlib.axes.Axes
147 The matplotlib Axes object on which the plot was drawn.
148 """
149 try:
150 import matplotlib # noqa
151 except ModuleNotFoundError as exc:
152 message = "matplotlib must be installed to use method `plot`."
153 raise ModuleNotFoundError(message) from exc
155 plots = {'histogram': self._hist_plot, 'qq': self._qq_plot,
156 'pp': self._pp_plot, 'cdf': self._cdf_plot,
157 'hist': self._hist_plot}
158 if plot_type.lower() not in plots:
159 message = f"`plot_type` must be one of {set(plots.keys())}"
160 raise ValueError(message)
161 plot = plots[plot_type.lower()]
163 if ax is None:
164 import matplotlib.pyplot as plt
165 ax = plt.gca()
167 fit_params = np.atleast_1d(self.params)
169 return plot(ax=ax, fit_params=fit_params)
171 def _hist_plot(self, ax, fit_params):
172 from matplotlib.ticker import MaxNLocator
174 support = self._dist.support(*fit_params)
175 lb = support[0] if np.isfinite(support[0]) else min(self._data)
176 ub = support[1] if np.isfinite(support[1]) else max(self._data)
177 pxf = "PMF" if self.discrete else "PDF"
179 if self.discrete:
180 x = np.arange(lb, ub + 2)
181 y = self.pxf(x, *fit_params)
182 ax.vlines(x[:-1], 0, y[:-1], label='Fitted Distribution PMF',
183 color='C0')
184 options = dict(density=True, bins=x, align='left', color='C1')
185 ax.xaxis.set_major_locator(MaxNLocator(integer=True))
186 ax.set_xlabel('k')
187 ax.set_ylabel('PMF')
188 else:
189 x = np.linspace(lb, ub, 200)
190 y = self.pxf(x, *fit_params)
191 ax.plot(x, y, '--', label='Fitted Distribution PDF', color='C0')
192 options = dict(density=True, bins=50, align='mid', color='C1')
193 ax.set_xlabel('x')
194 ax.set_ylabel('PDF')
196 if len(self._data) > 50 or self.discrete:
197 ax.hist(self._data, label="Histogram of Data", **options)
198 else:
199 ax.plot(self._data, np.zeros_like(self._data), "*",
200 label='Data', color='C1')
202 ax.set_title(rf"Fitted $\tt {self._dist.name}$ {pxf} and Histogram")
203 ax.legend(*ax.get_legend_handles_labels())
204 return ax
206 def _qp_plot(self, ax, fit_params, qq):
207 data = np.sort(self._data)
208 ps = self._plotting_positions(len(self._data))
210 if qq:
211 qp = "Quantiles"
212 plot_type = 'Q-Q'
213 x = self._dist.ppf(ps, *fit_params)
214 y = data
215 else:
216 qp = "Percentiles"
217 plot_type = 'P-P'
218 x = ps
219 y = self._dist.cdf(data, *fit_params)
221 ax.plot(x, y, '.', label=f'Fitted Distribution {plot_type}',
222 color='C0', zorder=1)
223 xlim = ax.get_xlim()
224 ylim = ax.get_ylim()
225 lim = [min(xlim[0], ylim[0]), max(xlim[1], ylim[1])]
226 if not qq:
227 lim = max(lim[0], 0), min(lim[1], 1)
229 if self.discrete and qq:
230 q_min, q_max = int(lim[0]), int(lim[1]+1)
231 q_ideal = np.arange(q_min, q_max)
232 # q_ideal = np.unique(self._dist.ppf(ps, *fit_params))
233 ax.plot(q_ideal, q_ideal, 'o', label='Reference', color='k',
234 alpha=0.25, markerfacecolor='none', clip_on=True)
235 elif self.discrete and not qq:
236 # The intent of this is to match the plot that would be produced
237 # if x were continuous on [0, 1] and y were cdf(ppf(x)).
238 # It can be approximated by letting x = np.linspace(0, 1, 1000),
239 # but this might not look great when zooming in. The vertical
240 # portions are included to indicate where the transition occurs
241 # where the data completely obscures the horizontal portions.
242 p_min, p_max = lim
243 a, b = self._dist.support(*fit_params)
244 p_min = max(p_min, 0 if np.isfinite(a) else 1e-3)
245 p_max = min(p_max, 1 if np.isfinite(b) else 1-1e-3)
246 q_min, q_max = self._dist.ppf([p_min, p_max], *fit_params)
247 qs = np.arange(q_min-1, q_max+1)
248 ps = self._dist.cdf(qs, *fit_params)
249 ax.step(ps, ps, '-', label='Reference', color='k', alpha=0.25,
250 clip_on=True)
251 else:
252 ax.plot(lim, lim, '-', label='Reference', color='k', alpha=0.25,
253 clip_on=True)
255 ax.set_xlim(lim)
256 ax.set_ylim(lim)
257 ax.set_xlabel(rf"Fitted $\tt {self._dist.name}$ Theoretical {qp}")
258 ax.set_ylabel(f"Data {qp}")
259 ax.set_title(rf"Fitted $\tt {self._dist.name}$ {plot_type} Plot")
260 ax.legend(*ax.get_legend_handles_labels())
261 ax.set_aspect('equal')
262 return ax
264 def _qq_plot(self, **kwargs):
265 return self._qp_plot(qq=True, **kwargs)
267 def _pp_plot(self, **kwargs):
268 return self._qp_plot(qq=False, **kwargs)
270 def _plotting_positions(self, n, a=.5):
271 # See https://en.wikipedia.org/wiki/Q%E2%80%93Q_plot#Plotting_positions
272 k = np.arange(1, n+1)
273 return (k-a) / (n + 1 - 2*a)
275 def _cdf_plot(self, ax, fit_params):
276 data = np.sort(self._data)
277 ecdf = self._plotting_positions(len(self._data))
278 ls = '--' if len(np.unique(data)) < 30 else '.'
279 xlabel = 'k' if self.discrete else 'x'
280 ax.step(data, ecdf, ls, label='Empirical CDF', color='C1', zorder=0)
282 xlim = ax.get_xlim()
283 q = np.linspace(*xlim, 300)
284 tcdf = self._dist.cdf(q, *fit_params)
286 ax.plot(q, tcdf, label='Fitted Distribution CDF', color='C0', zorder=1)
287 ax.set_xlim(xlim)
288 ax.set_ylim(0, 1)
289 ax.set_xlabel(xlabel)
290 ax.set_ylabel("CDF")
291 ax.set_title(rf"Fitted $\tt {self._dist.name}$ and Empirical CDF")
292 handles, labels = ax.get_legend_handles_labels()
293 ax.legend(handles[::-1], labels[::-1])
294 return ax
297def fit(dist, data, bounds=None, *, guess=None, method='mle',
298 optimizer=optimize.differential_evolution):
299 r"""Fit a discrete or continuous distribution to data
301 Given a distribution, data, and bounds on the parameters of the
302 distribution, return maximum likelihood estimates of the parameters.
304 Parameters
305 ----------
306 dist : `scipy.stats.rv_continuous` or `scipy.stats.rv_discrete`
307 The object representing the distribution to be fit to the data.
308 data : 1D array_like
309 The data to which the distribution is to be fit. If the data contain
310 any of ``np.nan``, ``np.inf``, or -``np.inf``, the fit method will
311 raise a ``ValueError``.
312 bounds : dict or sequence of tuples, optional
313 If a dictionary, each key is the name of a parameter of the
314 distribution, and the corresponding value is a tuple containing the
315 lower and upper bound on that parameter. If the distribution is
316 defined only for a finite range of values of that parameter, no entry
317 for that parameter is required; e.g., some distributions have
318 parameters which must be on the interval [0, 1]. Bounds for parameters
319 location (``loc``) and scale (``scale``) are optional; by default,
320 they are fixed to 0 and 1, respectively.
322 If a sequence, element *i* is a tuple containing the lower and upper
323 bound on the *i*\ th parameter of the distribution. In this case,
324 bounds for *all* distribution shape parameters must be provided.
325 Optionally, bounds for location and scale may follow the
326 distribution shape parameters.
328 If a shape is to be held fixed (e.g. if it is known), the
329 lower and upper bounds may be equal. If a user-provided lower or upper
330 bound is beyond a bound of the domain for which the distribution is
331 defined, the bound of the distribution's domain will replace the
332 user-provided value. Similarly, parameters which must be integral
333 will be constrained to integral values within the user-provided bounds.
334 guess : dict or array_like, optional
335 If a dictionary, each key is the name of a parameter of the
336 distribution, and the corresponding value is a guess for the value
337 of the parameter.
339 If a sequence, element *i* is a guess for the *i*\ th parameter of the
340 distribution. In this case, guesses for *all* distribution shape
341 parameters must be provided.
343 If `guess` is not provided, guesses for the decision variables will
344 not be passed to the optimizer. If `guess` is provided, guesses for
345 any missing parameters will be set at the mean of the lower and
346 upper bounds. Guesses for parameters which must be integral will be
347 rounded to integral values, and guesses that lie outside the
348 intersection of the user-provided bounds and the domain of the
349 distribution will be clipped.
350 method : {'mle', 'mse'}
351 With ``method="mle"`` (default), the fit is computed by minimizing
352 the negative log-likelihood function. A large, finite penalty
353 (rather than infinite negative log-likelihood) is applied for
354 observations beyond the support of the distribution.
355 With ``method="mse"``, the fit is computed by minimizing
356 the negative log-product spacing function. The same penalty is applied
357 for observations beyond the support. We follow the approach of [1]_,
358 which is generalized for samples with repeated observations.
359 optimizer : callable, optional
360 `optimizer` is a callable that accepts the following positional
361 argument.
363 fun : callable
364 The objective function to be optimized. `fun` accepts one argument
365 ``x``, candidate shape parameters of the distribution, and returns
366 the objective function value given ``x``, `dist`, and the provided
367 `data`.
368 The job of `optimizer` is to find values of the decision variables
369 that minimizes `fun`.
371 `optimizer` must also accept the following keyword argument.
373 bounds : sequence of tuples
374 The bounds on values of the decision variables; each element will
375 be a tuple containing the lower and upper bound on a decision
376 variable.
378 If `guess` is provided, `optimizer` must also accept the following
379 keyword argument.
381 x0 : array_like
382 The guesses for each decision variable.
384 If the distribution has any shape parameters that must be integral or
385 if the distribution is discrete and the location parameter is not
386 fixed, `optimizer` must also accept the following keyword argument.
388 integrality : array_like of bools
389 For each decision variable, True if the decision variable
390 must be constrained to integer values and False if the decision
391 variable is continuous.
393 `optimizer` must return an object, such as an instance of
394 `scipy.optimize.OptimizeResult`, which holds the optimal values of
395 the decision variables in an attribute ``x``. If attributes
396 ``fun``, ``status``, or ``message`` are provided, they will be
397 included in the result object returned by `fit`.
399 Returns
400 -------
401 result : `~scipy.stats._result_classes.FitResult`
402 An object with the following fields.
404 params : namedtuple
405 A namedtuple containing the maximum likelihood estimates of the
406 shape parameters, location, and (if applicable) scale of the
407 distribution.
408 success : bool or None
409 Whether the optimizer considered the optimization to terminate
410 successfully or not.
411 message : str or None
412 Any status message provided by the optimizer.
414 The object has the following method:
416 nllf(params=None, data=None)
417 By default, the negative log-likehood function at the fitted
418 `params` for the given `data`. Accepts a tuple containing
419 alternative shapes, location, and scale of the distribution and
420 an array of alternative data.
422 plot(ax=None)
423 Superposes the PDF/PMF of the fitted distribution over a normalized
424 histogram of the data.
426 See Also
427 --------
428 rv_continuous, rv_discrete
430 Notes
431 -----
432 Optimization is more likely to converge to the maximum likelihood estimate
433 when the user provides tight bounds containing the maximum likelihood
434 estimate. For example, when fitting a binomial distribution to data, the
435 number of experiments underlying each sample may be known, in which case
436 the corresponding shape parameter ``n`` can be fixed.
438 References
439 ----------
440 .. [1] Shao, Yongzhao, and Marjorie G. Hahn. "Maximum product of spacings
441 method: a unified formulation with illustration of strong
442 consistency." Illinois Journal of Mathematics 43.3 (1999): 489-499.
444 Examples
445 --------
446 Suppose we wish to fit a distribution to the following data.
448 >>> import numpy as np
449 >>> from scipy import stats
450 >>> rng = np.random.default_rng()
451 >>> dist = stats.nbinom
452 >>> shapes = (5, 0.5)
453 >>> data = dist.rvs(*shapes, size=1000, random_state=rng)
455 Suppose we do not know how the data were generated, but we suspect that
456 it follows a negative binomial distribution with parameters *n* and *p*\.
457 (See `scipy.stats.nbinom`.) We believe that the parameter *n* was fewer
458 than 30, and we know that the parameter *p* must lie on the interval
459 [0, 1]. We record this information in a variable `bounds` and pass
460 this information to `fit`.
462 >>> bounds = [(0, 30), (0, 1)]
463 >>> res = stats.fit(dist, data, bounds)
465 `fit` searches within the user-specified `bounds` for the
466 values that best match the data (in the sense of maximum likelihood
467 estimation). In this case, it found shape values similar to those
468 from which the data were actually generated.
470 >>> res.params
471 FitParams(n=5.0, p=0.5028157644634368, loc=0.0) # may vary
473 We can visualize the results by superposing the probability mass function
474 of the distribution (with the shapes fit to the data) over a normalized
475 histogram of the data.
477 >>> import matplotlib.pyplot as plt # matplotlib must be installed to plot
478 >>> res.plot()
479 >>> plt.show()
481 Note that the estimate for *n* was exactly integral; this is because
482 the domain of the `nbinom` PMF includes only integral *n*, and the `nbinom`
483 object "knows" that. `nbinom` also knows that the shape *p* must be a
484 value between 0 and 1. In such a case - when the domain of the distribution
485 with respect to a parameter is finite - we are not required to specify
486 bounds for the parameter.
488 >>> bounds = {'n': (0, 30)} # omit parameter p using a `dict`
489 >>> res2 = stats.fit(dist, data, bounds)
490 >>> res2.params
491 FitParams(n=5.0, p=0.5016492009232932, loc=0.0) # may vary
493 If we wish to force the distribution to be fit with *n* fixed at 6, we can
494 set both the lower and upper bounds on *n* to 6. Note, however, that the
495 value of the objective function being optimized is typically worse (higher)
496 in this case.
498 >>> bounds = {'n': (6, 6)} # fix parameter `n`
499 >>> res3 = stats.fit(dist, data, bounds)
500 >>> res3.params
501 FitParams(n=6.0, p=0.5486556076755706, loc=0.0) # may vary
502 >>> res3.nllf() > res.nllf()
503 True # may vary
505 Note that the numerical results of the previous examples are typical, but
506 they may vary because the default optimizer used by `fit`,
507 `scipy.optimize.differential_evolution`, is stochastic. However, we can
508 customize the settings used by the optimizer to ensure reproducibility -
509 or even use a different optimizer entirely - using the `optimizer`
510 parameter.
512 >>> from scipy.optimize import differential_evolution
513 >>> rng = np.random.default_rng(767585560716548)
514 >>> def optimizer(fun, bounds, *, integrality):
515 ... return differential_evolution(fun, bounds, strategy='best2bin',
516 ... seed=rng, integrality=integrality)
517 >>> bounds = [(0, 30), (0, 1)]
518 >>> res4 = stats.fit(dist, data, bounds, optimizer=optimizer)
519 >>> res4.params
520 FitParams(n=5.0, p=0.5015183149259951, loc=0.0)
522 """
523 # --- Input Validation / Standardization --- #
524 user_bounds = bounds
525 user_guess = guess
527 # distribution input validation and information collection
528 if hasattr(dist, "pdf"): # can't use isinstance for types
529 default_bounds = {'loc': (0, 0), 'scale': (1, 1)}
530 discrete = False
531 elif hasattr(dist, "pmf"):
532 default_bounds = {'loc': (0, 0)}
533 discrete = True
534 else:
535 message = ("`dist` must be an instance of `rv_continuous` "
536 "or `rv_discrete.`")
537 raise ValueError(message)
539 try:
540 param_info = dist._param_info()
541 except AttributeError as e:
542 message = (f"Distribution `{dist.name}` is not yet supported by "
543 "`scipy.stats.fit` because shape information has "
544 "not been defined.")
545 raise ValueError(message) from e
547 # data input validation
548 data = np.asarray(data)
549 if data.ndim != 1:
550 message = "`data` must be exactly one-dimensional."
551 raise ValueError(message)
552 if not (np.issubdtype(data.dtype, np.number)
553 and np.all(np.isfinite(data))):
554 message = "All elements of `data` must be finite numbers."
555 raise ValueError(message)
557 # bounds input validation and information collection
558 n_params = len(param_info)
559 n_shapes = n_params - (1 if discrete else 2)
560 param_list = [param.name for param in param_info]
561 param_names = ", ".join(param_list)
562 shape_names = ", ".join(param_list[:n_shapes])
564 if user_bounds is None:
565 user_bounds = {}
567 if isinstance(user_bounds, dict):
568 default_bounds.update(user_bounds)
569 user_bounds = default_bounds
570 user_bounds_array = np.empty((n_params, 2))
571 for i in range(n_params):
572 param_name = param_info[i].name
573 user_bound = user_bounds.pop(param_name, None)
574 if user_bound is None:
575 user_bound = param_info[i].domain
576 user_bounds_array[i] = user_bound
577 if user_bounds:
578 message = ("Bounds provided for the following unrecognized "
579 f"parameters will be ignored: {set(user_bounds)}")
580 warnings.warn(message, RuntimeWarning, stacklevel=2)
582 else:
583 try:
584 user_bounds = np.asarray(user_bounds, dtype=float)
585 if user_bounds.size == 0:
586 user_bounds = np.empty((0, 2))
587 except ValueError as e:
588 message = ("Each element of a `bounds` sequence must be a tuple "
589 "containing two elements: the lower and upper bound of "
590 "a distribution parameter.")
591 raise ValueError(message) from e
592 if (user_bounds.ndim != 2 or user_bounds.shape[1] != 2):
593 message = ("Each element of `bounds` must be a tuple specifying "
594 "the lower and upper bounds of a shape parameter")
595 raise ValueError(message)
596 if user_bounds.shape[0] < n_shapes:
597 message = (f"A `bounds` sequence must contain at least {n_shapes} "
598 "elements: tuples specifying the lower and upper "
599 f"bounds of all shape parameters {shape_names}.")
600 raise ValueError(message)
601 if user_bounds.shape[0] > n_params:
602 message = ("A `bounds` sequence may not contain more than "
603 f"{n_params} elements: tuples specifying the lower and "
604 "upper bounds of distribution parameters "
605 f"{param_names}.")
606 raise ValueError(message)
608 user_bounds_array = np.empty((n_params, 2))
609 user_bounds_array[n_shapes:] = list(default_bounds.values())
610 user_bounds_array[:len(user_bounds)] = user_bounds
612 user_bounds = user_bounds_array
613 validated_bounds = []
614 for i in range(n_params):
615 name = param_info[i].name
616 user_bound = user_bounds_array[i]
617 param_domain = param_info[i].domain
618 integral = param_info[i].integrality
619 combined = _combine_bounds(name, user_bound, param_domain, integral)
620 validated_bounds.append(combined)
622 bounds = np.asarray(validated_bounds)
623 integrality = [param.integrality for param in param_info]
625 # guess input validation
627 if user_guess is None:
628 guess_array = None
629 elif isinstance(user_guess, dict):
630 default_guess = {param.name: np.mean(bound)
631 for param, bound in zip(param_info, bounds)}
632 unrecognized = set(user_guess) - set(default_guess)
633 if unrecognized:
634 message = ("Guesses provided for the following unrecognized "
635 f"parameters will be ignored: {unrecognized}")
636 warnings.warn(message, RuntimeWarning, stacklevel=2)
637 default_guess.update(user_guess)
639 message = ("Each element of `guess` must be a scalar "
640 "guess for a distribution parameter.")
641 try:
642 guess_array = np.asarray([default_guess[param.name]
643 for param in param_info], dtype=float)
644 except ValueError as e:
645 raise ValueError(message) from e
647 else:
648 message = ("Each element of `guess` must be a scalar "
649 "guess for a distribution parameter.")
650 try:
651 user_guess = np.asarray(user_guess, dtype=float)
652 except ValueError as e:
653 raise ValueError(message) from e
654 if user_guess.ndim != 1:
655 raise ValueError(message)
656 if user_guess.shape[0] < n_shapes:
657 message = (f"A `guess` sequence must contain at least {n_shapes} "
658 "elements: scalar guesses for the distribution shape "
659 f"parameters {shape_names}.")
660 raise ValueError(message)
661 if user_guess.shape[0] > n_params:
662 message = ("A `guess` sequence may not contain more than "
663 f"{n_params} elements: scalar guesses for the "
664 f"distribution parameters {param_names}.")
665 raise ValueError(message)
667 guess_array = np.mean(bounds, axis=1)
668 guess_array[:len(user_guess)] = user_guess
670 if guess_array is not None:
671 guess_rounded = guess_array.copy()
673 guess_rounded[integrality] = np.round(guess_rounded[integrality])
674 rounded = np.where(guess_rounded != guess_array)[0]
675 for i in rounded:
676 message = (f"Guess for parameter `{param_info[i].name}` "
677 f"rounded from {guess_array[i]} to {guess_rounded[i]}.")
678 warnings.warn(message, RuntimeWarning, stacklevel=2)
680 guess_clipped = np.clip(guess_rounded, bounds[:, 0], bounds[:, 1])
681 clipped = np.where(guess_clipped != guess_rounded)[0]
682 for i in clipped:
683 message = (f"Guess for parameter `{param_info[i].name}` "
684 f"clipped from {guess_rounded[i]} to "
685 f"{guess_clipped[i]}.")
686 warnings.warn(message, RuntimeWarning, stacklevel=2)
688 guess = guess_clipped
689 else:
690 guess = None
692 # --- Fitting --- #
693 def nllf(free_params, data=data): # bind data NOW
694 with np.errstate(invalid='ignore', divide='ignore'):
695 return dist._penalized_nnlf(free_params, data)
697 def nlpsf(free_params, data=data): # bind data NOW
698 with np.errstate(invalid='ignore', divide='ignore'):
699 return dist._penalized_nlpsf(free_params, data)
701 methods = {'mle': nllf, 'mse': nlpsf}
702 objective = methods[method.lower()]
704 with np.errstate(invalid='ignore', divide='ignore'):
705 kwds = {}
706 if bounds is not None:
707 kwds['bounds'] = bounds
708 if np.any(integrality):
709 kwds['integrality'] = integrality
710 if guess is not None:
711 kwds['x0'] = guess
712 res = optimizer(objective, **kwds)
714 return FitResult(dist, data, discrete, res)
717GoodnessOfFitResult = namedtuple('GoodnessOfFitResult',
718 ('fit_result', 'statistic', 'pvalue',
719 'null_distribution'))
722def goodness_of_fit(dist, data, *, known_params=None, fit_params=None,
723 guessed_params=None, statistic='ad', n_mc_samples=9999,
724 random_state=None):
725 r"""
726 Perform a goodness of fit test comparing data to a distribution family.
728 Given a distribution family and data, perform a test of the null hypothesis
729 that the data were drawn from a distribution in that family. Any known
730 parameters of the distribution may be specified. Remaining parameters of
731 the distribution will be fit to the data, and the p-value of the test
732 is computed accordingly. Several statistics for comparing the distribution
733 to data are available.
735 Parameters
736 ----------
737 dist : `scipy.stats.rv_continuous`
738 The object representing the distribution family under the null
739 hypothesis.
740 data : 1D array_like
741 Finite, uncensored data to be tested.
742 known_params : dict, optional
743 A dictionary containing name-value pairs of known distribution
744 parameters. Monte Carlo samples are randomly drawn from the
745 null-hypothesized distribution with these values of the parameters.
746 Before the statistic is evaluated for each Monte Carlo sample, only
747 remaining unknown parameters of the null-hypothesized distribution
748 family are fit to the samples; the known parameters are held fixed.
749 If all parameters of the distribution family are known, then the step
750 of fitting the distribution family to each sample is omitted.
751 fit_params : dict, optional
752 A dictionary containing name-value pairs of distribution parameters
753 that have already been fit to the data, e.g. using `scipy.stats.fit`
754 or the ``fit`` method of `dist`. Monte Carlo samples are drawn from the
755 null-hypothesized distribution with these specified values of the
756 parameter. On those Monte Carlo samples, however, these and all other
757 unknown parameters of the null-hypothesized distribution family are
758 fit before the statistic is evaluated.
759 guessed_params : dict, optional
760 A dictionary containing name-value pairs of distribution parameters
761 which have been guessed. These parameters are always considered as
762 free parameters and are fit both to the provided `data` as well as
763 to the Monte Carlo samples drawn from the null-hypothesized
764 distribution. The purpose of these `guessed_params` is to be used as
765 initial values for the numerical fitting procedure.
766 statistic : {"ad", "ks", "cvm", "filliben"}, optional
767 The statistic used to compare data to a distribution after fitting
768 unknown parameters of the distribution family to the data. The
769 Anderson-Darling ("ad") [1]_, Kolmogorov-Smirnov ("ks") [1]_,
770 Cramer-von Mises ("cvm") [1]_, and Filliben ("filliben") [7]_
771 statistics are available.
772 n_mc_samples : int, default: 9999
773 The number of Monte Carlo samples drawn from the null hypothesized
774 distribution to form the null distribution of the statistic. The
775 sample size of each is the same as the given `data`.
776 random_state : {None, int, `numpy.random.Generator`,
777 `numpy.random.RandomState`}, optional
779 Pseudorandom number generator state used to generate the Monte Carlo
780 samples.
782 If `random_state` is ``None`` (default), the
783 `numpy.random.RandomState` singleton is used.
784 If `random_state` is an int, a new ``RandomState`` instance is used,
785 seeded with `random_state`.
786 If `random_state` is already a ``Generator`` or ``RandomState``
787 instance, then the provided instance is used.
789 Returns
790 -------
791 res : GoodnessOfFitResult
792 An object with the following attributes.
794 fit_result : `~scipy.stats._result_classes.FitResult`
795 An object representing the fit of the provided `dist` to `data`.
796 This object includes the values of distribution family parameters
797 that fully define the null-hypothesized distribution, that is,
798 the distribution from which Monte Carlo samples are drawn.
799 statistic : float
800 The value of the statistic comparing provided `data` to the
801 null-hypothesized distribution.
802 pvalue : float
803 The proportion of elements in the null distribution with
804 statistic values at least as extreme as the statistic value of the
805 provided `data`.
806 null_distribution : ndarray
807 The value of the statistic for each Monte Carlo sample
808 drawn from the null-hypothesized distribution.
810 Notes
811 -----
812 This is a generalized Monte Carlo goodness-of-fit procedure, special cases
813 of which correspond with various Anderson-Darling tests, Lilliefors' test,
814 etc. The test is described in [2]_, [3]_, and [4]_ as a parametric
815 bootstrap test. This is a Monte Carlo test in which parameters that
816 specify the distribution from which samples are drawn have been estimated
817 from the data. We describe the test using "Monte Carlo" rather than
818 "parametric bootstrap" throughout to avoid confusion with the more familiar
819 nonparametric bootstrap, and describe how the test is performed below.
821 *Traditional goodness of fit tests*
823 Traditionally, critical values corresponding with a fixed set of
824 significance levels are pre-calculated using Monte Carlo methods. Users
825 perform the test by calculating the value of the test statistic only for
826 their observed `data` and comparing this value to tabulated critical
827 values. This practice is not very flexible, as tables are not available for
828 all distributions and combinations of known and unknown parameter values.
829 Also, results can be inaccurate when critical values are interpolated from
830 limited tabulated data to correspond with the user's sample size and
831 fitted parameter values. To overcome these shortcomings, this function
832 allows the user to perform the Monte Carlo trials adapted to their
833 particular data.
835 *Algorithmic overview*
837 In brief, this routine executes the following steps:
839 1. Fit unknown parameters to the given `data`, thereby forming the
840 "null-hypothesized" distribution, and compute the statistic of
841 this pair of data and distribution.
842 2. Draw random samples from this null-hypothesized distribution.
843 3. Fit the unknown parameters to each random sample.
844 4. Calculate the statistic between each sample and the distribution that
845 has been fit to the sample.
846 5. Compare the value of the statistic corresponding with `data` from (1)
847 against the values of the statistic corresponding with the random
848 samples from (4). The p-value is the proportion of samples with a
849 statistic value greater than or equal to the statistic of the observed
850 data.
852 In more detail, the steps are as follows.
854 First, any unknown parameters of the distribution family specified by
855 `dist` are fit to the provided `data` using maximum likelihood estimation.
856 (One exception is the normal distribution with unknown location and scale:
857 we use the bias-corrected standard deviation ``np.std(data, ddof=1)`` for
858 the scale as recommended in [1]_.)
859 These values of the parameters specify a particular member of the
860 distribution family referred to as the "null-hypothesized distribution",
861 that is, the distribution from which the data were sampled under the null
862 hypothesis. The `statistic`, which compares data to a distribution, is
863 computed between `data` and the null-hypothesized distribution.
865 Next, many (specifically `n_mc_samples`) new samples, each containing the
866 same number of observations as `data`, are drawn from the
867 null-hypothesized distribution. All unknown parameters of the distribution
868 family `dist` are fit to *each resample*, and the `statistic` is computed
869 between each sample and its corresponding fitted distribution. These
870 values of the statistic form the Monte Carlo null distribution (not to be
871 confused with the "null-hypothesized distribution" above).
873 The p-value of the test is the proportion of statistic values in the Monte
874 Carlo null distribution that are at least as extreme as the statistic value
875 of the provided `data`. More precisely, the p-value is given by
877 .. math::
879 p = \frac{b + 1}
880 {m + 1}
882 where :math:`b` is the number of statistic values in the Monte Carlo null
883 distribution that are greater than or equal to the the statistic value
884 calculated for `data`, and :math:`m` is the number of elements in the
885 Monte Carlo null distribution (`n_mc_samples`). The addition of :math:`1`
886 to the numerator and denominator can be thought of as including the
887 value of the statistic corresponding with `data` in the null distribution,
888 but a more formal explanation is given in [5]_.
890 *Limitations*
892 The test can be very slow for some distribution families because unknown
893 parameters of the distribution family must be fit to each of the Monte
894 Carlo samples, and for most distributions in SciPy, distribution fitting
895 performed via numerical optimization.
897 *Anti-Pattern*
899 For this reason, it may be tempting
900 to treat parameters of the distribution pre-fit to `data` (by the user)
901 as though they were `known_params`, as specification of all parameters of
902 the distribution precludes the need to fit the distribution to each Monte
903 Carlo sample. (This is essentially how the original Kilmogorov-Smirnov
904 test is performed.) Although such a test can provide evidence against the
905 null hypothesis, the test is conservative in the sense that small p-values
906 will tend to (greatly) *overestimate* the probability of making a type I
907 error (that is, rejecting the null hypothesis although it is true), and the
908 power of the test is low (that is, it is less likely to reject the null
909 hypothesis even when the null hypothesis is false).
910 This is because the Monte Carlo samples are less likely to agree with the
911 null-hypothesized distribution as well as `data`. This tends to increase
912 the values of the statistic recorded in the null distribution, so that a
913 larger number of them exceed the value of statistic for `data`, thereby
914 inflating the p-value.
916 References
917 ----------
918 .. [1] M. A. Stephens (1974). "EDF Statistics for Goodness of Fit and
919 Some Comparisons." Journal of the American Statistical Association,
920 Vol. 69, pp. 730-737.
921 .. [2] W. Stute, W. G. Manteiga, and M. P. Quindimil (1993).
922 "Bootstrap based goodness-of-fit-tests." Metrika 40.1: 243-256.
923 .. [3] C. Genest, & B Rémillard. (2008). "Validity of the parametric
924 bootstrap for goodness-of-fit testing in semiparametric models."
925 Annales de l'IHP Probabilités et statistiques. Vol. 44. No. 6.
926 .. [4] I. Kojadinovic and J. Yan (2012). "Goodness-of-fit testing based on
927 a weighted bootstrap: A fast large-sample alternative to the
928 parametric bootstrap." Canadian Journal of Statistics 40.3: 480-500.
929 .. [5] B. Phipson and G. K. Smyth (2010). "Permutation P-values Should
930 Never Be Zero: Calculating Exact P-values When Permutations Are
931 Randomly Drawn." Statistical Applications in Genetics and Molecular
932 Biology 9.1.
933 .. [6] H. W. Lilliefors (1967). "On the Kolmogorov-Smirnov test for
934 normality with mean and variance unknown." Journal of the American
935 statistical Association 62.318: 399-402.
936 .. [7] Filliben, James J. "The probability plot correlation coefficient
937 test for normality." Technometrics 17.1 (1975): 111-117.
939 Examples
940 --------
941 A well-known test of the null hypothesis that data were drawn from a
942 given distribution is the Kolmogorov-Smirnov (KS) test, available in SciPy
943 as `scipy.stats.ks_1samp`. Suppose we wish to test whether the following
944 data:
946 >>> import numpy as np
947 >>> from scipy import stats
948 >>> rng = np.random.default_rng()
949 >>> x = stats.uniform.rvs(size=75, random_state=rng)
951 were sampled from a normal distribution. To perform a KS test, the
952 empirical distribution function of the observed data will be compared
953 against the (theoretical) cumulative distribution function of a normal
954 distribution. Of course, to do this, the normal distribution under the null
955 hypothesis must be fully specified. This is commonly done by first fitting
956 the ``loc`` and ``scale`` parameters of the distribution to the observed
957 data, then performing the test.
959 >>> loc, scale = np.mean(x), np.std(x, ddof=1)
960 >>> cdf = stats.norm(loc, scale).cdf
961 >>> stats.ks_1samp(x, cdf)
962 KstestResult(statistic=0.1119257570456813, pvalue=0.2827756409939257)
964 An advantage of the KS-test is that the p-value - the probability of
965 obtaining a value of the test statistic under the null hypothesis as
966 extreme as the value obtained from the observed data - can be calculated
967 exactly and efficiently. `goodness_of_fit` can only approximate these
968 results.
970 >>> known_params = {'loc': loc, 'scale': scale}
971 >>> res = stats.goodness_of_fit(stats.norm, x, known_params=known_params,
972 ... statistic='ks', random_state=rng)
973 >>> res.statistic, res.pvalue
974 (0.1119257570456813, 0.2788)
976 The statistic matches exactly, but the p-value is estimated by forming
977 a "Monte Carlo null distribution", that is, by explicitly drawing random
978 samples from `scipy.stats.norm` with the provided parameters and
979 calculating the stastic for each. The fraction of these statistic values
980 at least as extreme as ``res.statistic`` approximates the exact p-value
981 calculated by `scipy.stats.ks_1samp`.
983 However, in many cases, we would prefer to test only that the data were
984 sampled from one of *any* member of the normal distribution family, not
985 specifically from the normal distribution with the location and scale
986 fitted to the observed sample. In this case, Lilliefors [6]_ argued that
987 the KS test is far too conservative (that is, the p-value overstates
988 the actual probability of rejecting a true null hypothesis) and thus lacks
989 power - the ability to reject the null hypothesis when the null hypothesis
990 is actually false.
991 Indeed, our p-value above is approximately 0.28, which is far too large
992 to reject the null hypothesis at any common significance level.
994 Consider why this might be. Note that in the KS test above, the statistic
995 always compares data against the CDF of a normal distribution fitted to the
996 *observed data*. This tends to reduce the value of the statistic for the
997 observed data, but it is "unfair" when computing the statistic for other
998 samples, such as those we randomly draw to form the Monte Carlo null
999 distribution. It is easy to correct for this: whenever we compute the KS
1000 statistic of a sample, we use the CDF of a normal distribution fitted
1001 to *that sample*. The null distribution in this case has not been
1002 calculated exactly and is tyically approximated using Monte Carlo methods
1003 as described above. This is where `goodness_of_fit` excels.
1005 >>> res = stats.goodness_of_fit(stats.norm, x, statistic='ks',
1006 ... random_state=rng)
1007 >>> res.statistic, res.pvalue
1008 (0.1119257570456813, 0.0196)
1010 Indeed, this p-value is much smaller, and small enough to (correctly)
1011 reject the null hypothesis at common signficance levels, including 5% and
1012 2.5%.
1014 However, the KS statistic is not very sensitive to all deviations from
1015 normality. The original advantage of the KS statistic was the ability
1016 to compute the null distribution theoretically, but a more sensitive
1017 statistic - resulting in a higher test power - can be used now that we can
1018 approximate the null distribution
1019 computationally. The Anderson-Darling statistic [1]_ tends to be more
1020 sensitive, and critical values of the this statistic have been tabulated
1021 for various significance levels and sample sizes using Monte Carlo methods.
1023 >>> res = stats.anderson(x, 'norm')
1024 >>> print(res.statistic)
1025 1.2139573337497467
1026 >>> print(res.critical_values)
1027 [0.549 0.625 0.75 0.875 1.041]
1028 >>> print(res.significance_level)
1029 [15. 10. 5. 2.5 1. ]
1031 Here, the observed value of the statistic exceeds the critical value
1032 corresponding with a 1% significance level. This tells us that the p-value
1033 of the observed data is less than 1%, but what is it? We could interpolate
1034 from these (already-interpolated) values, but `goodness_of_fit` can
1035 estimate it directly.
1037 >>> res = stats.goodness_of_fit(stats.norm, x, statistic='ad',
1038 ... random_state=rng)
1039 >>> res.statistic, res.pvalue
1040 (1.2139573337497467, 0.0034)
1042 A further advantage is that use of `goodness_of_fit` is not limited to
1043 a particular set of distributions or conditions on which parameters
1044 are known versus which must be estimated from data. Instead,
1045 `goodness_of_fit` can estimate p-values relatively quickly for any
1046 distribution with a sufficiently fast and reliable ``fit`` method. For
1047 instance, here we perform a goodness of fit test using the Cramer-von Mises
1048 statistic against the Rayleigh distribution with known location and unknown
1049 scale.
1051 >>> rng = np.random.default_rng()
1052 >>> x = stats.chi(df=2.2, loc=0, scale=2).rvs(size=1000, random_state=rng)
1053 >>> res = stats.goodness_of_fit(stats.rayleigh, x, statistic='cvm',
1054 ... known_params={'loc': 0}, random_state=rng)
1056 This executes fairly quickly, but to check the reliability of the ``fit``
1057 method, we should inspect the fit result.
1059 >>> res.fit_result # location is as specified, and scale is reasonable
1060 params: FitParams(loc=0.0, scale=2.1026719844231243)
1061 success: True
1062 message: 'The fit was performed successfully.'
1063 >>> import matplotlib.pyplot as plt # matplotlib must be installed to plot
1064 >>> res.fit_result.plot()
1065 >>> plt.show()
1067 If the distribution is not fit to the observed data as well as possible,
1068 the test may not control the type I error rate, that is, the chance of
1069 rejecting the null hypothesis even when it is true.
1071 We should also look for extreme outliers in the null distribution that
1072 may be caused by unreliable fitting. These do not necessarily invalidate
1073 the result, but they tend to reduce the test's power.
1075 >>> _, ax = plt.subplots()
1076 >>> ax.hist(np.log10(res.null_distribution))
1077 >>> ax.set_xlabel("log10 of CVM statistic under the null hypothesis")
1078 >>> ax.set_ylabel("Frequency")
1079 >>> ax.set_title("Histogram of the Monte Carlo null distribution")
1080 >>> plt.show()
1082 This plot seems reassuring.
1084 If ``fit`` method is working reliably, and if the distribution of the test
1085 statistic is not particularly sensitive to the values of the fitted
1086 parameters, then the p-value provided by `goodness_of_fit` is expected to
1087 be a good approximation.
1089 >>> res.statistic, res.pvalue
1090 (0.2231991510248692, 0.0525)
1092 """
1093 args = _gof_iv(dist, data, known_params, fit_params, guessed_params,
1094 statistic, n_mc_samples, random_state)
1095 (dist, data, fixed_nhd_params, fixed_rfd_params, guessed_nhd_params,
1096 guessed_rfd_params, statistic, n_mc_samples_int, random_state) = args
1098 # Fit null hypothesis distribution to data
1099 nhd_fit_fun = _get_fit_fun(dist, data, guessed_nhd_params,
1100 fixed_nhd_params)
1101 nhd_vals = nhd_fit_fun(data)
1102 nhd_dist = dist(*nhd_vals)
1104 def rvs(size):
1105 return nhd_dist.rvs(size=size, random_state=random_state)
1107 # Define statistic
1108 fit_fun = _get_fit_fun(dist, data, guessed_rfd_params, fixed_rfd_params)
1109 compare_fun = _compare_dict[statistic]
1110 alternative = getattr(compare_fun, 'alternative', 'greater')
1112 def statistic_fun(data, axis=-1):
1113 # Make things simple by always working along the last axis.
1114 data = np.moveaxis(data, axis, -1)
1115 rfd_vals = fit_fun(data)
1116 rfd_dist = dist(*rfd_vals)
1117 return compare_fun(rfd_dist, data)
1119 res = stats.monte_carlo_test(data, rvs, statistic_fun, vectorized=True,
1120 n_resamples=n_mc_samples, axis=-1,
1121 alternative=alternative)
1122 opt_res = optimize.OptimizeResult()
1123 opt_res.success = True
1124 opt_res.message = "The fit was performed successfully."
1125 opt_res.x = nhd_vals
1126 # Only continuous distributions for now, hence discrete=False
1127 # There's no fundamental limitation; it's just that we're not using
1128 # stats.fit, discrete distributions don't have `fit` method, and
1129 # we haven't written any vectorized fit functions for a discrete
1130 # distribution yet.
1131 return GoodnessOfFitResult(FitResult(dist, data, False, opt_res),
1132 res.statistic, res.pvalue,
1133 res.null_distribution)
1136def _get_fit_fun(dist, data, guessed_params, fixed_params):
1138 shape_names = [] if dist.shapes is None else dist.shapes.split(", ")
1139 param_names = shape_names + ['loc', 'scale']
1140 fparam_names = ['f'+name for name in param_names]
1141 all_fixed = not set(fparam_names).difference(fixed_params)
1142 guessed_shapes = [guessed_params.pop(x, None)
1143 for x in shape_names if x in guessed_params]
1145 if all_fixed:
1146 def fit_fun(data):
1147 return [fixed_params[name] for name in fparam_names]
1148 # Define statistic, including fitting distribution to data
1149 elif dist in _fit_funs:
1150 def fit_fun(data):
1151 params = _fit_funs[dist](data, **fixed_params)
1152 params = np.asarray(np.broadcast_arrays(*params))
1153 if params.ndim > 1:
1154 params = params[..., np.newaxis]
1155 return params
1156 else:
1157 def fit_fun_1d(data):
1158 return dist.fit(data, *guessed_shapes, **guessed_params,
1159 **fixed_params)
1161 def fit_fun(data):
1162 params = np.apply_along_axis(fit_fun_1d, axis=-1, arr=data)
1163 if params.ndim > 1:
1164 params = params.T[..., np.newaxis]
1165 return params
1167 return fit_fun
1170# Vectorized fitting functions. These are to accept ND `data` in which each
1171# row (slice along last axis) is a sample to fit and scalar fixed parameters.
1172# They return a tuple of shape parameter arrays, each of shape data.shape[:-1].
1173def _fit_norm(data, floc=None, fscale=None):
1174 loc = floc
1175 scale = fscale
1176 if loc is None and scale is None:
1177 loc = np.mean(data, axis=-1)
1178 scale = np.std(data, ddof=1, axis=-1)
1179 elif loc is None:
1180 loc = np.mean(data, axis=-1)
1181 elif scale is None:
1182 scale = np.sqrt(((data - loc)**2).mean(axis=-1))
1183 return loc, scale
1186_fit_funs = {stats.norm: _fit_norm} # type: ignore[attr-defined]
1189# Vectorized goodness of fit statistic functions. These accept a frozen
1190# distribution object and `data` in which each row (slice along last axis) is
1191# a sample.
1194def _anderson_darling(dist, data):
1195 x = np.sort(data, axis=-1)
1196 n = data.shape[-1]
1197 i = np.arange(1, n+1)
1198 Si = (2*i - 1)/n * (dist.logcdf(x) + dist.logsf(x[..., ::-1]))
1199 S = np.sum(Si, axis=-1)
1200 return -n - S
1203def _compute_dplus(cdfvals): # adapted from _stats_py before gh-17062
1204 n = cdfvals.shape[-1]
1205 return (np.arange(1.0, n + 1) / n - cdfvals).max(axis=-1)
1208def _compute_dminus(cdfvals, axis=-1):
1209 n = cdfvals.shape[-1]
1210 return (cdfvals - np.arange(0.0, n)/n).max(axis=-1)
1213def _kolmogorov_smirnov(dist, data):
1214 x = np.sort(data, axis=-1)
1215 cdfvals = dist.cdf(x)
1216 Dplus = _compute_dplus(cdfvals) # always works along last axis
1217 Dminus = _compute_dminus(cdfvals)
1218 return np.maximum(Dplus, Dminus)
1221def _corr(X, M):
1222 # Correlation coefficient r, simplified and vectorized as we need it.
1223 # See [7] Equation (2). Lemma 1/2 are only for distributions symmetric
1224 # about 0.
1225 Xm = X.mean(axis=-1, keepdims=True)
1226 Mm = M.mean(axis=-1, keepdims=True)
1227 num = np.sum((X - Xm) * (M - Mm), axis=-1)
1228 den = np.sqrt(np.sum((X - Xm)**2, axis=-1) * np.sum((M - Mm)**2, axis=-1))
1229 return num/den
1232def _filliben(dist, data):
1233 # [7] Section 8 # 1
1234 X = np.sort(data, axis=-1)
1236 # [7] Section 8 # 2
1237 n = data.shape[-1]
1238 k = np.arange(1, n+1)
1239 # Filliben used an approximation for the uniform distribution order
1240 # statistic medians.
1241 # m = (k - .3175)/(n + 0.365)
1242 # m[-1] = 0.5**(1/n)
1243 # m[0] = 1 - m[-1]
1244 # We can just as easily use the (theoretically) exact values. See e.g.
1245 # https://en.wikipedia.org/wiki/Order_statistic
1246 # "Order statistics sampled from a uniform distribution"
1247 m = stats.beta(k, n + 1 - k).median()
1249 # [7] Section 8 # 3
1250 M = dist.ppf(m)
1252 # [7] Section 8 # 4
1253 return _corr(X, M)
1254_filliben.alternative = 'less' # type: ignore[attr-defined]
1257def _cramer_von_mises(dist, data):
1258 x = np.sort(data, axis=-1)
1259 n = data.shape[-1]
1260 cdfvals = dist.cdf(x)
1261 u = (2*np.arange(1, n+1) - 1)/(2*n)
1262 w = 1 / (12*n) + np.sum((u - cdfvals)**2, axis=-1)
1263 return w
1266_compare_dict = {"ad": _anderson_darling, "ks": _kolmogorov_smirnov,
1267 "cvm": _cramer_von_mises, "filliben": _filliben}
1270def _gof_iv(dist, data, known_params, fit_params, guessed_params, statistic,
1271 n_mc_samples, random_state):
1273 if not isinstance(dist, stats.rv_continuous):
1274 message = ("`dist` must be a (non-frozen) instance of "
1275 "`stats.rv_continuous`.")
1276 raise TypeError(message)
1278 data = np.asarray(data, dtype=float)
1279 if not data.ndim == 1:
1280 message = "`data` must be a one-dimensional array of numbers."
1281 raise ValueError(message)
1283 # Leave validation of these key/value pairs to the `fit` method,
1284 # but collect these into dictionaries that will be used
1285 known_params = known_params or dict()
1286 fit_params = fit_params or dict()
1287 guessed_params = guessed_params or dict()
1289 known_params_f = {("f"+key): val for key, val in known_params.items()}
1290 fit_params_f = {("f"+key): val for key, val in fit_params.items()}
1292 # These the the values of parameters of the null distribution family
1293 # with which resamples are drawn
1294 fixed_nhd_params = known_params_f.copy()
1295 fixed_nhd_params.update(fit_params_f)
1297 # These are fixed when fitting the distribution family to resamples
1298 fixed_rfd_params = known_params_f.copy()
1300 # These are used as guesses when fitting the distribution family to
1301 # the original data
1302 guessed_nhd_params = guessed_params.copy()
1304 # These are used as guesses when fitting the distribution family to
1305 # resamples
1306 guessed_rfd_params = fit_params.copy()
1307 guessed_rfd_params.update(guessed_params)
1309 statistic = statistic.lower()
1310 statistics = {'ad', 'ks', 'cvm', 'filliben'}
1311 if statistic not in statistics:
1312 message = f"`statistic` must be one of {statistics}."
1313 raise ValueError(message)
1315 n_mc_samples_int = int(n_mc_samples)
1316 if n_mc_samples_int != n_mc_samples:
1317 message = "`n_mc_samples` must be an integer."
1318 raise TypeError(message)
1320 random_state = check_random_state(random_state)
1322 return (dist, data, fixed_nhd_params, fixed_rfd_params, guessed_nhd_params,
1323 guessed_rfd_params, statistic, n_mc_samples_int, random_state)