modelmatrix 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modelmatrix/__init__.py +26 -0
- modelmatrix/_version.py +24 -0
- modelmatrix/columns.py +324 -0
- modelmatrix/model_spec.py +1710 -0
- modelmatrix/transforms.py +695 -0
- modelmatrix-0.1.0.dist-info/METADATA +72 -0
- modelmatrix-0.1.0.dist-info/RECORD +10 -0
- modelmatrix-0.1.0.dist-info/WHEEL +5 -0
- modelmatrix-0.1.0.dist-info/licenses/LICENSE +27 -0
- modelmatrix-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model specification
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
This module defines the basic object to represent regression
|
|
6
|
+
formula: *ModelSpec*.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections import namedtuple
|
|
10
|
+
from itertools import product
|
|
11
|
+
from typing import NamedTuple, Any
|
|
12
|
+
from copy import copy
|
|
13
|
+
|
|
14
|
+
import numpy as np, pandas as pd
|
|
15
|
+
import narwhals as nw
|
|
16
|
+
from scipy import sparse as sp_sparse
|
|
17
|
+
|
|
18
|
+
from sklearn.base import TransformerMixin, BaseEstimator, clone
|
|
19
|
+
from sklearn.utils.validation import check_is_fitted
|
|
20
|
+
from sklearn.preprocessing import (OneHotEncoder,
|
|
21
|
+
OrdinalEncoder,
|
|
22
|
+
StandardScaler)
|
|
23
|
+
from sklearn.decomposition import PCA
|
|
24
|
+
from sklearn.pipeline import make_pipeline, Pipeline
|
|
25
|
+
from sklearn.exceptions import NotFittedError
|
|
26
|
+
from joblib import hash as joblib_hash
|
|
27
|
+
|
|
28
|
+
from .columns import (_get_column_info,
|
|
29
|
+
Column,
|
|
30
|
+
_categorical_from_df,
|
|
31
|
+
_check_categories)
|
|
32
|
+
|
|
33
|
+
from .transforms import (Poly,
|
|
34
|
+
BSpline,
|
|
35
|
+
NaturalSpline,
|
|
36
|
+
Interaction)
|
|
37
|
+
|
|
38
|
+
DOCACHE = False
|
|
39
|
+
|
|
40
|
+
class Feature(NamedTuple):
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
An element in a model matrix that will build
|
|
44
|
+
columns from an array-like X.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
variables: tuple
|
|
48
|
+
name: str
|
|
49
|
+
encoder: Any
|
|
50
|
+
use_transform: bool=True # if False use the predict method
|
|
51
|
+
pure_columns: bool=False
|
|
52
|
+
override_encoder_colnames: bool=False
|
|
53
|
+
# how a helper such as ns() made this Feature: (helper, args, kwargs),
|
|
54
|
+
# so that it can be rebuilt with other parameters (ModelSpec.set_params)
|
|
55
|
+
spec: Any=None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#### contrast specific code
|
|
59
|
+
|
|
60
|
+
def _as_series(X):
|
|
61
|
+
"""
|
|
62
|
+
A single categorical feature as a `pd.Series`.
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(X, pd.DataFrame):
|
|
65
|
+
if X.shape[1] != 1:
|
|
66
|
+
raise ValueError(f'expecting a single column, got {X.shape[1]}')
|
|
67
|
+
X = X.iloc[:,0]
|
|
68
|
+
if not isinstance(X, pd.Series):
|
|
69
|
+
X = np.asarray(X)
|
|
70
|
+
if X.ndim > 2 or (X.ndim == 2 and X.shape[1] != 1):
|
|
71
|
+
raise ValueError(f'expecting a single column, got array of shape {X.shape}')
|
|
72
|
+
X = pd.Series(X.reshape(-1))
|
|
73
|
+
return X
|
|
74
|
+
|
|
75
|
+
def _observed_levels(X):
|
|
76
|
+
"""
|
|
77
|
+
Levels of `X` that are observed (missing values excluded),
|
|
78
|
+
in the order of `X.dtype.categories` for a categorical,
|
|
79
|
+
sorted otherwise.
|
|
80
|
+
"""
|
|
81
|
+
observed = X.dropna().unique()
|
|
82
|
+
if isinstance(X.dtype, pd.CategoricalDtype):
|
|
83
|
+
observed = set(observed)
|
|
84
|
+
return [c for c in X.dtype.categories if c in observed]
|
|
85
|
+
return sorted(observed)
|
|
86
|
+
|
|
87
|
+
def _level_codes(X, levels, during='transform'):
|
|
88
|
+
"""
|
|
89
|
+
Position of each entry of `X` in `levels`, and a mask
|
|
90
|
+
of missing entries (whose codes are -1). Raises a `ValueError`
|
|
91
|
+
for entries that are neither missing nor in `levels`.
|
|
92
|
+
"""
|
|
93
|
+
X = _as_series(X)
|
|
94
|
+
missing = np.asarray(X.isna())
|
|
95
|
+
codes = pd.Index(levels).get_indexer(np.asarray(X, dtype=object))
|
|
96
|
+
codes[missing] = -1
|
|
97
|
+
unknown = (codes < 0) & ~missing
|
|
98
|
+
if unknown.any():
|
|
99
|
+
raise ValueError('Found unknown categories {0} during {1}'.format(
|
|
100
|
+
sorted(set(np.asarray(X)[unknown]), key=str), during))
|
|
101
|
+
return codes, missing
|
|
102
|
+
|
|
103
|
+
class Contrast(TransformerMixin, BaseEstimator):
|
|
104
|
+
|
|
105
|
+
def __init__(self,
|
|
106
|
+
method='drop',
|
|
107
|
+
drop_level=None):
|
|
108
|
+
"""
|
|
109
|
+
Contrast encoding for categorical variables.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
method : ['drop', 'sum', None, callable]
|
|
114
|
+
If 'drop', then a column of the one-hot
|
|
115
|
+
encoding will be dropped. If 'sum', then the sum of
|
|
116
|
+
coefficients is constrained to sum to 1.
|
|
117
|
+
If `None`, the full one-hot encoding is returned.
|
|
118
|
+
Finally, if callable, then it should take the number of
|
|
119
|
+
levels of the category as a single argument and return
|
|
120
|
+
an appropriate contrast of the full one-hot encoding.
|
|
121
|
+
|
|
122
|
+
drop_level : level identifier (optional)
|
|
123
|
+
If not None, this level of the category
|
|
124
|
+
will be dropped if `method` is 'drop' (or given
|
|
125
|
+
coefficient minus the sum of the others if 'sum').
|
|
126
|
+
Matched against the levels by value, then by `str`.
|
|
127
|
+
|
|
128
|
+
Levels are those observed when fit, in the order of
|
|
129
|
+
the categories for a categorical dtype and sorted
|
|
130
|
+
otherwise; unused categories are dropped. Missing
|
|
131
|
+
values give a row of `np.nan`, and a level not seen
|
|
132
|
+
when fit raises a `ValueError` on transform.
|
|
133
|
+
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
self.method = method
|
|
137
|
+
self.drop_level = drop_level
|
|
138
|
+
|
|
139
|
+
def fit(self, X, y=None):
|
|
140
|
+
|
|
141
|
+
"""
|
|
142
|
+
Construct contrast of categorical variable
|
|
143
|
+
for use in building a design matrix.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
X : array-like
|
|
148
|
+
X on which model matrix will be evaluated.
|
|
149
|
+
If a :py:class:`pd.DataFrame` or :py:class:`pd.Series`, variables that are of
|
|
150
|
+
categorical dtype will be treated as categorical.
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
F : array-like
|
|
155
|
+
Columns of design matrix implied by the
|
|
156
|
+
categorical variable.
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
# validate before setting any fitted attribute, so a bad method
|
|
161
|
+
# does not leave a partially fitted encoder
|
|
162
|
+
if not (self.method in ['drop', 'sum', None] or callable(self.method)):
|
|
163
|
+
raise ValueError('method must be one of ["drop", "sum", None] or a callable ' +
|
|
164
|
+
'that returns a contrast matrix given the number' +
|
|
165
|
+
' of levels')
|
|
166
|
+
|
|
167
|
+
levels = _observed_levels(_as_series(X))
|
|
168
|
+
column_names = [str(l) for l in levels]
|
|
169
|
+
nlevel = len(levels)
|
|
170
|
+
|
|
171
|
+
if self.method in ['drop', 'sum']:
|
|
172
|
+
if self.drop_level is None: # these defaults consistent with R
|
|
173
|
+
drop_idx = 0 if self.method == 'drop' else nlevel - 1
|
|
174
|
+
else:
|
|
175
|
+
drop_idx = _level_index(levels, self.drop_level)
|
|
176
|
+
keep = np.ones(nlevel, bool)
|
|
177
|
+
keep[drop_idx] = 0
|
|
178
|
+
columns = [n for n, k in zip(column_names, keep) if k]
|
|
179
|
+
contrast = sp_sparse.identity(nlevel, format='csc')[:,keep]
|
|
180
|
+
if self.method == 'sum':
|
|
181
|
+
contrast = contrast.tolil()
|
|
182
|
+
contrast[drop_idx] = -1
|
|
183
|
+
elif callable(self.method):
|
|
184
|
+
contrast = sp_sparse.csr_matrix(np.asarray(self.method(nlevel), float))
|
|
185
|
+
columns = ['C({})'.format(i) for i in range(contrast.shape[1])]
|
|
186
|
+
elif self.method is None:
|
|
187
|
+
contrast = sp_sparse.identity(nlevel, format='csr')
|
|
188
|
+
columns = column_names
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError('method must be one of ["drop", "sum", None] or a callable' +
|
|
191
|
+
'that returns a contrast matrix and column names given the number' +
|
|
192
|
+
' of levels')
|
|
193
|
+
|
|
194
|
+
self.levels_ = levels
|
|
195
|
+
self.n_features_in_ = 1
|
|
196
|
+
self.columns_ = columns
|
|
197
|
+
# stored sparse: a dense (levels x levels) matrix is large
|
|
198
|
+
# for a categorical with many levels
|
|
199
|
+
self._contrast = sp_sparse.csr_matrix(contrast)
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def contrast_matrix_(self):
|
|
204
|
+
# rows are levels, columns are those of the encoding
|
|
205
|
+
return self._contrast.toarray()
|
|
206
|
+
|
|
207
|
+
def _islp_sparse_transform(self, X):
|
|
208
|
+
# the encoding as a sparse matrix, never forming the dense
|
|
209
|
+
# (n x levels) indicator matrix
|
|
210
|
+
check_is_fitted(self)
|
|
211
|
+
codes, missing = _level_codes(X, self.levels_)
|
|
212
|
+
rows = np.flatnonzero(~missing)
|
|
213
|
+
indicator = sp_sparse.csr_matrix((np.ones(rows.shape[0]), (rows, codes[rows])),
|
|
214
|
+
shape=(codes.shape[0], len(self.levels_)))
|
|
215
|
+
value = sp_sparse.csr_matrix(indicator @ self._contrast)
|
|
216
|
+
if missing.any():
|
|
217
|
+
value = value.tolil()
|
|
218
|
+
value[np.flatnonzero(missing)] = np.nan
|
|
219
|
+
value = value.tocsr()
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
def transform(self, X):
|
|
223
|
+
value = self._islp_sparse_transform(X).toarray()
|
|
224
|
+
|
|
225
|
+
if isinstance(X, (pd.DataFrame, pd.Series)):
|
|
226
|
+
df = pd.DataFrame(value, columns=self.columns_, copy=False)
|
|
227
|
+
df.index = X.index
|
|
228
|
+
return df
|
|
229
|
+
return value
|
|
230
|
+
|
|
231
|
+
def _level_index(levels, level):
|
|
232
|
+
"""
|
|
233
|
+
Index of `level` in `levels`, matched by value then by `str`.
|
|
234
|
+
"""
|
|
235
|
+
for match in [lambda l: l == level,
|
|
236
|
+
lambda l: str(l) == str(level)]:
|
|
237
|
+
idx = [i for i, l in enumerate(levels) if match(l)]
|
|
238
|
+
if idx:
|
|
239
|
+
return idx[0]
|
|
240
|
+
raise ValueError('level {0!r} is not among the observed levels {1}'.format(
|
|
241
|
+
level, [str(l) for l in levels]))
|
|
242
|
+
|
|
243
|
+
class Ordinal(TransformerMixin, BaseEstimator):
|
|
244
|
+
|
|
245
|
+
def __init__(self, levels=None):
|
|
246
|
+
"""
|
|
247
|
+
Ordinal encoding for categorical variables: each entry
|
|
248
|
+
is replaced by the position of its level.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
levels : sequence (optional)
|
|
253
|
+
The levels in order. If None, the order must be
|
|
254
|
+
unambiguous: the categories of an ordered categorical
|
|
255
|
+
dtype (unused categories keep their positions), or
|
|
256
|
+
the sorted values of a numeric or boolean column.
|
|
257
|
+
Other columns, e.g. strings or an unordered categorical,
|
|
258
|
+
raise a `ValueError` since their sorted (alphabetical)
|
|
259
|
+
order is rarely the intended one.
|
|
260
|
+
|
|
261
|
+
Missing values are encoded as `np.nan`, and a level not
|
|
262
|
+
among the levels raises a `ValueError`.
|
|
263
|
+
"""
|
|
264
|
+
self.levels = levels
|
|
265
|
+
|
|
266
|
+
def fit(self, X, y=None):
|
|
267
|
+
|
|
268
|
+
"""
|
|
269
|
+
Find the levels of the categorical variable.
|
|
270
|
+
|
|
271
|
+
Parameters
|
|
272
|
+
----------
|
|
273
|
+
X : array-like
|
|
274
|
+
Single column on which the encoding will be evaluated.
|
|
275
|
+
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
X = _as_series(X)
|
|
279
|
+
if self.levels is not None:
|
|
280
|
+
levels = list(self.levels)
|
|
281
|
+
if len(set(levels)) != len(levels):
|
|
282
|
+
raise ValueError('levels should not contain duplicates')
|
|
283
|
+
_level_codes(X, levels, during='fit') # raises if X has other levels
|
|
284
|
+
elif isinstance(X.dtype, pd.CategoricalDtype) and X.dtype.ordered:
|
|
285
|
+
levels = list(X.dtype.categories)
|
|
286
|
+
elif (pd.api.types.is_numeric_dtype(X.dtype) or
|
|
287
|
+
pd.api.types.is_bool_dtype(X.dtype)):
|
|
288
|
+
levels = _observed_levels(X)
|
|
289
|
+
else:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
'the order of the levels {0} is ambiguous: give it '
|
|
292
|
+
'with `levels=[...]`, or use an ordered pd.Categorical, '
|
|
293
|
+
'e.g. `X[col].cat.as_ordered()` or '
|
|
294
|
+
'`pd.Categorical(..., categories=[...], ordered=True)`'.format(
|
|
295
|
+
[str(l) for l in _observed_levels(X)]))
|
|
296
|
+
self.levels_ = levels
|
|
297
|
+
self.n_features_in_ = 1
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
def transform(self, X):
|
|
301
|
+
check_is_fitted(self)
|
|
302
|
+
codes, missing = _level_codes(X, self.levels_)
|
|
303
|
+
value = codes.astype(float).reshape((-1,1))
|
|
304
|
+
value[missing] = np.nan
|
|
305
|
+
return value
|
|
306
|
+
|
|
307
|
+
def _term_columns(terms):
|
|
308
|
+
"""
|
|
309
|
+
The columns of X that `terms` (column identifiers, Columns,
|
|
310
|
+
Features and tuples of these) are built from.
|
|
311
|
+
"""
|
|
312
|
+
found = set()
|
|
313
|
+
def visit(t):
|
|
314
|
+
if isinstance(t, Feature):
|
|
315
|
+
for v in t.variables:
|
|
316
|
+
visit(v)
|
|
317
|
+
elif isinstance(t, Column):
|
|
318
|
+
found.add(t.idx)
|
|
319
|
+
elif isinstance(t, tuple):
|
|
320
|
+
for v in t:
|
|
321
|
+
visit(v)
|
|
322
|
+
else:
|
|
323
|
+
try:
|
|
324
|
+
found.add(t)
|
|
325
|
+
except TypeError: # not a column identifier: an error later
|
|
326
|
+
pass
|
|
327
|
+
for t in terms:
|
|
328
|
+
visit(t)
|
|
329
|
+
return found
|
|
330
|
+
|
|
331
|
+
def _native_columns(X):
|
|
332
|
+
# the column names of a non-pandas dataframe, else None
|
|
333
|
+
if (isinstance(X, (pd.DataFrame, pd.Series, np.ndarray)) or
|
|
334
|
+
sp_sparse.issparse(X)):
|
|
335
|
+
return None
|
|
336
|
+
try:
|
|
337
|
+
X_nw = nw.from_native(X, eager_only=True)
|
|
338
|
+
except TypeError:
|
|
339
|
+
return None
|
|
340
|
+
return list(X_nw.columns)
|
|
341
|
+
|
|
342
|
+
def _from_native(X, columns=None):
|
|
343
|
+
"""
|
|
344
|
+
Convert a dataframe (or series) of any eager backend supported by
|
|
345
|
+
narwhals, e.g. polars or pyarrow, to pandas. Returns the converted
|
|
346
|
+
`X` and a function converting a `pd.DataFrame` model matrix back to
|
|
347
|
+
that backend, or None if `X` was not converted.
|
|
348
|
+
|
|
349
|
+
If `columns` is given, only those columns of a dataframe are
|
|
350
|
+
converted (the copy is of the columns a model matrix uses).
|
|
351
|
+
|
|
352
|
+
Conversion through pandas keeps categoricals, including the order
|
|
353
|
+
of a polars Enum or an ordered pyarrow dictionary (as an ordered
|
|
354
|
+
`pd.Categorical`). The levels of an unordered categorical, e.g. a
|
|
355
|
+
polars Categorical, are stored in order of appearance; they are
|
|
356
|
+
sorted, as pandas does when it infers categories, so that the
|
|
357
|
+
model matrix does not depend on the order of the rows.
|
|
358
|
+
"""
|
|
359
|
+
if (isinstance(X, (pd.DataFrame, pd.Series, np.ndarray)) or
|
|
360
|
+
sp_sparse.issparse(X)):
|
|
361
|
+
return X, None
|
|
362
|
+
try:
|
|
363
|
+
X_nw = nw.from_native(X, allow_series=True)
|
|
364
|
+
except TypeError: # not a dataframe, e.g. a list: left to _check_X
|
|
365
|
+
return X, None
|
|
366
|
+
if isinstance(X_nw, nw.LazyFrame):
|
|
367
|
+
raise TypeError('Lazy frames are not supported by ModelSpec; '
|
|
368
|
+
'collect them first, e.g. X.collect()')
|
|
369
|
+
backend = nw.get_native_namespace(X_nw)
|
|
370
|
+
if columns is not None and isinstance(X_nw, nw.DataFrame):
|
|
371
|
+
keep = [c for c in X_nw.columns if c in columns]
|
|
372
|
+
X_nw = X_nw.select(keep or X_nw.columns[:1])
|
|
373
|
+
|
|
374
|
+
def to_native(D):
|
|
375
|
+
duplicated = list(D.columns[D.columns.duplicated()])
|
|
376
|
+
if duplicated:
|
|
377
|
+
raise ValueError(f'the model matrix has duplicated column names {duplicated}, '
|
|
378
|
+
'which this dataframe backend cannot hold')
|
|
379
|
+
return nw.from_dict({str(c): D[c].to_numpy() for c in D.columns},
|
|
380
|
+
backend=backend).to_native()
|
|
381
|
+
|
|
382
|
+
X = X_nw.to_pandas()
|
|
383
|
+
if isinstance(X_nw, nw.Series):
|
|
384
|
+
dtypes = {X.name: X_nw.dtype}
|
|
385
|
+
else:
|
|
386
|
+
dtypes = dict(X_nw.schema)
|
|
387
|
+
for name, dtype in dtypes.items():
|
|
388
|
+
col = X if isinstance(X, pd.Series) else X[name]
|
|
389
|
+
if not isinstance(col.dtype, pd.CategoricalDtype):
|
|
390
|
+
continue
|
|
391
|
+
# some versions of polars convert an Enum to an unordered
|
|
392
|
+
# pd.Categorical: its categories are in the declared order
|
|
393
|
+
if dtype == nw.Enum:
|
|
394
|
+
col = col.cat.as_ordered()
|
|
395
|
+
elif not col.dtype.ordered:
|
|
396
|
+
col = col.cat.reorder_categories(sorted(col.dtype.categories))
|
|
397
|
+
else:
|
|
398
|
+
continue
|
|
399
|
+
if isinstance(X, pd.Series):
|
|
400
|
+
X = col
|
|
401
|
+
else:
|
|
402
|
+
X[name] = col
|
|
403
|
+
return X, to_native
|
|
404
|
+
|
|
405
|
+
class ModelSpec(TransformerMixin, BaseEstimator):
|
|
406
|
+
|
|
407
|
+
'''Parameters
|
|
408
|
+
----------
|
|
409
|
+
|
|
410
|
+
terms : sequence (optional)
|
|
411
|
+
|
|
412
|
+
Sequence of sets whose
|
|
413
|
+
elements are columns of *X* when fit.
|
|
414
|
+
For :py:class:`pd.DataFrame` these can be column
|
|
415
|
+
names.
|
|
416
|
+
|
|
417
|
+
intercept : bool, default=False
|
|
418
|
+
|
|
419
|
+
Include a column of ones named `'intercept'` as the first
|
|
420
|
+
column of the design? Pass `True` for statsmodels' models
|
|
421
|
+
(`OLS`, `GLM`, ...), which need the column; most scikit-learn
|
|
422
|
+
estimators, and glmstar's, fit the intercept themselves.
|
|
423
|
+
|
|
424
|
+
categorical_features : array-like of {bool, int} of shape (n_features)
|
|
425
|
+
or shape (n_categorical_features,), default=None.
|
|
426
|
+
|
|
427
|
+
Indicates the categorical features. Will be ignored if *X* is a :py:class:`pd.DataFrame`
|
|
428
|
+
or :py:class:`pd.Series`.
|
|
429
|
+
|
|
430
|
+
- None : no feature will be considered categorical for :py:class:`np.ndarray`.
|
|
431
|
+
- boolean array-like : boolean mask indicating categorical features.
|
|
432
|
+
- integer array-like : integer indices indicating categorical
|
|
433
|
+
features.
|
|
434
|
+
|
|
435
|
+
categorical_encoders : dict (optional)
|
|
436
|
+
|
|
437
|
+
Encoders for the categorical columns of *X*, i.e. the columns of
|
|
438
|
+
categorical dtype of a :py:class:`pd.DataFrame` or those given by
|
|
439
|
+
*categorical_features* for an :py:class:`np.ndarray`. Its keys are
|
|
440
|
+
either:
|
|
441
|
+
|
|
442
|
+
- `'categorical'` : the encoder of every categorical column, ordered
|
|
443
|
+
or not, without its own entry, cloned for each column. Defaults
|
|
444
|
+
to `Contrast(method='drop')`, which drops the first level.
|
|
445
|
+
- `'ordinal'` : if present, used instead for the ordered
|
|
446
|
+
`pd.Categorical` columns, e.g. `{'ordinal': Ordinal()}` to
|
|
447
|
+
score each by the position of its level. No default.
|
|
448
|
+
- a column of *X* (a name for a :py:class:`pd.DataFrame`, an integer
|
|
449
|
+
for an :py:class:`np.ndarray`) : the encoder of that column.
|
|
450
|
+
Like the others it is cloned, and the clone is fit, so the
|
|
451
|
+
dictionary's encoders are not modified.
|
|
452
|
+
|
|
453
|
+
For an encoder of a single term, see :py:func:`contrast`
|
|
454
|
+
and :py:func:`ordinal`.
|
|
455
|
+
|
|
456
|
+
sparse_output : bool (optional)
|
|
457
|
+
|
|
458
|
+
If True, the model matrix is a `scipy.sparse.csr_matrix`.
|
|
459
|
+
Categorical encodings and their interactions are then built
|
|
460
|
+
sparse throughout, so a design with categoricals of many levels
|
|
461
|
+
never forms its dense columns; other columns (numeric, splines,
|
|
462
|
+
etc.) are dense blocks stored sparsely. Not compatible with
|
|
463
|
+
`set_output`.
|
|
464
|
+
|
|
465
|
+
'''
|
|
466
|
+
|
|
467
|
+
def __init__(self,
|
|
468
|
+
terms=(),
|
|
469
|
+
intercept=False,
|
|
470
|
+
categorical_features=None,
|
|
471
|
+
categorical_encoders=None,
|
|
472
|
+
sparse_output=False
|
|
473
|
+
):
|
|
474
|
+
|
|
475
|
+
self.intercept = intercept
|
|
476
|
+
self.terms = terms
|
|
477
|
+
self.categorical_features = categorical_features
|
|
478
|
+
self.categorical_encoders = categorical_encoders
|
|
479
|
+
self.sparse_output = sparse_output
|
|
480
|
+
|
|
481
|
+
def _check_X(self, X, reset):
|
|
482
|
+
"""
|
|
483
|
+
Validate `X`, returning a `pd.DataFrame` or 2-d `np.ndarray`.
|
|
484
|
+
|
|
485
|
+
If `reset`, record `n_features_in_` (and `feature_names_in_`
|
|
486
|
+
for a `pd.DataFrame`), else check an `np.ndarray` has
|
|
487
|
+
`n_features_in_` columns. Columns of a `pd.DataFrame` are
|
|
488
|
+
matched by name, so it may have other columns, in any order.
|
|
489
|
+
"""
|
|
490
|
+
if isinstance(X, pd.Series):
|
|
491
|
+
X = X.to_frame()
|
|
492
|
+
if sp_sparse.issparse(X):
|
|
493
|
+
raise TypeError('Sparse data is not supported by ModelSpec; '
|
|
494
|
+
'convert it with X.toarray()')
|
|
495
|
+
if not isinstance(X, pd.DataFrame):
|
|
496
|
+
X = np.asarray(X)
|
|
497
|
+
if X.ndim != 2:
|
|
498
|
+
raise ValueError('Expected a 2-dimensional array or a pd.DataFrame, '
|
|
499
|
+
f'got an array with {X.ndim} dimension(s). '
|
|
500
|
+
'Reshape your data either using array.reshape(-1, 1) '
|
|
501
|
+
'if your data has a single feature or array.reshape(1, -1) '
|
|
502
|
+
'if it contains a single sample.')
|
|
503
|
+
if np.iscomplexobj(X):
|
|
504
|
+
raise ValueError('Complex data not supported')
|
|
505
|
+
if X.shape[0] == 0:
|
|
506
|
+
raise ValueError(f'Found array with 0 sample(s) (shape={X.shape}) '
|
|
507
|
+
'while a minimum of 1 is required.')
|
|
508
|
+
if X.shape[1] == 0:
|
|
509
|
+
raise ValueError(f'Found array with 0 feature(s) (shape={X.shape}) '
|
|
510
|
+
'while a minimum of 1 is required.')
|
|
511
|
+
if reset:
|
|
512
|
+
self.n_features_in_ = X.shape[1]
|
|
513
|
+
if (isinstance(X, pd.DataFrame) and
|
|
514
|
+
all(isinstance(c, str) for c in X.columns)):
|
|
515
|
+
self.feature_names_in_ = np.asarray(X.columns, dtype=object)
|
|
516
|
+
elif hasattr(self, 'feature_names_in_'):
|
|
517
|
+
del self.feature_names_in_
|
|
518
|
+
elif (not isinstance(X, pd.DataFrame) and
|
|
519
|
+
X.shape[1] != self.n_features_in_):
|
|
520
|
+
raise ValueError(f'X has {X.shape[1]} features, but ModelSpec '
|
|
521
|
+
f'is expecting {self.n_features_in_} features as input.')
|
|
522
|
+
return X
|
|
523
|
+
|
|
524
|
+
# the parameters of the terms, e.g. for GridSearchCV
|
|
525
|
+
|
|
526
|
+
def get_params(self, deep=True):
|
|
527
|
+
"""
|
|
528
|
+
Get parameters of this ModelSpec.
|
|
529
|
+
|
|
530
|
+
With `deep`, these include the parameters of each term, as
|
|
531
|
+
`'terms__<i>__<parameter>'` for the term at position `i` of
|
|
532
|
+
`terms` (`'terms__<i>__<j>__<parameter>'` for part `j` of an
|
|
533
|
+
interaction), e.g. `'terms__0__df'` for `ns('x', df=4)`.
|
|
534
|
+
"""
|
|
535
|
+
params = super().get_params(deep=deep)
|
|
536
|
+
# parameters are only validated at fit, so terms may be anything here
|
|
537
|
+
if deep and isinstance(self.terms, (list, tuple)):
|
|
538
|
+
for i, term in enumerate(self.terms):
|
|
539
|
+
for key, value in _term_params(term).items():
|
|
540
|
+
params[f'terms__{i}__{key}'] = value
|
|
541
|
+
return params
|
|
542
|
+
|
|
543
|
+
def set_params(self, **params):
|
|
544
|
+
"""
|
|
545
|
+
Set parameters of this ModelSpec.
|
|
546
|
+
|
|
547
|
+
A parameter of a term is set with `'terms__<i>__<parameter>'`,
|
|
548
|
+
where `i` is the position of the term in `terms` or the name
|
|
549
|
+
given to it, e.g. `'terms__price__df'` for
|
|
550
|
+
`ns('Price', df=4, name='price')`. A term made by `poly()`,
|
|
551
|
+
`ns()`, `bs()` or `pca()` is made again with the new parameter,
|
|
552
|
+
so its name (if not given) and its columns' names follow it.
|
|
553
|
+
`terms` itself is not modified: it is replaced by a copy, in
|
|
554
|
+
which every occurrence of the changed term (e.g. also in an
|
|
555
|
+
interaction) is changed.
|
|
556
|
+
"""
|
|
557
|
+
term_params = {k: v for k, v in params.items() if k.startswith('terms__')}
|
|
558
|
+
rest = {k: v for k, v in params.items() if not k.startswith('terms__')}
|
|
559
|
+
if 'terms' in rest: # a new terms first, then its parameters
|
|
560
|
+
super().set_params(terms=rest.pop('terms'))
|
|
561
|
+
if term_params:
|
|
562
|
+
terms = list(self.terms)
|
|
563
|
+
for key, value in term_params.items():
|
|
564
|
+
path = key.split('__')[1:]
|
|
565
|
+
i = _term_position(terms, path[0])
|
|
566
|
+
new_term, (old, new) = _set_term_param(terms[i], path[1:], value)
|
|
567
|
+
terms[i] = new_term
|
|
568
|
+
terms = [_substitute(t, old, new) for t in terms]
|
|
569
|
+
container = list if isinstance(self.terms, list) else tuple
|
|
570
|
+
super().set_params(terms=container(terms))
|
|
571
|
+
if rest:
|
|
572
|
+
super().set_params(**rest)
|
|
573
|
+
return self
|
|
574
|
+
|
|
575
|
+
# missing values in X propagate to the model matrix
|
|
576
|
+
|
|
577
|
+
def __sklearn_tags__(self):
|
|
578
|
+
tags = super().__sklearn_tags__()
|
|
579
|
+
tags.input_tags.allow_nan = True
|
|
580
|
+
return tags
|
|
581
|
+
|
|
582
|
+
def _more_tags(self): # scikit-learn < 1.6
|
|
583
|
+
return {'allow_nan': True}
|
|
584
|
+
|
|
585
|
+
def _include_intercept(self):
|
|
586
|
+
return bool(self.intercept)
|
|
587
|
+
|
|
588
|
+
def fit(self, X, y=None):
|
|
589
|
+
|
|
590
|
+
"""
|
|
591
|
+
Fit the encoders of each term, determining
|
|
592
|
+
the columns of the model matrix.
|
|
593
|
+
|
|
594
|
+
Parameters
|
|
595
|
+
----------
|
|
596
|
+
X : array-like
|
|
597
|
+
X on which model matrix will be evaluated.
|
|
598
|
+
If a :py:class:`pd.DataFrame` or :py:class:`pd.Series`, variables that are of
|
|
599
|
+
categorical dtype will be treated as categorical. Dataframes of other
|
|
600
|
+
backends supported by `narwhals`, such as `polars` or `pyarrow`, are
|
|
601
|
+
converted to `pandas`; `transform` then returns the model matrix
|
|
602
|
+
in the backend of its input.
|
|
603
|
+
|
|
604
|
+
y : None
|
|
605
|
+
Ignored. This parameter exists only for compatibility with
|
|
606
|
+
:py:class:`sklearn.pipeline.Pipeline`.
|
|
607
|
+
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
if not isinstance(self.sparse_output, (bool, np.bool_)):
|
|
611
|
+
raise ValueError(f'sparse_output should be True or False, got {self.sparse_output!r}')
|
|
612
|
+
native_columns = _native_columns(X)
|
|
613
|
+
X, _ = _from_native(X, _term_columns(self.terms))
|
|
614
|
+
X = self._check_X(X, reset=True)
|
|
615
|
+
if native_columns is not None: # describe all of X, not only the columns used
|
|
616
|
+
self.n_features_in_ = len(native_columns)
|
|
617
|
+
self.feature_names_in_ = np.asarray(native_columns, dtype=object)
|
|
618
|
+
|
|
619
|
+
# built at fit, not in __init__, so set_params(categorical_encoders=...)
|
|
620
|
+
# takes effect; update(dict) as the keys may be integers
|
|
621
|
+
self.categorical_encoders_ = {'categorical': Contrast(method='drop')}
|
|
622
|
+
if self.categorical_encoders is not None:
|
|
623
|
+
self.categorical_encoders_.update(self.categorical_encoders)
|
|
624
|
+
|
|
625
|
+
if isinstance(X, (pd.DataFrame, pd.Series)):
|
|
626
|
+
(categorical_features,
|
|
627
|
+
self.is_ordinal_) = _categorical_from_df(X)
|
|
628
|
+
(self.is_categorical_,
|
|
629
|
+
self.known_categories_) = _check_categories(categorical_features,
|
|
630
|
+
X)
|
|
631
|
+
self.columns_ = X.columns
|
|
632
|
+
if self.is_categorical_ is None:
|
|
633
|
+
self.is_categorical_ = np.zeros(X.shape[1], bool)
|
|
634
|
+
self.is_ordinal_ = pd.Series(self.is_ordinal_,
|
|
635
|
+
index=self.columns_)
|
|
636
|
+
self.is_categorical_ = pd.Series(self.is_categorical_,
|
|
637
|
+
index=self.columns_)
|
|
638
|
+
else:
|
|
639
|
+
categorical_features = self.categorical_features
|
|
640
|
+
(self.is_categorical_,
|
|
641
|
+
self.known_categories_) = _check_categories(categorical_features,
|
|
642
|
+
X)
|
|
643
|
+
if self.is_categorical_ is None:
|
|
644
|
+
self.is_categorical_ = np.zeros(X.shape[1], bool)
|
|
645
|
+
self.is_ordinal_ = np.zeros(self.is_categorical_.shape,
|
|
646
|
+
bool)
|
|
647
|
+
self.columns_ = np.arange(X.shape[1])
|
|
648
|
+
|
|
649
|
+
self.features_ = {}
|
|
650
|
+
self.encoders_ = {}
|
|
651
|
+
|
|
652
|
+
# encoders are fit on clones so that `self.terms` is never
|
|
653
|
+
# mutated and the spec can be refit (or cloned and refit)
|
|
654
|
+
clone_memo = {}
|
|
655
|
+
|
|
656
|
+
self.column_info_ = _get_column_info(X,
|
|
657
|
+
self.columns_,
|
|
658
|
+
np.asarray(self.is_categorical_),
|
|
659
|
+
np.asarray(self.is_ordinal_),
|
|
660
|
+
categorical_encoders=self.categorical_encoders_)
|
|
661
|
+
|
|
662
|
+
# include each column as a Feature
|
|
663
|
+
# so that their columns are built if needed
|
|
664
|
+
|
|
665
|
+
for col_ in self.columns_:
|
|
666
|
+
self.features_[col_] = Feature((col_,), str(col_), None, pure_columns=True)
|
|
667
|
+
|
|
668
|
+
# find possible interactions and other features
|
|
669
|
+
|
|
670
|
+
tmp_cache = {}
|
|
671
|
+
|
|
672
|
+
# the encoders are fit on all of X, but the names and number of
|
|
673
|
+
# the columns of each term only need its fitted encoders: they are
|
|
674
|
+
# found by evaluating the terms on the first rows of X, so that
|
|
675
|
+
# fit does not build the model matrix
|
|
676
|
+
X_head = X.iloc[:2] if isinstance(X, pd.DataFrame) else X[:2]
|
|
677
|
+
|
|
678
|
+
for term in self.terms:
|
|
679
|
+
if isinstance(term, Feature):
|
|
680
|
+
term_ = _clone_term(term, clone_memo)
|
|
681
|
+
self.features_[term] = term_
|
|
682
|
+
build_columns(self.column_info_,
|
|
683
|
+
X,
|
|
684
|
+
term_,
|
|
685
|
+
encoders=self.encoders_,
|
|
686
|
+
col_cache=tmp_cache,
|
|
687
|
+
fit=True, sparse=self.sparse_output,
|
|
688
|
+
fit_only=True) # these encoders won't have been fit yet
|
|
689
|
+
for var, var_ in zip(term.variables, term_.variables):
|
|
690
|
+
if var not in self.features_ and isinstance(var, Feature):
|
|
691
|
+
self.features_[var] = var_
|
|
692
|
+
elif term not in self.column_info_:
|
|
693
|
+
# a tuple of features represents an interaction
|
|
694
|
+
if type(term) == type((1,)):
|
|
695
|
+
names = []
|
|
696
|
+
column_map = {}
|
|
697
|
+
column_names = {}
|
|
698
|
+
idx = 0
|
|
699
|
+
variables_ = []
|
|
700
|
+
for var in term:
|
|
701
|
+
if var in self.features_: # already fit
|
|
702
|
+
var_ = self.features_[var]
|
|
703
|
+
else:
|
|
704
|
+
var_ = _clone_term(var, clone_memo)
|
|
705
|
+
if isinstance(var_, Column):
|
|
706
|
+
var_.fit_encoder(X)
|
|
707
|
+
else:
|
|
708
|
+
build_columns(self.column_info_,
|
|
709
|
+
X,
|
|
710
|
+
var_,
|
|
711
|
+
encoders=self.encoders_,
|
|
712
|
+
col_cache=tmp_cache,
|
|
713
|
+
fit=True, sparse=self.sparse_output,
|
|
714
|
+
fit_only=True)
|
|
715
|
+
# column identifiers are kept as is, Features and
|
|
716
|
+
# Columns are replaced by their fitted copies
|
|
717
|
+
variables_.append(var_ if isinstance(var, (Feature, Column)) else var)
|
|
718
|
+
var = var_
|
|
719
|
+
cols, cur_names = build_columns(self.column_info_,
|
|
720
|
+
X_head,
|
|
721
|
+
var,
|
|
722
|
+
encoders=self.encoders_,
|
|
723
|
+
sparse=self.sparse_output)
|
|
724
|
+
column_map[var.name] = range(idx, idx + cols.shape[1])
|
|
725
|
+
column_names[var.name] = cur_names
|
|
726
|
+
idx += cols.shape[1]
|
|
727
|
+
names.append(var.name)
|
|
728
|
+
encoder_ = Interaction(names, column_map, column_names)
|
|
729
|
+
self.features_[term] = Feature(tuple(variables_), ':'.join(n for n in names), encoder_)
|
|
730
|
+
elif isinstance(term, Column):
|
|
731
|
+
term_ = _clone_term(term, clone_memo)
|
|
732
|
+
self.features_[term] = Feature((term_,), term.name, None, pure_columns=True)
|
|
733
|
+
term_.fit_encoder(X)
|
|
734
|
+
else:
|
|
735
|
+
raise ValueError('each element in a term should be a Feature, Column or identify a column')
|
|
736
|
+
|
|
737
|
+
# build the mapping of terms to columns and column names
|
|
738
|
+
|
|
739
|
+
self.column_names_ = {}
|
|
740
|
+
self.column_map_ = {}
|
|
741
|
+
self.terms_ = [self.features_[t] for t in self.terms]
|
|
742
|
+
|
|
743
|
+
idx = 0
|
|
744
|
+
if self._include_intercept():
|
|
745
|
+
self.column_map_['intercept'] = slice(0, 1)
|
|
746
|
+
idx += 1 # intercept will be first column
|
|
747
|
+
|
|
748
|
+
for term, term_ in zip(self.terms, self.terms_):
|
|
749
|
+
term_df, term_names = build_columns(self.column_info_,
|
|
750
|
+
X_head,
|
|
751
|
+
term_,
|
|
752
|
+
encoders=self.encoders_, sparse=self.sparse_output)
|
|
753
|
+
self.column_names_[term] = term_names
|
|
754
|
+
self.column_map_[term] = slice(idx, idx + term_df.shape[1])
|
|
755
|
+
idx += term_df.shape[1]
|
|
756
|
+
|
|
757
|
+
return self
|
|
758
|
+
|
|
759
|
+
def transform(self, X, y=None):
|
|
760
|
+
"""
|
|
761
|
+
Build design on X after fitting.
|
|
762
|
+
|
|
763
|
+
Parameters
|
|
764
|
+
----------
|
|
765
|
+
X : array-like
|
|
766
|
+
|
|
767
|
+
y : None
|
|
768
|
+
Ignored. This parameter exists only for compatibility with
|
|
769
|
+
:py:class:`sklearn.pipeline.Pipeline`.
|
|
770
|
+
"""
|
|
771
|
+
check_is_fitted(self)
|
|
772
|
+
X, to_native = _from_native(X, _term_columns(self.terms_))
|
|
773
|
+
X = self._check_X(X, reset=False)
|
|
774
|
+
D = build_model(self.column_info_,
|
|
775
|
+
X,
|
|
776
|
+
self.terms_,
|
|
777
|
+
intercept=self._include_intercept(),
|
|
778
|
+
encoders=self.encoders_,
|
|
779
|
+
sparse=self.sparse_output,
|
|
780
|
+
order='C' if to_native is None else 'F')
|
|
781
|
+
return self._to_output(D, to_native)
|
|
782
|
+
|
|
783
|
+
def _to_output(self, D, to_native):
|
|
784
|
+
# a model matrix for a non-pandas dataframe is returned in its
|
|
785
|
+
# backend, unless an output container was chosen with set_output
|
|
786
|
+
config = getattr(self, '_sklearn_output_config', {}).get('transform', 'default')
|
|
787
|
+
if to_native is None or config != 'default' or sp_sparse.issparse(D):
|
|
788
|
+
return D
|
|
789
|
+
return to_native(D)
|
|
790
|
+
|
|
791
|
+
def get_feature_names_out(self, input_features=None):
|
|
792
|
+
"""
|
|
793
|
+
Names of the columns of the model matrix.
|
|
794
|
+
|
|
795
|
+
Parameters
|
|
796
|
+
----------
|
|
797
|
+
input_features : None
|
|
798
|
+
Ignored. This parameter exists only for compatibility with
|
|
799
|
+
:py:class:`sklearn.pipeline.Pipeline`.
|
|
800
|
+
|
|
801
|
+
Returns
|
|
802
|
+
-------
|
|
803
|
+
names : np.ndarray of str
|
|
804
|
+
"""
|
|
805
|
+
check_is_fitted(self)
|
|
806
|
+
names = ['intercept'] if self._include_intercept() else []
|
|
807
|
+
for term in self.terms:
|
|
808
|
+
names.extend(str(n) for n in self.column_names_[term])
|
|
809
|
+
if not names: # build_model returns a 'zero' column
|
|
810
|
+
names = ['zero']
|
|
811
|
+
return np.asarray(names, dtype=object)
|
|
812
|
+
|
|
813
|
+
# ModelSpec specific methods
|
|
814
|
+
|
|
815
|
+
@property
|
|
816
|
+
def names(self, help='Name for each term in model specification.'):
|
|
817
|
+
names = []
|
|
818
|
+
if self._include_intercept():
|
|
819
|
+
names = ['intercept']
|
|
820
|
+
return names + [t.name for t in self.terms_]
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def build_submodel(self,
|
|
824
|
+
X,
|
|
825
|
+
terms):
|
|
826
|
+
"""
|
|
827
|
+
Build design on X after fitting.
|
|
828
|
+
|
|
829
|
+
Parameters
|
|
830
|
+
----------
|
|
831
|
+
X : array-like
|
|
832
|
+
X on which columns are evaluated.
|
|
833
|
+
|
|
834
|
+
terms : [Feature]
|
|
835
|
+
Sequence of features. Elements of `self.terms`
|
|
836
|
+
are replaced by their fitted versions in `self.terms_`.
|
|
837
|
+
|
|
838
|
+
Returns
|
|
839
|
+
-------
|
|
840
|
+
D : array-like
|
|
841
|
+
Design matrix created with `terms`
|
|
842
|
+
"""
|
|
843
|
+
|
|
844
|
+
check_is_fitted(self)
|
|
845
|
+
X, to_native = _from_native(X, _term_columns(terms))
|
|
846
|
+
X = self._check_X(X, reset=False)
|
|
847
|
+
terms = [self.features_.get(t, t) for t in terms]
|
|
848
|
+
D = build_model(self.column_info_,
|
|
849
|
+
X,
|
|
850
|
+
terms,
|
|
851
|
+
intercept=self._include_intercept(),
|
|
852
|
+
encoders=self.encoders_,
|
|
853
|
+
sparse=self.sparse_output,
|
|
854
|
+
order='C' if to_native is None else 'F')
|
|
855
|
+
return self._to_output(D, to_native)
|
|
856
|
+
|
|
857
|
+
def build_sequence(self,
|
|
858
|
+
X,
|
|
859
|
+
anova_type='sequential'):
|
|
860
|
+
"""
|
|
861
|
+
Build implied sequence of submodels
|
|
862
|
+
based on successively including more terms.
|
|
863
|
+
|
|
864
|
+
Parameters
|
|
865
|
+
----------
|
|
866
|
+
X : array-like
|
|
867
|
+
X on which columns are evaluated.
|
|
868
|
+
|
|
869
|
+
anova_type: str
|
|
870
|
+
One of "sequential" or "drop".
|
|
871
|
+
|
|
872
|
+
Returns
|
|
873
|
+
-------
|
|
874
|
+
|
|
875
|
+
models : generator
|
|
876
|
+
Generator for sequence of models for ANOVA.
|
|
877
|
+
|
|
878
|
+
"""
|
|
879
|
+
|
|
880
|
+
check_is_fitted(self)
|
|
881
|
+
X, to_native = _from_native(X, _term_columns(self.terms_))
|
|
882
|
+
X = self._check_X(X, reset=False)
|
|
883
|
+
|
|
884
|
+
if anova_type not in ['sequential', 'drop']:
|
|
885
|
+
raise ValueError('anova_type must be one of ["sequential", "drop"]')
|
|
886
|
+
|
|
887
|
+
if self.sparse_output:
|
|
888
|
+
blocks = [np.ones((X.shape[0], 1)) if self._include_intercept() else
|
|
889
|
+
np.zeros((X.shape[0], 1))]
|
|
890
|
+
for term_ in self.terms_:
|
|
891
|
+
blocks.append(build_columns(self.column_info_,
|
|
892
|
+
X,
|
|
893
|
+
term_,
|
|
894
|
+
encoders=self.encoders_,
|
|
895
|
+
fit=False,
|
|
896
|
+
sparse=True)[0])
|
|
897
|
+
if anova_type == 'sequential':
|
|
898
|
+
return (_stack_sparse(blocks[:i], X.shape[0]) for i in range(1, len(blocks)+1))
|
|
899
|
+
return (_stack_sparse([b for j, b in enumerate(blocks) if j != i], X.shape[0])
|
|
900
|
+
for i in range(len(blocks)))
|
|
901
|
+
|
|
902
|
+
col_cache = {} # avoid recomputing the same columns
|
|
903
|
+
|
|
904
|
+
dfs = []
|
|
905
|
+
|
|
906
|
+
if self._include_intercept():
|
|
907
|
+
df_int = pd.DataFrame({'intercept':np.ones(X.shape[0])})
|
|
908
|
+
if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
909
|
+
df_int.index = X.index
|
|
910
|
+
dfs.append(df_int)
|
|
911
|
+
else:
|
|
912
|
+
df_int = pd.DataFrame({'zero':np.zeros(X.shape[0])})
|
|
913
|
+
if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
914
|
+
df_int.index = X.index
|
|
915
|
+
dfs.append(df_int)
|
|
916
|
+
|
|
917
|
+
for term_ in self.terms_:
|
|
918
|
+
term_df, _ = build_columns(self.column_info_,
|
|
919
|
+
X,
|
|
920
|
+
term_,
|
|
921
|
+
col_cache=col_cache,
|
|
922
|
+
encoders=self.encoders_,
|
|
923
|
+
fit=False)
|
|
924
|
+
if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
925
|
+
term_df.index = X.index
|
|
926
|
+
# as in build_model, make boolean columns float
|
|
927
|
+
for i, col in enumerate(term_df.columns):
|
|
928
|
+
if term_df.iloc[:,i].dtype == bool:
|
|
929
|
+
term_df[col] = term_df.iloc[:,i].astype(float)
|
|
930
|
+
|
|
931
|
+
dfs.append(term_df)
|
|
932
|
+
|
|
933
|
+
if anova_type == 'sequential':
|
|
934
|
+
if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
935
|
+
seq = (pd.concat(dfs[:i], axis=1) for i in range(1, len(dfs)+1))
|
|
936
|
+
else:
|
|
937
|
+
seq = (np.column_stack(dfs[:i]) for i in range(1, len(dfs)+1))
|
|
938
|
+
elif anova_type == 'drop':
|
|
939
|
+
if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
940
|
+
seq = (pd.concat([dfs[j] for j in range(len(dfs)) if j != i], axis=1) for i in range(len(dfs)))
|
|
941
|
+
else:
|
|
942
|
+
seq = (np.column_stack([dfs[j] for j in range(len(dfs)) if j != i]) for i in range(len(dfs)))
|
|
943
|
+
else:
|
|
944
|
+
raise ValueError('anova_type must be one of ["sequential", "drop"]')
|
|
945
|
+
return (self._to_output(D, to_native) for D in seq)
|
|
946
|
+
|
|
947
|
+
def _clone_term(term, memo):
|
|
948
|
+
"""
|
|
949
|
+
Copy a Feature or Column, replacing each encoder
|
|
950
|
+
(recursively) with an unfitted clone.
|
|
951
|
+
|
|
952
|
+
`memo` maps `id()` of terms and encoders already copied
|
|
953
|
+
to their copies, so a Feature or encoder that appears
|
|
954
|
+
in several places is copied (and later fit) only once.
|
|
955
|
+
"""
|
|
956
|
+
|
|
957
|
+
if not isinstance(term, (Feature, Column)):
|
|
958
|
+
return term # a column identifier
|
|
959
|
+
if id(term) in memo:
|
|
960
|
+
return memo[id(term)]
|
|
961
|
+
|
|
962
|
+
encoder = term.encoder
|
|
963
|
+
if encoder is not None:
|
|
964
|
+
if id(encoder) not in memo:
|
|
965
|
+
memo[id(encoder)] = clone(encoder, safe=False)
|
|
966
|
+
encoder = memo[id(encoder)]
|
|
967
|
+
|
|
968
|
+
if isinstance(term, Feature):
|
|
969
|
+
variables = tuple(_clone_term(v, memo) for v in term.variables)
|
|
970
|
+
term_ = term._replace(variables=variables, encoder=encoder)
|
|
971
|
+
else:
|
|
972
|
+
term_ = term._replace(encoder=encoder)
|
|
973
|
+
memo[id(term)] = term_
|
|
974
|
+
return term_
|
|
975
|
+
|
|
976
|
+
def fit_encoder(encoders, var, X):
|
|
977
|
+
"""
|
|
978
|
+
Fit an encoder if not already registered
|
|
979
|
+
in `encoders`.
|
|
980
|
+
|
|
981
|
+
Parameters
|
|
982
|
+
----------
|
|
983
|
+
|
|
984
|
+
encoders : dict
|
|
985
|
+
Dictionary of encoders for each feature.
|
|
986
|
+
|
|
987
|
+
var : Feature
|
|
988
|
+
Feature whose encoder will be fit.
|
|
989
|
+
|
|
990
|
+
X : array-like
|
|
991
|
+
X on which encoder will be fit.
|
|
992
|
+
|
|
993
|
+
"""
|
|
994
|
+
|
|
995
|
+
if var.encoder not in encoders:
|
|
996
|
+
encoders[var] = var.encoder.fit(X)
|
|
997
|
+
|
|
998
|
+
def build_columns(column_info, X, var, encoders={}, col_cache={}, fit=False, sparse=False,
|
|
999
|
+
fit_only=False, as_array=False):
|
|
1000
|
+
"""
|
|
1001
|
+
Build columns for a Feature from X.
|
|
1002
|
+
|
|
1003
|
+
Parameters
|
|
1004
|
+
----------
|
|
1005
|
+
|
|
1006
|
+
column_info: dict
|
|
1007
|
+
Dictionary with values specifying sets of columns to
|
|
1008
|
+
be concatenated into a design matrix.
|
|
1009
|
+
|
|
1010
|
+
X : array-like
|
|
1011
|
+
X on which columns are evaluated.
|
|
1012
|
+
|
|
1013
|
+
var : Feature
|
|
1014
|
+
Feature whose columns will be built, typically a key in `column_info`.
|
|
1015
|
+
|
|
1016
|
+
encoders : dict
|
|
1017
|
+
Dict that stores encoder of each Feature.
|
|
1018
|
+
|
|
1019
|
+
col_cache: dict
|
|
1020
|
+
Dict where columns will be stored --
|
|
1021
|
+
if `var.name` in `col_cache` then just
|
|
1022
|
+
returns those columns.
|
|
1023
|
+
|
|
1024
|
+
fit : bool (optional)
|
|
1025
|
+
If True, then try to fit encoder.
|
|
1026
|
+
Will raise an error if encoder has already been fit.
|
|
1027
|
+
|
|
1028
|
+
sparse : bool (optional)
|
|
1029
|
+
If True, return the columns as a `scipy.sparse` matrix or
|
|
1030
|
+
`np.ndarray` (not a `pd.DataFrame`), keeping categorical
|
|
1031
|
+
encodings and their interactions sparse.
|
|
1032
|
+
|
|
1033
|
+
fit_only : bool (optional)
|
|
1034
|
+
With `fit`, fit the encoders of a Feature (and of the features
|
|
1035
|
+
and columns it is built from) without evaluating its columns:
|
|
1036
|
+
returns `(None, None)`.
|
|
1037
|
+
|
|
1038
|
+
as_array : bool (optional)
|
|
1039
|
+
If True, return the columns as a 2-d `np.ndarray` rather than
|
|
1040
|
+
a `pd.DataFrame`.
|
|
1041
|
+
|
|
1042
|
+
"""
|
|
1043
|
+
|
|
1044
|
+
if var in column_info:
|
|
1045
|
+
var = column_info[var]
|
|
1046
|
+
|
|
1047
|
+
docache = DOCACHE and not sparse
|
|
1048
|
+
if docache and joblib_hash([var, X]) in col_cache:
|
|
1049
|
+
return col_cache[joblib_hash([var, X])]
|
|
1050
|
+
|
|
1051
|
+
if isinstance(var, Column):
|
|
1052
|
+
if docache:
|
|
1053
|
+
if joblib_hash([var, X]) not in col_cache:
|
|
1054
|
+
cols, names = var.get_columns(X, fit=fit)
|
|
1055
|
+
col_cache[joblib_hash([var, X])] = cols, names
|
|
1056
|
+
cols, name = col_cache[joblib_hash([var, X])]
|
|
1057
|
+
else:
|
|
1058
|
+
cols, names = var.get_columns(X, fit=fit, sparse=sparse)
|
|
1059
|
+
elif isinstance(var, Feature):
|
|
1060
|
+
if fit and fit_only and not var.encoder:
|
|
1061
|
+
# nothing to fit here: only the variables need fitting
|
|
1062
|
+
for v in var.variables:
|
|
1063
|
+
if v in column_info:
|
|
1064
|
+
continue
|
|
1065
|
+
if isinstance(v, Feature):
|
|
1066
|
+
build_columns(column_info, X, v, encoders=encoders,
|
|
1067
|
+
col_cache=col_cache, fit=True, sparse=sparse,
|
|
1068
|
+
fit_only=True)
|
|
1069
|
+
elif isinstance(v, Column):
|
|
1070
|
+
v.fit_encoder(X)
|
|
1071
|
+
return None, None
|
|
1072
|
+
cols = []
|
|
1073
|
+
names = []
|
|
1074
|
+
for v in var.variables:
|
|
1075
|
+
cur, cur_names = build_columns(column_info,
|
|
1076
|
+
X,
|
|
1077
|
+
v,
|
|
1078
|
+
encoders=encoders,
|
|
1079
|
+
col_cache=col_cache,
|
|
1080
|
+
fit=fit,
|
|
1081
|
+
sparse=sparse,
|
|
1082
|
+
as_array=True)
|
|
1083
|
+
cols.append(cur)
|
|
1084
|
+
names.extend(cur_names)
|
|
1085
|
+
if any(sp_sparse.issparse(c) for c in cols):
|
|
1086
|
+
cols = sp_sparse.hstack([c if sp_sparse.issparse(c) else
|
|
1087
|
+
sp_sparse.csc_matrix(_as_2d(c)) for c in cols],
|
|
1088
|
+
format='csc')
|
|
1089
|
+
elif len(cols) == 1: # np.column_stack would copy it
|
|
1090
|
+
cols = _as_2d(cols[0])
|
|
1091
|
+
else:
|
|
1092
|
+
cols = np.column_stack(cols)
|
|
1093
|
+
if len(names) != cols.shape[1]:
|
|
1094
|
+
names = ['{0}[{1}]'.format(var.name, j) for j in range(cols.shape[1])]
|
|
1095
|
+
if var.encoder:
|
|
1096
|
+
if sp_sparse.issparse(cols) and isinstance(var.encoder, Interaction):
|
|
1097
|
+
df_cols = cols # products of sparse columns stay sparse
|
|
1098
|
+
else:
|
|
1099
|
+
df_cols = pd.DataFrame(_dense(cols),
|
|
1100
|
+
columns=names,
|
|
1101
|
+
copy=False)
|
|
1102
|
+
try:
|
|
1103
|
+
check_is_fitted(var.encoder)
|
|
1104
|
+
# ModelSpec.fit clones encoders, so one that is already fit
|
|
1105
|
+
# was kept by clone(), e.g. a sklearn.frozen.FrozenEstimator:
|
|
1106
|
+
# use it as is
|
|
1107
|
+
if fit and var not in encoders:
|
|
1108
|
+
encoders[var] = var.encoder
|
|
1109
|
+
except NotFittedError as e:
|
|
1110
|
+
if fit:
|
|
1111
|
+
fit_encoder(encoders,
|
|
1112
|
+
var,
|
|
1113
|
+
df_cols)
|
|
1114
|
+
# known issue with Pipeline
|
|
1115
|
+
# https://github.com/scikit-learn/scikit-learn/issues/18648
|
|
1116
|
+
elif isinstance(var.encoder, Pipeline):
|
|
1117
|
+
check_is_fitted(var.encoder, 'n_features_in_')
|
|
1118
|
+
else:
|
|
1119
|
+
raise(e)
|
|
1120
|
+
except Exception as e: # was not the NotFitted
|
|
1121
|
+
raise ValueError(e)
|
|
1122
|
+
if fit_only:
|
|
1123
|
+
return None, None
|
|
1124
|
+
if var.use_transform:
|
|
1125
|
+
cols = var.encoder.transform(df_cols)
|
|
1126
|
+
else:
|
|
1127
|
+
cols = var.encoder.predict(df_cols)
|
|
1128
|
+
if not sparse:
|
|
1129
|
+
cols = _dense(cols)
|
|
1130
|
+
elif not sp_sparse.issparse(cols):
|
|
1131
|
+
cols = np.asarray(cols)
|
|
1132
|
+
if hasattr(var.encoder, 'columns_') and not var.override_encoder_colnames:
|
|
1133
|
+
names = var.encoder.columns_
|
|
1134
|
+
else:
|
|
1135
|
+
if cols.ndim > 1 and cols.shape[1] > 1:
|
|
1136
|
+
names = ['{0}[{1}]'.format(var.name, j) for j in range(cols.shape[1])]
|
|
1137
|
+
else:
|
|
1138
|
+
names = [var.name]
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
else:
|
|
1142
|
+
raise ValueError('expecting either a column or a Feature')
|
|
1143
|
+
if sparse:
|
|
1144
|
+
return (cols if sp_sparse.issparse(cols) else _as_2d(cols)), names
|
|
1145
|
+
if as_array:
|
|
1146
|
+
return _as_2d(_dense(cols)), names
|
|
1147
|
+
val = pd.DataFrame(_dense(cols), columns=names, copy=False)
|
|
1148
|
+
|
|
1149
|
+
if isinstance(X, (pd.DataFrame, pd.Series)):
|
|
1150
|
+
val.index = X.index
|
|
1151
|
+
|
|
1152
|
+
if docache:
|
|
1153
|
+
col_cache[joblib_hash([var.name, X])] = (val, names)
|
|
1154
|
+
return val, names
|
|
1155
|
+
|
|
1156
|
+
def _fill_model(column_info, X, terms, intercept, encoders, order='C'):
|
|
1157
|
+
# the dense model matrix, as float: its columns are allocated once
|
|
1158
|
+
# (their number found from the first rows of X) and each term's
|
|
1159
|
+
# block is copied in and freed, so the peak memory is the model
|
|
1160
|
+
# matrix and its largest block. order='F' stores each column
|
|
1161
|
+
# contiguously, so that e.g. polars can take them without a copy
|
|
1162
|
+
X_head = X.iloc[:2] if isinstance(X, pd.DataFrame) else X[:2]
|
|
1163
|
+
names, widths = (['intercept'], [1]) if intercept else ([], [])
|
|
1164
|
+
for term_ in terms:
|
|
1165
|
+
head, head_names = build_columns(column_info, X_head, term_,
|
|
1166
|
+
encoders=encoders, as_array=True)
|
|
1167
|
+
names.extend(head_names)
|
|
1168
|
+
widths.append(head.shape[1])
|
|
1169
|
+
D = np.empty((X.shape[0], sum(widths)), order=order)
|
|
1170
|
+
start = 0
|
|
1171
|
+
if intercept:
|
|
1172
|
+
D[:, 0] = 1
|
|
1173
|
+
start = 1
|
|
1174
|
+
for term_, width in zip(terms, widths[1:] if intercept else widths):
|
|
1175
|
+
block = build_columns(column_info, X, term_, encoders=encoders,
|
|
1176
|
+
as_array=True)[0]
|
|
1177
|
+
D[:, start:start + width] = block
|
|
1178
|
+
start += width
|
|
1179
|
+
del block
|
|
1180
|
+
if isinstance(X, (pd.DataFrame, pd.Series)):
|
|
1181
|
+
return pd.DataFrame(D, columns=names, index=X.index, copy=False)
|
|
1182
|
+
return D
|
|
1183
|
+
|
|
1184
|
+
def _dense(cols):
|
|
1185
|
+
return cols.toarray() if sp_sparse.issparse(cols) else np.asarray(cols)
|
|
1186
|
+
|
|
1187
|
+
def _as_2d(cols):
|
|
1188
|
+
cols = np.asarray(cols)
|
|
1189
|
+
return cols.reshape((cols.shape[0], -1))
|
|
1190
|
+
|
|
1191
|
+
def _stack_sparse(blocks, n):
|
|
1192
|
+
# blocks (sparse or dense) as one CSR matrix of floats
|
|
1193
|
+
if not blocks:
|
|
1194
|
+
blocks = [np.zeros((n, 1))]
|
|
1195
|
+
return sp_sparse.hstack([b if sp_sparse.issparse(b) else
|
|
1196
|
+
sp_sparse.csr_matrix(_as_2d(b).astype(float))
|
|
1197
|
+
for b in blocks], format='csr', dtype=float)
|
|
1198
|
+
|
|
1199
|
+
def build_model(column_info,
|
|
1200
|
+
X,
|
|
1201
|
+
terms,
|
|
1202
|
+
intercept=True,
|
|
1203
|
+
encoders={},
|
|
1204
|
+
sparse=False,
|
|
1205
|
+
order='C'):
|
|
1206
|
+
|
|
1207
|
+
"""
|
|
1208
|
+
Construct design matrix on a
|
|
1209
|
+
sequence of terms and X after
|
|
1210
|
+
fitting.
|
|
1211
|
+
|
|
1212
|
+
Parameters
|
|
1213
|
+
----------
|
|
1214
|
+
column_info: dict
|
|
1215
|
+
Dictionary with values specifying sets of columns to
|
|
1216
|
+
be concatenated into a design matrix.
|
|
1217
|
+
|
|
1218
|
+
X : array-like
|
|
1219
|
+
X on which columns are evaluated.
|
|
1220
|
+
|
|
1221
|
+
terms : [Feature]
|
|
1222
|
+
Sequence of features
|
|
1223
|
+
|
|
1224
|
+
encoders : dict
|
|
1225
|
+
Dict that stores encoder of each Feature.
|
|
1226
|
+
|
|
1227
|
+
sparse : bool
|
|
1228
|
+
If True, return a `scipy.sparse.csr_matrix`.
|
|
1229
|
+
|
|
1230
|
+
order : 'C' or 'F'
|
|
1231
|
+
The memory layout of a dense model matrix.
|
|
1232
|
+
|
|
1233
|
+
Returns
|
|
1234
|
+
-------
|
|
1235
|
+
df : np.ndarray, pd.DataFrame or scipy.sparse.csr_matrix
|
|
1236
|
+
Design matrix.
|
|
1237
|
+
"""
|
|
1238
|
+
|
|
1239
|
+
if sparse:
|
|
1240
|
+
blocks = [np.ones((X.shape[0], 1))] if intercept else []
|
|
1241
|
+
for term_ in terms:
|
|
1242
|
+
blocks.append(build_columns(column_info,
|
|
1243
|
+
X,
|
|
1244
|
+
term_,
|
|
1245
|
+
encoders=encoders,
|
|
1246
|
+
fit=False,
|
|
1247
|
+
sparse=True)[0])
|
|
1248
|
+
return _stack_sparse(blocks, X.shape[0])
|
|
1249
|
+
|
|
1250
|
+
if terms or intercept:
|
|
1251
|
+
return _fill_model(column_info, X, terms, intercept, encoders, order=order)
|
|
1252
|
+
|
|
1253
|
+
dfs = []
|
|
1254
|
+
|
|
1255
|
+
col_cache = {} # avoid recomputing the same columns
|
|
1256
|
+
|
|
1257
|
+
if intercept:
|
|
1258
|
+
df = pd.DataFrame({'intercept':np.ones(X.shape[0])})
|
|
1259
|
+
if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
1260
|
+
df.index = X.index
|
|
1261
|
+
dfs.append(df)
|
|
1262
|
+
|
|
1263
|
+
for term_ in terms:
|
|
1264
|
+
term_df = build_columns(column_info,
|
|
1265
|
+
X,
|
|
1266
|
+
term_,
|
|
1267
|
+
col_cache=col_cache,
|
|
1268
|
+
encoders=encoders,
|
|
1269
|
+
fit=False)[0]
|
|
1270
|
+
dfs.append(term_df)
|
|
1271
|
+
|
|
1272
|
+
if len(dfs):
|
|
1273
|
+
if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
1274
|
+
df = pd.concat(dfs, axis='columns')
|
|
1275
|
+
df.index = X.index
|
|
1276
|
+
else:
|
|
1277
|
+
return np.column_stack(dfs).astype(float)
|
|
1278
|
+
else: # return a 0 design
|
|
1279
|
+
zero = np.zeros(X.shape[0])
|
|
1280
|
+
if isinstance(X, (pd.Series, pd.DataFrame)):
|
|
1281
|
+
df = pd.DataFrame({'zero': zero})
|
|
1282
|
+
df.index = X.index
|
|
1283
|
+
else:
|
|
1284
|
+
return zero
|
|
1285
|
+
|
|
1286
|
+
# if we reach here, we will be returning a DataFrame
|
|
1287
|
+
# make sure all columns are floats
|
|
1288
|
+
|
|
1289
|
+
for i, col in enumerate(df.columns):
|
|
1290
|
+
if df.iloc[:,i].dtype == bool:
|
|
1291
|
+
df[col] = df.iloc[:,i].astype(float)
|
|
1292
|
+
return df
|
|
1293
|
+
|
|
1294
|
+
def derived_feature(variables, encoder=None, name=None, use_transform=True):
|
|
1295
|
+
"""
|
|
1296
|
+
Create a Feature, optionally
|
|
1297
|
+
applying an encoder to the stacked columns.
|
|
1298
|
+
|
|
1299
|
+
Parameters
|
|
1300
|
+
----------
|
|
1301
|
+
|
|
1302
|
+
variables : [column identifier, Column, Feature]
|
|
1303
|
+
Variables to apply transform to. Could be
|
|
1304
|
+
column identifiers or variables: all columns
|
|
1305
|
+
will be stacked before encoding.
|
|
1306
|
+
|
|
1307
|
+
name : str (optional)
|
|
1308
|
+
Defaults to `str(encoder)`.
|
|
1309
|
+
|
|
1310
|
+
encoder : transform-like (optional)
|
|
1311
|
+
Transform obeying sklearn fit/transform convention.
|
|
1312
|
+
|
|
1313
|
+
Returns
|
|
1314
|
+
-------
|
|
1315
|
+
|
|
1316
|
+
var : Feature
|
|
1317
|
+
"""
|
|
1318
|
+
|
|
1319
|
+
if name is None:
|
|
1320
|
+
name = str(encoder)
|
|
1321
|
+
var = Feature(tuple([v for v in variables]),
|
|
1322
|
+
name,
|
|
1323
|
+
encoder,
|
|
1324
|
+
use_transform=use_transform,
|
|
1325
|
+
override_encoder_colnames=True)
|
|
1326
|
+
return var
|
|
1327
|
+
|
|
1328
|
+
def contrast(col,
|
|
1329
|
+
method='drop',
|
|
1330
|
+
drop_level=None):
|
|
1331
|
+
"""
|
|
1332
|
+
Create encoding of categorical feature.
|
|
1333
|
+
|
|
1334
|
+
Parameters
|
|
1335
|
+
----------
|
|
1336
|
+
|
|
1337
|
+
col: column identifier
|
|
1338
|
+
|
|
1339
|
+
method: 'drop', 'sum', None or callable
|
|
1340
|
+
|
|
1341
|
+
drop_level: level identifier
|
|
1342
|
+
|
|
1343
|
+
Returns
|
|
1344
|
+
-------
|
|
1345
|
+
|
|
1346
|
+
var : Feature
|
|
1347
|
+
|
|
1348
|
+
"""
|
|
1349
|
+
|
|
1350
|
+
if isinstance(col, Column):
|
|
1351
|
+
name = col.name
|
|
1352
|
+
col = col.idx
|
|
1353
|
+
else:
|
|
1354
|
+
name = str(col)
|
|
1355
|
+
encoder = Contrast(method,
|
|
1356
|
+
drop_level=drop_level)
|
|
1357
|
+
return Column(col,
|
|
1358
|
+
name,
|
|
1359
|
+
is_categorical=True,
|
|
1360
|
+
encoder=encoder)
|
|
1361
|
+
|
|
1362
|
+
def ordinal(col, name=None, levels=None):
|
|
1363
|
+
"""
|
|
1364
|
+
Create ordinal encoding of categorical feature: a single
|
|
1365
|
+
column holding the position of each entry's level (see `Ordinal`).
|
|
1366
|
+
|
|
1367
|
+
Parameters
|
|
1368
|
+
----------
|
|
1369
|
+
|
|
1370
|
+
col: column identifier
|
|
1371
|
+
|
|
1372
|
+
name: str (optional)
|
|
1373
|
+
Defaults to `'ordinal(col)'`.
|
|
1374
|
+
|
|
1375
|
+
levels: sequence (optional)
|
|
1376
|
+
The levels in order. Needed unless `col` is an ordered
|
|
1377
|
+
categorical, or numeric.
|
|
1378
|
+
|
|
1379
|
+
Returns
|
|
1380
|
+
-------
|
|
1381
|
+
|
|
1382
|
+
var : Column
|
|
1383
|
+
|
|
1384
|
+
"""
|
|
1385
|
+
|
|
1386
|
+
if isinstance(col, Column):
|
|
1387
|
+
colname = col.name
|
|
1388
|
+
col = col.idx
|
|
1389
|
+
else:
|
|
1390
|
+
colname = str(col)
|
|
1391
|
+
if name is None:
|
|
1392
|
+
name = f'ordinal({colname})'
|
|
1393
|
+
return Column(col,
|
|
1394
|
+
name,
|
|
1395
|
+
is_categorical=True,
|
|
1396
|
+
is_ordinal=True,
|
|
1397
|
+
columns=(name,),
|
|
1398
|
+
encoder=Ordinal(levels=levels))
|
|
1399
|
+
|
|
1400
|
+
def poly(col,
|
|
1401
|
+
degree=1,
|
|
1402
|
+
intercept=False,
|
|
1403
|
+
raw=False,
|
|
1404
|
+
name=None):
|
|
1405
|
+
|
|
1406
|
+
"""
|
|
1407
|
+
Create a polynomial Feature
|
|
1408
|
+
for a given column.
|
|
1409
|
+
|
|
1410
|
+
Additional `args` and `kwargs`
|
|
1411
|
+
are passed to `Poly`.
|
|
1412
|
+
|
|
1413
|
+
Parameters
|
|
1414
|
+
----------
|
|
1415
|
+
|
|
1416
|
+
col : column identifier or Column
|
|
1417
|
+
Column to transform.
|
|
1418
|
+
|
|
1419
|
+
degree : int, default=1
|
|
1420
|
+
Degree of polynomial.
|
|
1421
|
+
|
|
1422
|
+
intercept : bool, default=False
|
|
1423
|
+
Include a column for intercept?
|
|
1424
|
+
|
|
1425
|
+
raw : bool, default=False
|
|
1426
|
+
If False, perform a QR decomposition on the resulting
|
|
1427
|
+
matrix of powers of centered and / or scaled features.
|
|
1428
|
+
|
|
1429
|
+
name : str (optional)
|
|
1430
|
+
Defaults to one derived from col.
|
|
1431
|
+
|
|
1432
|
+
Returns
|
|
1433
|
+
-------
|
|
1434
|
+
|
|
1435
|
+
var : Feature
|
|
1436
|
+
"""
|
|
1437
|
+
shortname, klass = 'poly', Poly
|
|
1438
|
+
spec = _spec('poly', (col,), degree=degree, intercept=intercept, raw=raw, name=name)
|
|
1439
|
+
encoder = klass(degree=degree,
|
|
1440
|
+
raw=raw,
|
|
1441
|
+
intercept=intercept)
|
|
1442
|
+
if name is None:
|
|
1443
|
+
if isinstance(col, Column):
|
|
1444
|
+
name = col.name
|
|
1445
|
+
else:
|
|
1446
|
+
name = str(col)
|
|
1447
|
+
|
|
1448
|
+
kwargs = {}
|
|
1449
|
+
if intercept:
|
|
1450
|
+
kwargs['intercept'] = True
|
|
1451
|
+
if raw:
|
|
1452
|
+
kwargs['raw'] = True
|
|
1453
|
+
|
|
1454
|
+
_args = _argstring(degree=degree,
|
|
1455
|
+
**kwargs)
|
|
1456
|
+
if _args:
|
|
1457
|
+
name = ', '.join([name, _args])
|
|
1458
|
+
|
|
1459
|
+
name = f'{shortname}({name})'
|
|
1460
|
+
|
|
1461
|
+
return derived_feature([col],
|
|
1462
|
+
name=name,
|
|
1463
|
+
encoder=encoder)._replace(spec=spec)
|
|
1464
|
+
|
|
1465
|
+
def ns(col, intercept=False, name=None, **spline_args):
|
|
1466
|
+
"""
|
|
1467
|
+
Create a natural spline Feature
|
|
1468
|
+
for a given column.
|
|
1469
|
+
|
|
1470
|
+
Additional *spline_args*
|
|
1471
|
+
are passed to :py:class:`NaturalSpline` along with *intercept*.
|
|
1472
|
+
|
|
1473
|
+
Parameters
|
|
1474
|
+
----------
|
|
1475
|
+
|
|
1476
|
+
col : column identifier or Column
|
|
1477
|
+
|
|
1478
|
+
intercept : bool
|
|
1479
|
+
Include an intercept column.
|
|
1480
|
+
|
|
1481
|
+
name : str (optional)
|
|
1482
|
+
Defaults to one derived from col.
|
|
1483
|
+
|
|
1484
|
+
Returns
|
|
1485
|
+
-------
|
|
1486
|
+
|
|
1487
|
+
var : Feature
|
|
1488
|
+
|
|
1489
|
+
"""
|
|
1490
|
+
shortname, klass = 'ns', NaturalSpline
|
|
1491
|
+
spec = _spec('ns', (col,), intercept=intercept, name=name, **spline_args)
|
|
1492
|
+
if name is None:
|
|
1493
|
+
if isinstance(col, Column):
|
|
1494
|
+
name = col.name
|
|
1495
|
+
else:
|
|
1496
|
+
name = str(col)
|
|
1497
|
+
|
|
1498
|
+
_args = _argstring(**spline_args)
|
|
1499
|
+
if _args:
|
|
1500
|
+
name = ', '.join([name, _args])
|
|
1501
|
+
|
|
1502
|
+
name = f'{shortname}({name})'
|
|
1503
|
+
encoder = klass(intercept=intercept,
|
|
1504
|
+
**spline_args)
|
|
1505
|
+
return derived_feature([col],
|
|
1506
|
+
name=name,
|
|
1507
|
+
encoder=encoder)._replace(spec=spec)
|
|
1508
|
+
|
|
1509
|
+
def bs(col, intercept=False, name=None, **spline_args):
|
|
1510
|
+
"""
|
|
1511
|
+
Create a B-spline Feature
|
|
1512
|
+
for a given column.
|
|
1513
|
+
|
|
1514
|
+
Additional args and *spline_args*
|
|
1515
|
+
are passed to :py:class:`modelmatrix.transforms.BSpline`
|
|
1516
|
+
along with *intercept*.
|
|
1517
|
+
|
|
1518
|
+
Parameters
|
|
1519
|
+
----------
|
|
1520
|
+
|
|
1521
|
+
col : column identifier or Column
|
|
1522
|
+
|
|
1523
|
+
intercept : bool
|
|
1524
|
+
Include an intercept column.
|
|
1525
|
+
|
|
1526
|
+
name : str (optional)
|
|
1527
|
+
Defaults to one derived from col.
|
|
1528
|
+
|
|
1529
|
+
Returns
|
|
1530
|
+
-------
|
|
1531
|
+
|
|
1532
|
+
var : Feature
|
|
1533
|
+
|
|
1534
|
+
"""
|
|
1535
|
+
shortname, klass = 'bs', BSpline
|
|
1536
|
+
spec = _spec('bs', (col,), intercept=intercept, name=name, **spline_args)
|
|
1537
|
+
if name is None:
|
|
1538
|
+
if isinstance(col, Column):
|
|
1539
|
+
name = col.name
|
|
1540
|
+
else:
|
|
1541
|
+
name = str(col)
|
|
1542
|
+
|
|
1543
|
+
_args = _argstring(**spline_args)
|
|
1544
|
+
if _args:
|
|
1545
|
+
name = ', '.join([name, _args])
|
|
1546
|
+
|
|
1547
|
+
name = f'{shortname}({name})'
|
|
1548
|
+
encoder = klass(intercept=intercept,
|
|
1549
|
+
**spline_args)
|
|
1550
|
+
return derived_feature([col],
|
|
1551
|
+
name=name,
|
|
1552
|
+
encoder=encoder)._replace(spec=spec)
|
|
1553
|
+
|
|
1554
|
+
def pca(variables, name, scale=False, **pca_args):
|
|
1555
|
+
"""
|
|
1556
|
+
Create PCA encoding of features
|
|
1557
|
+
from a sequence of variables.
|
|
1558
|
+
|
|
1559
|
+
Additional args and *pca_args*
|
|
1560
|
+
are passed to :py:class:`modelmatrix.transforms.PCA`.
|
|
1561
|
+
|
|
1562
|
+
Parameters
|
|
1563
|
+
----------
|
|
1564
|
+
|
|
1565
|
+
variables : [column identifier, Column or Feature]
|
|
1566
|
+
Sequence whose columns will be encoded by PCA.
|
|
1567
|
+
|
|
1568
|
+
Returns
|
|
1569
|
+
-------
|
|
1570
|
+
|
|
1571
|
+
var : Feature
|
|
1572
|
+
|
|
1573
|
+
"""
|
|
1574
|
+
shortname, klass = 'pca', PCA
|
|
1575
|
+
spec = _spec('pca', (variables, name), scale=scale, **pca_args)
|
|
1576
|
+
encoder = klass(**pca_args)
|
|
1577
|
+
if scale:
|
|
1578
|
+
scaler = StandardScaler(with_mean=True,
|
|
1579
|
+
with_std=True)
|
|
1580
|
+
encoder = make_pipeline(scaler, encoder)
|
|
1581
|
+
|
|
1582
|
+
_args = _argstring(**pca_args)
|
|
1583
|
+
|
|
1584
|
+
if _args:
|
|
1585
|
+
name = ', '.join([name, _args])
|
|
1586
|
+
|
|
1587
|
+
return derived_feature(variables,
|
|
1588
|
+
name=f'{shortname}({name})',
|
|
1589
|
+
encoder=encoder)._replace(spec=spec)
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _argstring(*args, **kwargs):
|
|
1593
|
+
_args = ', '.join([str(a) for a in args])
|
|
1594
|
+
_kwargs = ', '.join([f'{k}={v}' for k, v in kwargs.items()])
|
|
1595
|
+
|
|
1596
|
+
if args and kwargs:
|
|
1597
|
+
return ', '.join([_args, _kwargs])
|
|
1598
|
+
elif args:
|
|
1599
|
+
return _args
|
|
1600
|
+
elif kwargs:
|
|
1601
|
+
return _kwargs
|
|
1602
|
+
else:
|
|
1603
|
+
return ''
|
|
1604
|
+
|
|
1605
|
+
#### parameters of the terms of a ModelSpec, e.g. for GridSearchCV
|
|
1606
|
+
|
|
1607
|
+
def _freeze(v):
|
|
1608
|
+
# a hashable version of a parameter value, so that Features stay hashable
|
|
1609
|
+
if isinstance(v, np.ndarray):
|
|
1610
|
+
return tuple(_freeze(x) for x in v.tolist())
|
|
1611
|
+
if isinstance(v, (list, tuple)) and not isinstance(v, (Feature, Column)):
|
|
1612
|
+
return tuple(_freeze(x) for x in v)
|
|
1613
|
+
return v
|
|
1614
|
+
|
|
1615
|
+
def _spec(helper, args, **kwargs):
|
|
1616
|
+
# how `helper` was called; a name is kept only if one was given
|
|
1617
|
+
if kwargs.get('name') is None:
|
|
1618
|
+
kwargs.pop('name', None)
|
|
1619
|
+
return (helper, tuple(_freeze(a) for a in args),
|
|
1620
|
+
tuple(sorted((k, _freeze(v)) for k, v in kwargs.items())))
|
|
1621
|
+
|
|
1622
|
+
def _rebuild(feature, **changes):
|
|
1623
|
+
# the Feature made again by its helper, with some parameters changed
|
|
1624
|
+
helper, args, kwargs = feature.spec
|
|
1625
|
+
kwargs = dict(kwargs)
|
|
1626
|
+
kwargs.update(changes)
|
|
1627
|
+
if helper == 'pca':
|
|
1628
|
+
args = (list(args[0]),) + tuple(args[1:])
|
|
1629
|
+
return {'poly': poly, 'ns': ns, 'bs': bs, 'pca': pca}[helper](*args, **kwargs)
|
|
1630
|
+
|
|
1631
|
+
def _encoder_params(encoder):
|
|
1632
|
+
if encoder is None or not hasattr(encoder, 'get_params'):
|
|
1633
|
+
return {}
|
|
1634
|
+
if isinstance(encoder, Pipeline): # pca(..., scale=True): the PCA's parameters
|
|
1635
|
+
encoder = encoder.steps[-1][1]
|
|
1636
|
+
return encoder.get_params(deep=False)
|
|
1637
|
+
|
|
1638
|
+
def _term_params(term):
|
|
1639
|
+
"""
|
|
1640
|
+
The parameters of a term that `ModelSpec.set_params` can change,
|
|
1641
|
+
e.g. {'df': 4, 'intercept': False, ...} for ns('x', df=4).
|
|
1642
|
+
"""
|
|
1643
|
+
if isinstance(term, Feature):
|
|
1644
|
+
params = _encoder_params(term.encoder)
|
|
1645
|
+
if term.spec is not None:
|
|
1646
|
+
params.update((k, v) for k, v in term.spec[2] if k != 'name')
|
|
1647
|
+
return params
|
|
1648
|
+
if isinstance(term, Column):
|
|
1649
|
+
return _encoder_params(term.encoder)
|
|
1650
|
+
if isinstance(term, tuple): # an interaction: the parameters of its parts
|
|
1651
|
+
return {f'{j}__{k}': v for j, part in enumerate(term)
|
|
1652
|
+
for k, v in _term_params(part).items()}
|
|
1653
|
+
return {}
|
|
1654
|
+
|
|
1655
|
+
def _set_term_param(term, path, value):
|
|
1656
|
+
"""
|
|
1657
|
+
A copy of `term` with the parameter at `path` (e.g. ['df'], or
|
|
1658
|
+
['0', 'degree'] for a part of an interaction) set to `value`, and the
|
|
1659
|
+
(old, new) pair of the Feature or Column that was replaced.
|
|
1660
|
+
"""
|
|
1661
|
+
if isinstance(term, tuple) and not isinstance(term, (Feature, Column)):
|
|
1662
|
+
if not path or not path[0].isdigit() or int(path[0]) >= len(term):
|
|
1663
|
+
raise ValueError(f'expecting the position of a part of the interaction {term!r}, '
|
|
1664
|
+
f'got {"__".join(path)!r}')
|
|
1665
|
+
j = int(path[0])
|
|
1666
|
+
part, replaced = _set_term_param(term[j], path[1:], value)
|
|
1667
|
+
return term[:j] + (part,) + term[j+1:], replaced
|
|
1668
|
+
if not isinstance(term, (Feature, Column)):
|
|
1669
|
+
raise ValueError(f'the term {term!r} has no parameters to set')
|
|
1670
|
+
valid = _term_params(term)
|
|
1671
|
+
if len(path) != 1 or path[0] not in valid:
|
|
1672
|
+
raise ValueError(f'invalid parameter {"__".join(path)!r} for the term {term.name!r}; '
|
|
1673
|
+
f'valid parameters are {sorted(valid)}')
|
|
1674
|
+
param = path[0]
|
|
1675
|
+
if isinstance(term, Feature) and term.spec is not None:
|
|
1676
|
+
new = _rebuild(term, **{param: value})
|
|
1677
|
+
else:
|
|
1678
|
+
encoder = term.encoder
|
|
1679
|
+
if isinstance(encoder, Pipeline):
|
|
1680
|
+
encoder = clone(encoder).set_params(**{f'{encoder.steps[-1][0]}__{param}': value})
|
|
1681
|
+
else:
|
|
1682
|
+
encoder = clone(encoder).set_params(**{param: value})
|
|
1683
|
+
new = term._replace(encoder=encoder)
|
|
1684
|
+
return new, (term, new)
|
|
1685
|
+
|
|
1686
|
+
def _substitute(term, old, new):
|
|
1687
|
+
# `term` with every occurrence of the object `old` replaced by `new`
|
|
1688
|
+
if term is old:
|
|
1689
|
+
return new
|
|
1690
|
+
if isinstance(term, Feature):
|
|
1691
|
+
variables = tuple(_substitute(v, old, new) for v in term.variables)
|
|
1692
|
+
if any(a is not b for a, b in zip(variables, term.variables)):
|
|
1693
|
+
return term._replace(variables=variables)
|
|
1694
|
+
return term
|
|
1695
|
+
if isinstance(term, tuple) and not isinstance(term, Column):
|
|
1696
|
+
parts = tuple(_substitute(v, old, new) for v in term)
|
|
1697
|
+
return parts if any(a is not b for a, b in zip(parts, term)) else term
|
|
1698
|
+
return term
|
|
1699
|
+
|
|
1700
|
+
def _term_position(terms, key):
|
|
1701
|
+
# the position in `terms` of the term named by `key`: a position, or a name
|
|
1702
|
+
if key.isdigit():
|
|
1703
|
+
if int(key) >= len(terms):
|
|
1704
|
+
raise ValueError(f'there is no term at position {key}: there are {len(terms)} terms')
|
|
1705
|
+
return int(key)
|
|
1706
|
+
found = [i for i, t in enumerate(terms) if getattr(t, 'name', None) == key]
|
|
1707
|
+
if len(found) != 1:
|
|
1708
|
+
raise ValueError(f'expecting a position in terms, or the name of exactly one term, '
|
|
1709
|
+
f'got {key!r} ({len(found)} terms have that name)')
|
|
1710
|
+
return found[0]
|