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.
@@ -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]