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
modelmatrix/__init__.py
ADDED
|
@@ -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']
|
modelmatrix/_version.py
ADDED
|
@@ -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
|