divinity-forecast 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
divinity/legacy.py ADDED
@@ -0,0 +1,138 @@
1
+ """
2
+ The version 0.1 interface, kept so existing code keeps working.
3
+ New code should use :class:`divinity.Forecaster`.
4
+ """
5
+
6
+ import warnings
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from .forecaster import Forecaster
12
+
13
+
14
+ class divinity:
15
+ """
16
+ Deprecated version 0.1 interface, now a thin wrapper around
17
+ :class:`Forecaster`.
18
+
19
+ :param forecast_length: number of steps returned by :meth:`predict`.
20
+ :param confidence_interval: interval coverage in percent.
21
+ :param seasonal_periods: candidate seasonal periods. None uses the
22
+ Forecaster defaults.
23
+ :param trend_order: [0] for no trend, anything including 1 for "auto".
24
+ :param kwargs: other version 0.1 arguments are accepted and ignored.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ forecast_length,
30
+ confidence_interval=95.0,
31
+ seasonal_periods=None,
32
+ trend_order=(0, 1),
33
+ **kwargs,
34
+ ):
35
+ warnings.warn(
36
+ "divinity.divinity is deprecated, use divinity.Forecaster",
37
+ DeprecationWarning,
38
+ stacklevel=2,
39
+ )
40
+ self.forecast_length = forecast_length
41
+ self.confidence_interval = confidence_interval
42
+ self.model = Forecaster(
43
+ seasonal_periods="auto" if seasonal_periods is None else seasonal_periods,
44
+ trend="auto" if max(trend_order) >= 1 else None,
45
+ interval=confidence_interval / 100.0,
46
+ )
47
+
48
+ @property
49
+ def features(self):
50
+ return self.model._features(np.arange(self.model.n_))
51
+
52
+ def fit(self, y):
53
+ self.model.fit(y)
54
+
55
+ def predict(self):
56
+ """
57
+ :return: numpy array of the forecast. The half width of the
58
+ prediction interval is stored in ``ystd``.
59
+ """
60
+ result = self.model.predict(self.forecast_length)
61
+ self.ypred = result["forecast"].to_numpy()
62
+ self.ystd = ((result["upper"] - result["lower"]) / 2).to_numpy()
63
+ return self.ypred
64
+
65
+ def forecast(self, steps):
66
+ self.forecast_length = steps
67
+ return self.predict()
68
+
69
+
70
+ def gen_season(N, periods=(10, 20), sine_amplitudes=(1, 1), cosine_amplitudes=(0, 0)):
71
+ """
72
+ Sinusoidal features and their weighted sum.
73
+
74
+ :return: dict with "features" (DataFrame) and "target" (array).
75
+ """
76
+ t = np.arange(N)
77
+ features = {}
78
+ X = np.zeros(N)
79
+ for idx, p in enumerate(periods):
80
+ if p == 0 or p > N / 2:
81
+ warnings.warn("period {} ignored, must be in (0, N/2]".format(p))
82
+ continue
83
+ s = np.sin(2 * np.pi / p * t)
84
+ c = np.cos(2 * np.pi / p * t)
85
+ if sine_amplitudes is not None:
86
+ X += sine_amplitudes[idx] * s
87
+ if cosine_amplitudes is not None:
88
+ X += cosine_amplitudes[idx] * c
89
+ features["sin P=" + str(p)] = s
90
+ features["cos P=" + str(p)] = c
91
+ return {"features": pd.DataFrame(features), "target": X}
92
+
93
+
94
+ def gen_trend(N, coef=(0, 1, 2), amplitudes=(1, 0.5, 0.2)):
95
+ """
96
+ Polynomial trend features and their weighted sum.
97
+
98
+ :return: dict with "features" (DataFrame) and "target" (array).
99
+ """
100
+ t = np.arange(N)
101
+ features = {}
102
+ X = np.zeros(N)
103
+ for idx, order in enumerate(coef):
104
+ f = np.array(t**order, dtype=float)
105
+ if amplitudes is not None:
106
+ X += amplitudes[idx] * f
107
+ features["trend order " + str(order)] = f
108
+ return {"features": pd.DataFrame(features), "target": X}
109
+
110
+
111
+ def gen_fake(
112
+ N,
113
+ trend_order,
114
+ trend_amplitudes,
115
+ seasonal_periods,
116
+ seasonal_amplitudes_sine,
117
+ seasonal_amplitudes_cosine,
118
+ normalize="0to1",
119
+ ):
120
+ """
121
+ Synthetic trend plus seasonal series and its feature matrix. Prefer
122
+ :mod:`divinity.synthetic` for new code.
123
+
124
+ :return: dict with "features" (DataFrame) and "target" (array).
125
+ """
126
+ trend = gen_trend(N, coef=list(range(trend_order + 1)), amplitudes=trend_amplitudes)
127
+ season = gen_season(
128
+ N,
129
+ periods=seasonal_periods,
130
+ sine_amplitudes=seasonal_amplitudes_sine,
131
+ cosine_amplitudes=seasonal_amplitudes_cosine,
132
+ )
133
+ features = pd.concat([trend["features"], season["features"]], axis=1)
134
+ if normalize == "0to1":
135
+ spread = features.max() - features.min()
136
+ cols = spread[spread > 0].index
137
+ features[cols] = (features[cols] - features[cols].min()) / spread[cols]
138
+ return {"features": features, "target": trend["target"] + season["target"]}
divinity/periods.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ Candidate seasonal periods, from the series frequency and from the data.
3
+ """
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from scipy import signal
8
+
9
+
10
+ def default_periods(freq):
11
+ """
12
+ Sensible seasonal periods (in time steps) for a pandas frequency.
13
+ Returns an empty list if the frequency is unknown.
14
+ """
15
+ if freq is None:
16
+ return []
17
+ offset = pd.tseries.frequencies.to_offset(freq)
18
+ if offset.n != 1:
19
+ return []
20
+ o = pd.offsets
21
+ if isinstance(offset, o.BusinessDay):
22
+ return [5.0, 261.0]
23
+ if isinstance(offset, o.Day):
24
+ return [7.0, 365.25]
25
+ if isinstance(offset, o.Hour):
26
+ return [24.0, 168.0]
27
+ if isinstance(offset, o.Minute):
28
+ return [60.0, 1440.0]
29
+ if isinstance(offset, o.Week):
30
+ return [365.25 / 7]
31
+ if isinstance(offset, (o.MonthBegin, o.MonthEnd)):
32
+ return [12.0]
33
+ if isinstance(offset, (o.QuarterBegin, o.QuarterEnd)):
34
+ return [4.0]
35
+ return []
36
+
37
+
38
+ def detect_periods(y, max_periods=3, min_period=2.0, max_period=None, oversample=8):
39
+ """
40
+ Find the strongest periodicities in a series from its periodogram.
41
+
42
+ The series is linearly detrended first (gaps are interpolated for this
43
+ step only). The periodogram is zero padded so that periods falling
44
+ between Fourier frequencies are located accurately.
45
+
46
+ :param y: 1D array, may contain NaN.
47
+ :param max_periods: maximum number of periods to return.
48
+ :param min_period: shortest period considered, in time steps.
49
+ :param max_period: longest period considered. Defaults to half the
50
+ series length, so at least two full cycles are observed.
51
+ :return: list of periods, strongest first.
52
+ """
53
+ y = pd.Series(np.asarray(y, dtype=float)).interpolate(limit_direction="both")
54
+ y = y.to_numpy()
55
+ n = len(y)
56
+ if n < 8 or np.allclose(y, y[0]):
57
+ return []
58
+ if max_period is None:
59
+ max_period = n / 2
60
+ y = signal.detrend(y)
61
+ freqs, power = signal.periodogram(y, nfft=oversample * n, detrend=False)
62
+ with np.errstate(divide="ignore"):
63
+ periods = 1.0 / freqs
64
+ valid = (periods >= min_period) & (periods <= max_period)
65
+ if not np.any(valid):
66
+ return []
67
+ peaks, _ = signal.find_peaks(np.where(valid, power, 0.0))
68
+ peaks = peaks[valid[peaks]]
69
+ order = peaks[np.argsort(power[peaks])[::-1]]
70
+
71
+ found = []
72
+ for idx in order:
73
+ p = periods[idx]
74
+ # skip harmonics and near duplicates of periods already found
75
+ if any(_is_related(p, q) for q in found):
76
+ continue
77
+ found.append(float(p))
78
+ if len(found) == max_periods:
79
+ break
80
+ return found
81
+
82
+
83
+ def _is_related(p, q, tol=0.05):
84
+ """
85
+ True if period p is within ``tol`` of q or of one of its harmonics q/k.
86
+ """
87
+ for k in range(1, 6):
88
+ if abs(p - q / k) <= tol * q / k:
89
+ return True
90
+ return False
91
+
92
+
93
+ def merge_periods(known, detected, n, tol=0.05):
94
+ """
95
+ Combine known and detected periods, dropping detected periods that match
96
+ a known one and any period that cannot be seen twice in ``n`` steps.
97
+
98
+ :return: (known, detected) with invalid entries removed.
99
+ """
100
+ known = [float(p) for p in known if 2 <= p <= n / 2]
101
+ detected = [
102
+ float(p)
103
+ for p in detected
104
+ if 2 <= p <= n / 2 and not any(_is_related(p, q, tol) for q in known)
105
+ ]
106
+ return known, detected
divinity/plotting.py ADDED
@@ -0,0 +1,77 @@
1
+ """
2
+ Forecast plots. Colours follow a colour blind safe categorical palette
3
+ (history in neutral grey, forecast in blue, true future values in orange).
4
+ """
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ HISTORY = "#52514e"
10
+ FORECAST = "#2a78d6"
11
+ ACTUAL = "#eb6834"
12
+ GRID = "#e4e3df"
13
+ SURFACE = "#fcfcfb"
14
+
15
+
16
+ def plot_forecast(history, forecast, actual=None, ax=None, title=None):
17
+ """
18
+ Plot history, forecast with its prediction interval, and optionally the
19
+ true future values.
20
+
21
+ :param history: Series of observed values.
22
+ :param forecast: DataFrame with columns forecast, lower and upper.
23
+ :param actual: optional Series or array of true future values.
24
+ :param ax: matplotlib Axes, a new figure is made if None.
25
+ :param title: optional title.
26
+ :return: the matplotlib Axes.
27
+ """
28
+ import matplotlib.pyplot as plt
29
+
30
+ if ax is None:
31
+ _, ax = plt.subplots(figsize=(9, 3.9))
32
+ ax.set_facecolor(SURFACE)
33
+ ax.figure.set_facecolor(SURFACE)
34
+
35
+ pct = int(round(100 * forecast.attrs.get("interval", 0.95)))
36
+ ax.plot(history.index, history.values, color=HISTORY, lw=1.5, label="history")
37
+ ax.fill_between(
38
+ forecast.index,
39
+ forecast["lower"],
40
+ forecast["upper"],
41
+ color=FORECAST,
42
+ alpha=0.15,
43
+ lw=0,
44
+ label="{}% interval".format(pct),
45
+ )
46
+ ax.plot(
47
+ forecast.index, forecast["forecast"], color=FORECAST, lw=2, label="forecast"
48
+ )
49
+ if actual is not None:
50
+ if not isinstance(actual, pd.Series):
51
+ actual = pd.Series(np.asarray(actual), index=forecast.index[: len(actual)])
52
+ ax.plot(actual.index, actual.values, color=ACTUAL, lw=1.5, label="actual")
53
+
54
+ ax.grid(True, color=GRID, lw=0.8)
55
+ ax.set_axisbelow(True)
56
+ for side in ("top", "right"):
57
+ ax.spines[side].set_visible(False)
58
+ for side in ("left", "bottom"):
59
+ ax.spines[side].set_color(GRID)
60
+ ax.tick_params(colors=HISTORY, labelsize=9)
61
+ if isinstance(forecast.index, pd.DatetimeIndex):
62
+ import matplotlib.dates as mdates
63
+
64
+ locator = mdates.AutoDateLocator()
65
+ ax.xaxis.set_major_locator(locator)
66
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
67
+ # below the axes so it never covers the data
68
+ ax.legend(
69
+ frameon=False,
70
+ fontsize=9,
71
+ loc="upper left",
72
+ bbox_to_anchor=(0, -0.12),
73
+ ncol=4,
74
+ )
75
+ if title:
76
+ ax.set_title(title, loc="left", fontsize=11, color="#0b0b0b")
77
+ return ax
divinity/residuals.py ADDED
@@ -0,0 +1,146 @@
1
+ """
2
+ Choosing the ARIMA model for whatever the trend and seasonality leave behind.
3
+ """
4
+
5
+ import warnings
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from statsmodels.tools.sm_exceptions import ConvergenceWarning
10
+ from statsmodels.tsa.arima.model import ARIMA
11
+ from statsmodels.tsa.adfvalues import mackinnonp
12
+ from statsmodels.tsa.stattools import adfuller
13
+
14
+
15
+ def has_unit_root(resid, alpha=0.05, trend=True):
16
+ """
17
+ Augmented Dickey-Fuller test for a unit root (random walk behaviour).
18
+
19
+ Set ``trend`` to True if a trend was removed from the series (or could
20
+ be present). Testing detrended data against the no-trend critical
21
+ values rejects the unit root far too often, so random walks would be
22
+ mistaken for mean reverting series.
23
+
24
+ The null hypothesis is a unit root, so the series is treated as a random
25
+ walk unless the test rejects at ``alpha``. When in doubt this errs
26
+ towards differencing, which gives wider but honest intervals, rather
27
+ than towards mean reversion, which gives confident wrong ones.
28
+ """
29
+ r = np.asarray(resid, dtype=float)
30
+ observed = r[~np.isnan(r)]
31
+ if len(observed) < 20 or np.allclose(observed, observed[0]):
32
+ return False
33
+ regression = "ct" if trend else "c"
34
+ if np.isnan(r).any():
35
+ r = fill_short_gaps(r)
36
+ if np.isnan(r).any():
37
+ p_value = _adf_with_gaps(r, regression)
38
+ if p_value is not None:
39
+ return p_value > alpha
40
+ # too few complete rows, fall back to interpolating the gaps
41
+ r = pd.Series(r).interpolate(limit_direction="both").to_numpy()
42
+ with warnings.catch_warnings():
43
+ warnings.simplefilter("ignore")
44
+ p_value = adfuller(r, regression=regression, autolag="AIC")[1]
45
+ return p_value > alpha
46
+
47
+
48
+ def fill_short_gaps(r, max_gap=2):
49
+ """
50
+ Linearly interpolate runs of at most ``max_gap`` missing values that
51
+ have data on both sides. Longer gaps are left as NaN.
52
+ """
53
+ r = np.asarray(r, dtype=float).copy()
54
+ missing = np.isnan(r)
55
+ filled = pd.Series(r).interpolate(limit_area="inside").to_numpy()
56
+ i = 0
57
+ while i < len(r):
58
+ if missing[i]:
59
+ j = i
60
+ while j < len(r) and missing[j]:
61
+ j += 1
62
+ if j - i <= max_gap:
63
+ r[i:j] = filled[i:j]
64
+ i = j
65
+ else:
66
+ i += 1
67
+ return r
68
+
69
+
70
+ def _adf_with_gaps(r, regression, max_lag=4, min_rows=30):
71
+ """
72
+ ADF test on a series with missing values, using only the rows where the
73
+ value and all its lags are observed. Interpolating instead would turn
74
+ long gaps into smooth straight lines, which look like a random walk.
75
+
76
+ The lag length is chosen by AIC on a common sample, and the p-value
77
+ comes from MacKinnon's tables, as in statsmodels' adfuller.
78
+
79
+ :return: p-value, or None if there are too few complete rows.
80
+ """
81
+ n = len(r)
82
+ dy = np.full(n, np.nan)
83
+ dy[1:] = np.diff(r)
84
+ t = np.arange(n, dtype=float)
85
+ rows = np.arange(max_lag + 1, n)
86
+ lags = np.column_stack([dy[rows - k] for k in range(1, max_lag + 1)])
87
+ target = dy[rows]
88
+ level = r[rows - 1]
89
+ base = [level, np.ones(len(rows))]
90
+ if regression == "ct":
91
+ base.append(t[rows] / n)
92
+ base = np.column_stack(base)
93
+ ok = ~(np.isnan(target) | np.isnan(level) | np.isnan(lags).any(axis=1))
94
+ if ok.sum() < min_rows:
95
+ return None
96
+ y, base, lags = target[ok], base[ok], lags[ok]
97
+ m = len(y)
98
+
99
+ best = None
100
+ for k in range(max_lag + 1):
101
+ X = np.hstack([base, lags[:, :k]])
102
+ beta, *_ = np.linalg.lstsq(X, y, rcond=None)
103
+ rss = float(np.sum((y - X @ beta) ** 2))
104
+ aic = m * np.log(max(rss, 1e-300) / m) + 2 * X.shape[1]
105
+ if best is None or aic < best[0]:
106
+ best = (aic, X, beta, rss)
107
+ _, X, beta, rss = best
108
+ sigma2 = rss / (m - X.shape[1])
109
+ cov = sigma2 * np.linalg.pinv(X.T @ X)
110
+ t_stat = beta[0] / np.sqrt(cov[0, 0])
111
+ return float(mackinnonp(t_stat, regression=regression, N=1))
112
+
113
+
114
+ def fit_quietly(model):
115
+ """
116
+ Fit a statsmodels model, silencing the routine convergence chatter.
117
+ """
118
+ with warnings.catch_warnings():
119
+ warnings.simplefilter("ignore", ConvergenceWarning)
120
+ warnings.simplefilter("ignore", UserWarning)
121
+ return model.fit()
122
+
123
+
124
+ def select_order(resid, d, max_p=2, max_q=2):
125
+ """
126
+ Pick (p, d, q) for the residuals by BIC over a small grid.
127
+
128
+ :param resid: residual series (NaN allowed).
129
+ :param d: differencing order decided beforehand (0 or 1).
130
+ :return: (order, fitted results) for the best model.
131
+ """
132
+ best = None
133
+ trend = "c" if d == 0 else "n"
134
+ for p in range(max_p + 1):
135
+ for q in range(max_q + 1):
136
+ try:
137
+ res = fit_quietly(ARIMA(resid, order=(p, d, q), trend=trend))
138
+ except (np.linalg.LinAlgError, ValueError):
139
+ continue
140
+ if not np.isfinite(res.bic):
141
+ continue
142
+ if best is None or res.bic < best[1].bic:
143
+ best = ((p, d, q), res)
144
+ if best is None:
145
+ return (0, d, 0), None
146
+ return best
divinity/selection.py ADDED
@@ -0,0 +1,157 @@
1
+ """
2
+ Forward selection of trend and seasonal feature groups.
3
+
4
+ Groups are scored with BIC plus an extra penalty for searching over many
5
+ candidates (in the spirit of the extended BIC of Chen and Chen, 2008).
6
+ Without that extra term, trying dozens of candidate periods on pure noise
7
+ will eventually find one that looks significant by chance.
8
+
9
+ Selection can also be run on quasi-differenced data (y_t - rho * y_{t-1}),
10
+ which is feasible GLS for AR(1) errors. Ordinary least squares assumes
11
+ independent errors, so on a random walk or a strongly autocorrelated series
12
+ it finds "significant" trends and cycles that are not there.
13
+ """
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ import numpy as np
18
+
19
+ from .features import design_matrix
20
+
21
+
22
+ @dataclass
23
+ class SelectionResult:
24
+ """
25
+ Outcome of forward selection.
26
+
27
+ :param groups: selected FeatureGroups, in the order they were added.
28
+ :param history: list of (group name, score) for each accepted step,
29
+ starting with ("constant", score).
30
+ """
31
+
32
+ groups: list
33
+ history: list = field(default_factory=list)
34
+
35
+
36
+ def ols_rss(X, y):
37
+ """
38
+ Residual sum of squares of an ordinary least squares fit.
39
+ """
40
+ beta, *_ = np.linalg.lstsq(X, y, rcond=None)
41
+ resid = y - X @ beta
42
+ return float(resid @ resid)
43
+
44
+
45
+ def score(rss, n, n_params, search_penalty):
46
+ """
47
+ BIC for a Gaussian linear model plus the multiple search penalty.
48
+ """
49
+ rss = max(rss, 1e-12 * n)
50
+ return n * np.log(rss / n) + n_params * np.log(n) + search_penalty
51
+
52
+
53
+ def lag1_autocorrelation(resid):
54
+ """
55
+ AR(1) coefficient of a residual series, using only adjacent pairs where
56
+ both values are observed. Clipped to [-0.95, 1].
57
+ """
58
+ r = np.asarray(resid, dtype=float)
59
+ a, b = r[1:], r[:-1]
60
+ ok = ~(np.isnan(a) | np.isnan(b))
61
+ if ok.sum() < 3 or np.sum(b[ok] ** 2) == 0:
62
+ return 0.0
63
+ rho = np.sum(a[ok] * b[ok]) / np.sum(b[ok] ** 2)
64
+ return float(np.clip(rho, -0.95, 1.0))
65
+
66
+
67
+ def quasi_difference(values, rho):
68
+ """
69
+ Apply x_t - rho * x_{t-1} to a 1D or 2D array (first row is dropped).
70
+ """
71
+ values = np.asarray(values, dtype=float)
72
+ if rho == 0:
73
+ return values
74
+ return values[1:] - rho * values[:-1]
75
+
76
+
77
+ def forward_select(
78
+ y, candidates, pool_sizes, drop_columns=(), gamma=1.0, forced=(), rho=0.0
79
+ ):
80
+ """
81
+ Greedily add the candidate group that lowers the score most, until no
82
+ group lowers it.
83
+
84
+ A harmonic k > 1 of a period is only eligible once harmonic k - 1 of the
85
+ same period has been selected.
86
+
87
+ :param y: training series (NaN allowed, those rows are ignored).
88
+ :param candidates: list of FeatureGroup.
89
+ :param pool_sizes: dict of FeatureGroup to the number of alternatives it
90
+ was chosen from. Each selected group adds 2 * gamma * log(pool size)
91
+ to the score.
92
+ :param drop_columns: degenerate column names to leave out.
93
+ :param gamma: strength of the search penalty. 0 gives plain BIC.
94
+ :param forced: groups that are always included and carry no penalty.
95
+ :param rho: AR(1) coefficient of the errors. Non zero values select on
96
+ quasi-differenced data.
97
+ :return: SelectionResult
98
+ """
99
+ y = np.asarray(y, dtype=float)
100
+ n_train = len(y)
101
+ t = np.arange(n_train)
102
+
103
+ forced = list(forced)
104
+ candidates = [g for g in candidates if g not in forced]
105
+ full = design_matrix(t, forced + candidates, n_train, drop_columns)
106
+ columns_of = {
107
+ g: [c for c in design_matrix(t[:1], [g], n_train).columns if c in full.columns]
108
+ for g in forced + candidates
109
+ }
110
+ candidates = [g for g in candidates if columns_of[g]]
111
+ col_pos = {c: i for i, c in enumerate(full.columns)}
112
+
113
+ y_t = quasi_difference(y, rho)
114
+ X_t = quasi_difference(np.hstack([np.ones((n_train, 1)), full.to_numpy()]), rho)
115
+ observed = ~np.isnan(y_t)
116
+ y_obs = y_t[observed]
117
+ X_obs = X_t[observed]
118
+ n = len(y_obs)
119
+
120
+ def evaluate(groups):
121
+ idx = [0] + [1 + col_pos[c] for g in forced + groups for c in columns_of[g]]
122
+ X = X_obs[:, idx]
123
+ penalty = sum(2 * gamma * np.log(max(pool_sizes[g], 1)) for g in groups)
124
+ return score(ols_rss(X, y_obs), n, X.shape[1], penalty)
125
+
126
+ selected = []
127
+ current = evaluate(selected)
128
+ history = [("constant", current)]
129
+ while True:
130
+ eligible = [
131
+ g
132
+ for g in candidates
133
+ if g not in selected
134
+ and (
135
+ g.kind == "trend"
136
+ or g.harmonic == 1
137
+ or any(
138
+ s.kind == "season"
139
+ and s.period == g.period
140
+ and s.harmonic == g.harmonic - 1
141
+ for s in selected
142
+ )
143
+ )
144
+ ]
145
+ # never use more parameters than about a third of the data
146
+ n_cols = sum(len(columns_of[g]) for g in forced + selected)
147
+ eligible = [g for g in eligible if n_cols + len(columns_of[g]) + 1 < n / 3]
148
+ if not eligible:
149
+ break
150
+ scores = [evaluate(selected + [g]) for g in eligible]
151
+ best = int(np.argmin(scores))
152
+ if scores[best] >= current:
153
+ break
154
+ selected.append(eligible[best])
155
+ current = scores[best]
156
+ history.append((eligible[best].name, current))
157
+ return SelectionResult(forced + selected, history)