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,26 @@
1
+ """
2
+ Model matrices from tabular data, as scikit-learn transformers.
3
+ """
4
+
5
+ from .model_spec import (ModelSpec,
6
+ Feature,
7
+ Contrast,
8
+ Ordinal,
9
+ poly,
10
+ ns,
11
+ bs,
12
+ derived_feature,
13
+ pca,
14
+ contrast,
15
+ ordinal,
16
+ build_columns)
17
+ from .columns import Column
18
+
19
+ try:
20
+ from ._version import version as __version__
21
+ except ImportError: # not installed
22
+ __version__ = 'unknown'
23
+
24
+ __all__ = ['ModelSpec', 'Feature', 'Column', 'Contrast', 'Ordinal',
25
+ 'poly', 'ns', 'bs', 'derived_feature', 'pca', 'contrast',
26
+ 'ordinal', 'build_columns']
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = 'g2f08b48f3'
modelmatrix/columns.py ADDED
@@ -0,0 +1,324 @@
1
+
2
+ from typing import NamedTuple, Any
3
+ from copy import copy
4
+
5
+ import numpy as np, pandas as pd
6
+ from scipy import sparse as sp_sparse
7
+ from sklearn.base import clone
8
+ from sklearn.preprocessing import (OneHotEncoder,
9
+ OrdinalEncoder)
10
+ from sklearn.utils.validation import check_is_fitted
11
+ from sklearn.exceptions import NotFittedError
12
+
13
+ class Column(NamedTuple):
14
+
15
+ """
16
+ A column extractor with a possible
17
+ encoder (following `sklearn` fit/transform template).
18
+ """
19
+
20
+ idx: Any
21
+ name: str
22
+ is_categorical: bool = False
23
+ is_ordinal: bool = False
24
+ columns: tuple = ()
25
+ encoder: Any = None
26
+
27
+ def get_columns(self, X, fit=False, sparse=False):
28
+
29
+ """
30
+ Extract associated column from X,
31
+ encoding it with `self.encoder` if not None.
32
+
33
+ Parameters
34
+ ----------
35
+
36
+ X : array-like
37
+ X on which model matrix will be evaluated.
38
+ If a `pd.DataFrame` or `pd.Series`, variables that are of
39
+ categorical dtype will be treated as categorical.
40
+
41
+ fit : bool
42
+ If True, fit `self.encoder` on corresponding
43
+ column (if it is not already fit). The encoder
44
+ must be fit to evaluate the columns: a `Column`
45
+ used in the terms of a `ModelSpec` is not itself fit
46
+ when the `ModelSpec` is, as a copy of it is.
47
+
48
+ sparse : bool
49
+ If True, the columns may be returned as a
50
+ `scipy.sparse` matrix, e.g. for a categorical encoding.
51
+
52
+ Returns
53
+ -------
54
+ cols : `np.ndarray`
55
+ Evaluated columns -- if an encoder is used,
56
+ several columns may be produced.
57
+
58
+ names : (str,)
59
+ Column names
60
+ """
61
+
62
+ cols = _get_column(self.idx, X)
63
+ if fit:
64
+ self.fit_encoder(X)
65
+
66
+ if self.encoder is not None:
67
+ transform = self.encoder.transform
68
+ if sparse and hasattr(self.encoder, '_islp_sparse_transform'):
69
+ transform = self.encoder._islp_sparse_transform
70
+ try:
71
+ cols = transform(cols)
72
+ except NotFittedError as e:
73
+ raise NotFittedError(
74
+ f"The encoder of Column '{self.name}' is not fit: "
75
+ "use get_columns(X, fit=True) to fit it on X.") from e
76
+ except ValueError:
77
+ # commonly raised for transformers given pd.Seriies
78
+ # try casting to a 2-d ndarray
79
+ cols = transform(np.asarray(cols).reshape((-1,1)))
80
+ else:
81
+ cols = _check_numeric(cols, self.name)
82
+ if sp_sparse.issparse(cols):
83
+ if not sparse:
84
+ cols = cols.toarray()
85
+ else:
86
+ cols = np.asarray(cols)
87
+
88
+ names = self.columns
89
+ if hasattr(self.encoder, 'columns_'):
90
+ names = ['{0}[{1}]'.format(self.name, c)
91
+ for c in self.encoder.columns_]
92
+ if not names:
93
+ names = ['{0}[{1}]'.format(self.name, i)
94
+ for i in range(cols.shape[1])]
95
+ return cols, names
96
+
97
+ def fit_encoder(self, X):
98
+
99
+ """
100
+ Fit `self.encoder`.
101
+
102
+ Parameters
103
+ ----------
104
+ X : array-like
105
+ X on which encoder will be fit.
106
+
107
+ Returns
108
+ -------
109
+ None
110
+ """
111
+ cols = _get_column(self.idx, X)
112
+ if self.encoder is not None:
113
+ try:
114
+ check_is_fitted(self.encoder)
115
+ except NotFittedError:
116
+ self.encoder.fit(cols)
117
+ return np.asarray(cols)
118
+
119
+
120
+ # private functions
121
+
122
+ _NUMERIC_INFERRED = ['integer', 'floating', 'mixed-integer-float',
123
+ 'decimal', 'boolean', 'empty']
124
+
125
+ def _check_numeric(col, name):
126
+ """
127
+ Raise a `ValueError` if `col` (a column with no encoder)
128
+ is not numeric: a string column must be made categorical
129
+ explicitly. Returns `col`, as floats if it was of object dtype.
130
+ """
131
+ values = np.asarray(col)
132
+ if values.dtype.kind in 'biuf':
133
+ return col
134
+ if values.dtype.kind == 'O':
135
+ if pd.api.types.infer_dtype(values.reshape(-1), skipna=True) in _NUMERIC_INFERRED:
136
+ return values.astype(float)
137
+ raise ValueError(
138
+ f"column '{name}' is not numeric; to treat it as categorical, "
139
+ f"convert it with `X['{name}'] = X['{name}'].astype('category')` "
140
+ f"or use `contrast('{name}')` in the terms")
141
+
142
+
143
+ def _get_column(idx,
144
+ X,
145
+ loc=True):
146
+ """
147
+ Extract column `idx` from `X`
148
+ as a two-dimensional ndarray or a pd.DataFrame
149
+ """
150
+ if isinstance(X, np.ndarray):
151
+ col = X[:, [idx]]
152
+ elif hasattr(X, 'loc'):
153
+ if loc:
154
+ col = X.loc[:, [idx]]
155
+ else: # use iloc instead
156
+ col = X.iloc[:, [idx]]
157
+ else:
158
+ raise ValueError('expecting an ndarray or a ' +
159
+ '"loc/iloc" methods, got %s' % str(X))
160
+
161
+ return col
162
+
163
+
164
+ def _get_column_info(X,
165
+ columns,
166
+ is_categorical,
167
+ is_ordinal,
168
+ categorical_encoders={}
169
+ ):
170
+
171
+
172
+ """
173
+ Compute a dictionary
174
+ of `Column` instances for each column
175
+ of `X`. Keys are `columns`.
176
+
177
+ Categorical columns use the encoder given for that
178
+ column in `categorical_encoders`, else the one under
179
+ 'ordinal' for an ordered column if that key is present,
180
+ else the one under 'categorical'.
181
+
182
+ """
183
+
184
+ column_info = {}
185
+ for i, col in enumerate(columns):
186
+ if type(col) == int:
187
+ name = f'X{col}'
188
+ else:
189
+ name = str(col)
190
+ if is_categorical[i]:
191
+ if col in categorical_encoders:
192
+ key = col
193
+ elif is_ordinal[i] and 'ordinal' in categorical_encoders:
194
+ key = 'ordinal'
195
+ else:
196
+ key = 'categorical'
197
+ encoder = clone(categorical_encoders[key])
198
+ Xcol = _get_column(col, X)
199
+ encoder.fit(Xcol)
200
+ if hasattr(encoder, 'columns_'):
201
+ columns = ['{0}[{1}]'.format(col, c)
202
+ for c in encoder.columns_]
203
+ else: # only transform to count the columns
204
+ cols = encoder.transform(Xcol)
205
+ if not sp_sparse.issparse(cols):
206
+ cols = np.asarray(cols)
207
+ if cols.ndim == 1 or cols.shape[1] == 1:
208
+ columns = ['{0}'.format(col)]
209
+ else:
210
+ columns = ['{0}[{1}]'.format(col, c)
211
+ for c in range(cols.shape[1])]
212
+ column_info[col] = Column(col,
213
+ name,
214
+ bool(is_categorical[i]),
215
+ bool(is_ordinal[i]),
216
+ tuple(columns),
217
+ encoder)
218
+ else:
219
+ Xcol = _get_column(col, X)
220
+ column_info[col] = Column(col,
221
+ name,
222
+ columns=(name,))
223
+ return column_info
224
+
225
+ # extracted from method of BaseHistGradientBoosting from
226
+ # https://github.com/scikit-learn/scikit-learn/blob/2beed55847ee70d363bdbfe14ee4401438fba057/sklearn/ensemble/_hist_gradient_boosting/gradient_boosting.py
227
+ # max_bins is ignored
228
+
229
+ def _check_categories(categorical_features, X):
230
+ """Check and validate categorical features in X
231
+
232
+ Return
233
+ ------
234
+ is_categorical : ndarray of shape (n_features,) or None, dtype=bool
235
+ Indicates whether a feature is categorical. If no feature is
236
+ categorical, this is None.
237
+ known_categories : list of size n_features or None
238
+ The list contains, for each feature:
239
+ - an array of shape (n_categories,) with the unique cat values
240
+ - None if the feature is not categorical
241
+ None if no feature is categorical.
242
+ """
243
+ if categorical_features is None:
244
+ return None, None
245
+
246
+ categorical_features = np.asarray(categorical_features)
247
+
248
+ if categorical_features.size == 0:
249
+ return None, None
250
+
251
+ if categorical_features.dtype.kind not in ('i', 'b'):
252
+ raise ValueError("categorical_features must be an array-like of "
253
+ "bools or array-like of ints.")
254
+
255
+ n_features = X.shape[1]
256
+
257
+ # check for categorical features as indices
258
+ if categorical_features.dtype.kind == 'i':
259
+ if (np.max(categorical_features) >= n_features
260
+ or np.min(categorical_features) < 0):
261
+ raise ValueError("categorical_features set as integer "
262
+ "indices must be in [0, n_features - 1]")
263
+ is_categorical = np.zeros(n_features, dtype=bool)
264
+ is_categorical[categorical_features] = True
265
+ else:
266
+ if categorical_features.shape[0] != n_features:
267
+ raise ValueError("categorical_features set as a boolean mask "
268
+ "must have shape (n_features,), got: "
269
+ f"{categorical_features.shape}")
270
+ is_categorical = categorical_features
271
+
272
+ if not np.any(is_categorical):
273
+ return None, None
274
+
275
+ # compute the known categories in the training data. We need to do
276
+ # that here instead of in the BinMapper because in case of early
277
+ # stopping, the mapper only gets a fraction of the training data.
278
+ known_categories = []
279
+
280
+ for f_idx in range(n_features):
281
+ if is_categorical[f_idx]:
282
+ # _get_column returns a 2-d column: flatten it to its values
283
+ categories = np.array([v for v in
284
+ set(np.asarray(_get_column(f_idx,
285
+ X,
286
+ loc=False)).ravel())])
287
+ missing = []
288
+ for c in categories:
289
+ try:
290
+ missing.append(np.isnan(c))
291
+ except TypeError: # not a float
292
+ missing.append(False)
293
+ missing = np.array(missing)
294
+ if missing.any():
295
+ categories = sorted(categories[~missing])
296
+
297
+ else:
298
+ categories = None
299
+ known_categories.append(categories)
300
+
301
+ return is_categorical, known_categories
302
+
303
+
304
+ def _categorical_from_df(df):
305
+ """
306
+ Find
307
+ """
308
+ is_categorical = []
309
+ is_ordinal = []
310
+ for c in df.columns:
311
+ try:
312
+ if df[c].dtype == 'category':
313
+ is_categorical.append(True)
314
+ is_ordinal.append(df[c].cat.ordered)
315
+ else:
316
+ is_categorical.append(False)
317
+ is_ordinal.append(False)
318
+ except (TypeError, AttributeError):
319
+ is_categorical.append(False)
320
+ is_ordinal.append(False)
321
+ is_categorical = np.asarray(is_categorical)
322
+ is_ordinal = np.asarray(is_ordinal)
323
+
324
+ return is_categorical, is_ordinal