sd-auto-encoder 0.3.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,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: sd-auto-encoder
3
+ Version: 0.3.0
4
+ Summary: auto-picks onehot/ordinal/binary encoding per categorical column
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: pandas>=1.2
9
+ Dynamic: license-file
10
+
11
+ # SDEncoder
12
+
13
+ Auto-picks an encoding method per categorical column instead of you manually
14
+ deciding onehot vs ordinal vs label encoding for each one. Companion to
15
+ SDScaler, same idea but for categorical data instead of numeric.
16
+
17
+ Two things in here:
18
+ - `SDEncoder` - for feature columns (X)
19
+ - `SDTargetEncoder` - for your target column (y)
20
+
21
+ They're kept separate on purpose. Label-style integer codes are fine for a
22
+ target (a model just needs distinct class ids, order doesn't matter), but
23
+ doing that on a *feature* column tricks a lot of models into thinking there's
24
+ a numeric relationship between categories that don't actually have one.
25
+ `SDEncoder` will refuse to touch a Series for this reason, and `SDTargetEncoder`
26
+ will refuse a DataFrame.
27
+
28
+ ## Install
29
+
30
+ ```
31
+ cd SDEncoder
32
+ pip install -e .
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```python
38
+ import pandas as pd
39
+ from sdencoder import SDEncoder, SDTargetEncoder
40
+
41
+ df = pd.DataFrame({
42
+ "color": ["red", "blue", "green", "blue"],
43
+ "size": ["S", "M", "L", "M"],
44
+ "is_member": ["yes", "no", "yes", "yes"],
45
+ })
46
+
47
+ encoder = SDEncoder(ordinal_maps={"size": ["S", "M", "L"]})
48
+ X_encoded = encoder.fit_transform(df)
49
+ print(X_encoded)
50
+ print(encoder.summary())
51
+ ```
52
+
53
+ For the target:
54
+
55
+ ```python
56
+ y = pd.Series(["approved", "denied", "approved"])
57
+ target_encoder = SDTargetEncoder()
58
+ y_encoded = target_encoder.fit_transform(y)
59
+ ```
60
+
61
+ Same train/test rule as SDScaler - fit on train, just transform on test:
62
+
63
+ ```python
64
+ X_train_enc = encoder.fit_transform(X_train)
65
+ X_test_enc = encoder.transform(X_test)
66
+ ```
67
+
68
+ ## How SDEncoder decides
69
+
70
+ For each object/category column:
71
+ - if you gave it an `ordinal_maps` entry for that column -> uses your order
72
+ - 2 categories -> binary (0/1)
73
+ - more than 2 but under `onehot_cutoff` (default 10) -> onehot
74
+ - more than `onehot_cutoff` -> falls back to ordinal, since onehot on
75
+ something like a "city" column with 200 values would blow up your
76
+ feature count for not much benefit
77
+
78
+ Numeric columns are left completely alone.
79
+
80
+ `ordinal_maps` is the important one to use correctly - only pass a real
81
+ order there for columns that actually have one (like S/M/L, or
82
+ low/medium/high). Don't use it just to avoid getting a onehot column.
83
+
84
+ ## Unseen categories
85
+
86
+ If `.transform()` sees a category that wasn't there during `.fit()`, it
87
+ becomes `NaN` for ordinal/binary columns, or an all-zero row for onehot
88
+ columns. That's intentional - better to notice missing data than have it
89
+ silently mapped to some made-up number.
90
+
91
+ ## ColumnTransformer
92
+
93
+ If you'd rather use this inside an sklearn `Pipeline`:
94
+
95
+ ```python
96
+ encoder = SDEncoder(ordinal_maps={"size": ["S", "M", "L"]}).fit(df)
97
+ ct = encoder.to_column_transformer()
98
+ ```
99
+
100
+ This just builds an sklearn `ColumnTransformer` using the same decisions
101
+ SDEncoder already made. Needs scikit-learn installed, only used if you
102
+ actually call this method.
103
+
104
+ ## Files
105
+
106
+ ```
107
+ sdencoder/
108
+ encoder.py - SDEncoder, for X
109
+ target.py - SDTargetEncoder, for y
110
+ examples/
111
+ example_usage.py
112
+ tests/
113
+ test_encoder.py
114
+ ```
115
+
116
+ ## Todo / ideas
117
+
118
+ - frequency encoding as another fallback option for high-cardinality columns
119
+ - inverse_transform for onehot columns (currently raises NotImplementedError)
120
+ - handle missing values (NaN) as their own category instead of erroring later
@@ -0,0 +1,8 @@
1
+ sd_auto_encoder-0.3.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
2
+ sdencoder/__init__.py,sha256=k1MoH1x2uyLHrWxU-HDWGhxqJHOUGjGLg2--puD1Qes,90
3
+ sdencoder/encoder.py,sha256=Uon-kmLrrhdz3RkRyGtimx1JBB6VAJESwayiDLEmADc,9533
4
+ sdencoder/target.py,sha256=7VbtfkHSm_8eWOgCh8_O6UyHPT88yxHFaVp0EZ8a22A,2392
5
+ sd_auto_encoder-0.3.0.dist-info/METADATA,sha256=9UIDaC50N-lKJfvMV7ECTEvFW5iMewgrURfX8anMYGA,3616
6
+ sd_auto_encoder-0.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ sd_auto_encoder-0.3.0.dist-info/top_level.txt,sha256=FjkiQcQPbRudyfba0RAq7EgeDUnVJc4b1LJFiYfVsM0,10
8
+ sd_auto_encoder-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sdencoder
sdencoder/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .encoder import SDEncoder
2
+ from .target import SDTargetEncoder
3
+
4
+ __version__ = "0.3.0"
sdencoder/encoder.py ADDED
@@ -0,0 +1,230 @@
1
+ import pandas as pd
2
+
3
+ METHODS = ("onehot", "ordinal", "binary", "target")
4
+
5
+
6
+ class SDEncoder:
7
+ """
8
+ encodes categorical feature columns - picks onehot, ordinal, or binary
9
+ per column depending on how many categories it has.
10
+
11
+ encoder = SDEncoder()
12
+ X_encoded = encoder.fit_transform(X)
13
+ encoder.summary() # see what got picked per column
14
+
15
+ pass ordinal_maps for columns that actually have a real order, like
16
+ ordinal_maps={"education": ["high school", "bachelors", "masters", "phd"]}
17
+ anything not listed there just gets auto-picked based on category count.
18
+
19
+ don't use this on your target (y), use SDTargetEncoder for that instead.
20
+ label-style integer codes are fine for a target since the model just
21
+ needs distinct class ids, but doing that on a feature column makes the
22
+ model think there's a numeric order between categories that don't
23
+ actually have one (unless you gave it an ordinal_map on purpose).
24
+
25
+ pass target_encode_cols if you want specific high-cardinality columns
26
+ replaced with the (smoothed) mean of y per category instead of falling
27
+ back to ordinal. this one's opt-in only, never auto-picked, since it
28
+ needs y and carries more overfitting risk than the other three. if you
29
+ use it, fit() needs y: encoder.fit(X, y)
30
+
31
+ note on the target method: this does simple mean + smoothing, not the
32
+ full cross-fold fitting sklearn's own TargetEncoder does internally, so
33
+ it's a bit more overfit-prone on the training set itself. fine for a
34
+ class project, but for anything more serious use sklearn's TargetEncoder
35
+ directly with fit_transform() on train.
36
+ """
37
+
38
+ def __init__(self, onehot_cutoff=10, ordinal_maps=None, drop_first=False,
39
+ target_encode_cols=None, target_smoothing=10):
40
+ self.onehot_cutoff = onehot_cutoff
41
+ self.ordinal_maps = ordinal_maps or {}
42
+ self.drop_first = drop_first
43
+ self.target_encode_cols = target_encode_cols or []
44
+ self.target_smoothing = target_smoothing
45
+
46
+ self.stats_ = {}
47
+ self.columns_ = None
48
+ self.cat_columns_ = None
49
+ self.fitted = False
50
+
51
+ def fit(self, X, y=None):
52
+ if isinstance(X, pd.Series):
53
+ raise TypeError(
54
+ "SDEncoder wants a DataFrame of features, not a Series. "
55
+ "if this is your target column, use SDTargetEncoder instead."
56
+ )
57
+
58
+ if self.target_encode_cols and y is None:
59
+ raise ValueError(
60
+ "target_encode_cols is set but no y was given - "
61
+ "target encoding needs the target column, call fit(X, y)"
62
+ )
63
+
64
+ X = pd.DataFrame(X)
65
+ self.columns_ = list(X.columns)
66
+ # anything not numeric gets treated as categorical - covers "object"
67
+ # dtype, the newer pandas "str" dtype, and category dtype all at
68
+ # once instead of checking dtype names one by one (those checks
69
+ # broke on pandas 3.x, which reports plain strings as dtype "str")
70
+ self.cat_columns_ = [
71
+ c for c in self.columns_ if not pd.api.types.is_numeric_dtype(X[c])
72
+ ]
73
+
74
+ self.stats_ = {}
75
+ for col in self.cat_columns_:
76
+ if col in self.target_encode_cols:
77
+ self.stats_[col] = self._fit_target(X[col], y)
78
+ else:
79
+ self.stats_[col] = self._fit_one(X[col])
80
+
81
+ self.fitted = True
82
+ return self
83
+
84
+ def transform(self, X):
85
+ if not self.fitted:
86
+ raise RuntimeError("call fit() before transform()")
87
+
88
+ X = pd.DataFrame(X)
89
+ out = X.copy()
90
+
91
+ for col in self.cat_columns_:
92
+ stat = self.stats_[col]
93
+
94
+ if stat["method"] == "onehot":
95
+ dummies = pd.get_dummies(X[col], prefix=col)
96
+ # line up columns with what we saw during fit, in case a
97
+ # category is missing/new in this batch of data
98
+ dummies = dummies.reindex(columns=stat["dummy_columns"], fill_value=0)
99
+ out = out.drop(columns=[col])
100
+ out = pd.concat([out, dummies], axis=1)
101
+ elif stat["method"] == "target":
102
+ out[col] = X[col].map(stat["mapping"]).fillna(stat["global_mean"])
103
+ # unseen categories get the global mean here instead of NaN -
104
+ # that's the standard fallback for target encoding, "we have
105
+ # no info about this category so just guess the average"
106
+ else:
107
+ out[col] = X[col].map(stat["mapping"])
108
+ # categories not seen during fit turn into NaN on purpose,
109
+ # better than silently making up a code for them
110
+
111
+ return out
112
+
113
+ def fit_transform(self, X, y=None):
114
+ return self.fit(X, y).transform(X)
115
+
116
+ def inverse_transform(self, X):
117
+ if not self.fitted:
118
+ raise RuntimeError("call fit() before inverse_transform()")
119
+
120
+ X = pd.DataFrame(X).copy()
121
+ for col in self.cat_columns_:
122
+ stat = self.stats_[col]
123
+ if stat["method"] in ("onehot", "target"):
124
+ raise NotImplementedError(
125
+ f"can't auto-reverse a {stat['method']} column ('{col}') through this method"
126
+ )
127
+ reverse = {v: k for k, v in stat["mapping"].items()}
128
+ X[col] = X[col].map(reverse)
129
+
130
+ return X
131
+
132
+ def summary(self):
133
+ if not self.fitted:
134
+ raise RuntimeError("call fit() before summary()")
135
+
136
+ rows = []
137
+ for col in self.columns_:
138
+ if col in self.stats_:
139
+ rows.append({
140
+ "column": col,
141
+ "method": self.stats_[col]["method"],
142
+ "categories": self.stats_[col]["n_categories"],
143
+ })
144
+ else:
145
+ rows.append({"column": col, "method": "passthrough", "categories": None})
146
+
147
+ return pd.DataFrame(rows)
148
+
149
+ def to_column_transformer(self):
150
+ """
151
+ builds an sklearn ColumnTransformer that mirrors whatever this
152
+ encoder decided, in case you'd rather plug it into an sklearn
153
+ Pipeline instead of calling .transform() directly. needs sklearn.
154
+
155
+ note: target-encoded columns get dropped from the ColumnTransformer
156
+ version since sklearn's transformers don't take y at transform time
157
+ the way our target method does - if you're using target encoding,
158
+ just call SDEncoder.transform() directly instead of going through
159
+ this method for those columns.
160
+ """
161
+ if not self.fitted:
162
+ raise RuntimeError("call fit() before to_column_transformer()")
163
+
164
+ try:
165
+ from sklearn.compose import ColumnTransformer
166
+ from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
167
+ except ImportError:
168
+ raise ImportError("to_column_transformer() needs scikit-learn installed")
169
+
170
+ transformers = []
171
+ for col, stat in self.stats_.items():
172
+ if stat["method"] == "onehot":
173
+ transformers.append((f"{col}_onehot", OneHotEncoder(handle_unknown="ignore"), [col]))
174
+ elif stat["method"] == "target":
175
+ continue
176
+ else:
177
+ order = [list(stat["mapping"].keys())]
178
+ transformers.append((f"{col}_ordinal", OrdinalEncoder(categories=order), [col]))
179
+
180
+ return ColumnTransformer(transformers, remainder="passthrough")
181
+
182
+ # internal, don't call directly
183
+
184
+ def _fit_one(self, series):
185
+ col = series.name
186
+ n_unique = series.nunique(dropna=True)
187
+
188
+ if col in self.ordinal_maps:
189
+ order = self.ordinal_maps[col]
190
+ mapping = {cat: i for i, cat in enumerate(order)}
191
+ return {"method": "ordinal", "mapping": mapping, "n_categories": len(order)}
192
+
193
+ if n_unique == 2:
194
+ cats = sorted(series.dropna().unique())
195
+ mapping = {cats[0]: 0, cats[1]: 1}
196
+ return {"method": "binary", "mapping": mapping, "n_categories": 2}
197
+
198
+ if n_unique <= self.onehot_cutoff:
199
+ dummies = pd.get_dummies(series, prefix=col)
200
+ dummy_cols = list(dummies.columns)
201
+ if self.drop_first and len(dummy_cols) > 1:
202
+ dummy_cols = dummy_cols[1:]
203
+ return {"method": "onehot", "dummy_columns": dummy_cols, "n_categories": n_unique}
204
+
205
+ # too many categories for onehot to be practical, fall back to ordinal
206
+ cats = sorted(series.dropna().unique())
207
+ mapping = {cat: i for i, cat in enumerate(cats)}
208
+ return {"method": "ordinal", "mapping": mapping, "n_categories": n_unique}
209
+
210
+ def _fit_target(self, series, y):
211
+ y = pd.Series(y).reset_index(drop=True)
212
+ series = series.reset_index(drop=True)
213
+
214
+ global_mean = float(y.mean())
215
+ smoothing = self.target_smoothing
216
+
217
+ grouped = y.groupby(series).agg(["mean", "count"])
218
+ # blend each category's own mean with the global mean - categories
219
+ # with few rows lean more on the global mean, categories with lots
220
+ # of rows lean more on their own mean. classic smoothing trick so
221
+ # a category that only shows up once or twice doesn't get a wildly
222
+ # overfit value.
223
+ smoothed = (grouped["count"] * grouped["mean"] + smoothing * global_mean) / (grouped["count"] + smoothing)
224
+
225
+ return {
226
+ "method": "target",
227
+ "mapping": smoothed.to_dict(),
228
+ "global_mean": global_mean,
229
+ "n_categories": series.nunique(dropna=True),
230
+ }
sdencoder/target.py ADDED
@@ -0,0 +1,66 @@
1
+ import pandas as pd
2
+
3
+
4
+ class SDTargetEncoder:
5
+ """
6
+ encodes a target column (y) into integer class labels, basically what
7
+ LabelEncoder does. only meant for a single column, not a full feature
8
+ dataframe - that's what SDEncoder is for.
9
+
10
+ by default it just sorts categories alphabetically and numbers them,
11
+ which is fine if you don't care which class gets which number. but if
12
+ your target actually has a real order (low/medium/high) or you care
13
+ which class ends up as 1 for a binary target (metrics like precision/
14
+ recall treat 1 as the "positive" class), pass class_order yourself:
15
+
16
+ SDTargetEncoder(class_order=["low", "medium", "high"])
17
+ SDTargetEncoder(class_order=["denied", "approved"]) # approved -> 1
18
+ """
19
+
20
+ def __init__(self, class_order=None):
21
+ self.class_order = class_order
22
+ self.mapping_ = None
23
+ self.classes_ = None
24
+ self.fitted = False
25
+
26
+ def fit(self, y):
27
+ if isinstance(y, pd.DataFrame):
28
+ raise TypeError(
29
+ "SDTargetEncoder wants a single column, not a DataFrame. "
30
+ "for feature columns use SDEncoder instead."
31
+ )
32
+
33
+ y = pd.Series(y)
34
+ actual_classes = set(y.dropna().unique())
35
+
36
+ if self.class_order is not None:
37
+ given_classes = set(self.class_order)
38
+ if given_classes != actual_classes:
39
+ raise ValueError(
40
+ f"class_order {self.class_order} doesn't match the classes "
41
+ f"actually found in y: {sorted(actual_classes)}"
42
+ )
43
+ self.classes_ = list(self.class_order)
44
+ else:
45
+ self.classes_ = sorted(actual_classes)
46
+
47
+ self.mapping_ = {c: i for i, c in enumerate(self.classes_)}
48
+ self.fitted = True
49
+ return self
50
+
51
+ def transform(self, y):
52
+ if not self.fitted:
53
+ raise RuntimeError("call fit() before transform()")
54
+ return pd.Series(y).map(self.mapping_)
55
+
56
+ def fit_transform(self, y):
57
+ return self.fit(y).transform(y)
58
+
59
+ def inverse_transform(self, y_encoded):
60
+ if not self.fitted:
61
+ raise RuntimeError("call fit() before inverse_transform()")
62
+ reverse = {v: k for k, v in self.mapping_.items()}
63
+ return pd.Series(y_encoded).map(reverse)
64
+
65
+ def __repr__(self):
66
+ return f"SDTargetEncoder(classes={self.classes_})"