-
Notifications
You must be signed in to change notification settings - Fork 348
/
Copy pathmonotonic_splines.py
276 lines (203 loc) · 8.47 KB
/
monotonic_splines.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "altair==5.4.1",
# "drawdata==0.3.4",
# "marimo",
# "matplotlib==3.9.2",
# "numpy==2.1.3",
# "pandas==2.2.3",
# "scikit-learn==1.5.2",
# ]
# ///
import marimo
__generated_with = "0.9.14"
app = marimo.App(width="medium")
@app.cell
def __():
import marimo as mo
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import sklearn
from sklearn.pipeline import make_pipeline
from sklearn.utils import check_array
np.random.seed(0)
matplotlib.style.use("ggplot")
plt.rcParams["figure.figsize"] = [10, 4]
return check_array, make_pipeline, matplotlib, mo, np, plt, sklearn
@app.cell(hide_code=True)
def __(mo):
mo.md(
r"""
## Introduction to monotonic splines
> It turns out that you can generate features that can help turn (linear) machine learning models into models that respent monotonicity. While this technique isn't going to be useful for every application out there, it is a nice exercise in feature engineering because it does show off some lesser known and unconventional techniques.
>
> This document reflects the code discussed in [this probabl livestream](https://www.youtube.com/watch?v=BLsWIJSKcGg) which in turn was heavily insired by [this blogpost](https://matekadlicsko.github.io/posts/monotonic-splines/).
We are going to dive into feature engineering in this document, but before going there it would help to have a dataset first. So let's draw one! **Draw some points below**, but make sure that you only draw a single class of points here. We're going for a regression dataset here where the x-values need to predict the y-values.
"""
)
return
@app.cell
def __(mo):
from drawdata import ScatterWidget
widget = mo.ui.anywidget(ScatterWidget())
widget
return ScatterWidget, widget
@app.cell
def __(mo, widget):
mo.stop(
not widget.value["data"],
mo.md("Draw a dataset above to proceed!").callout(),
)
df = widget.data_as_pandas.sort_values("x")
X, y = df[["x"]].values, df["y"].values
return X, df, y
@app.cell(hide_code=True)
def __(mo):
mo.md(
"""
## General splines
You have probably drawn something that is very much non-linear. So you might expect a linear model to perform quite badly here. However, thanks to non-linear feature-engineering, we might still be able to get a nice fit. After all, getting the right features is 90% of the work towards a good model.
So let's build a pipeline that uses the [SplineTransformer](https://scikit-learn.org/1.5/modules/generated/sklearn.preprocessing.SplineTransformer.html) from scikit-learn. This featurizer can generate "hills" on our behalf that span the input space of the x-axis.
"""
)
return
@app.cell(hide_code=True)
def __(X, np, plt, tfm):
X_tfm = tfm.fit_transform(X)
x_range = np.linspace(-50, 900, 2000).reshape(-1, 1)
x_range_tfm = tfm.transform(x_range)
plt.plot(x_range, x_range_tfm)
return X_tfm, x_range, x_range_tfm
@app.cell(hide_code=True)
def __(mo):
mo.md(
"""
You can see the x-values that our drawing widget can provide and you can also see all the generated features. Each feature is represented with a different colored line and you should also see how each hill goes up and typically goes back down again. At the edges of the samples that we have we see straight lines in an attempt to also have some features for extrapolation.
There are some inputs for this `SplineTransformer` though. We can ask the transformer to add more hills, each hill also has a polynomial degree attached to it that we may alter and we can also tell the component to have the placement of each hill be determined by the quantiles in the dataset.
Feel free to change the drawing and the parameters at this point to try and get a feeling for this.
"""
)
return
@app.cell(hide_code=True)
def __(mo):
n_knots = mo.ui.slider(
2, 20, step=1, show_value=True, label="number of knots", value=5
)
knots = mo.ui.dropdown(["uniform", "quantile"], value="uniform")
degree = mo.ui.slider(1, 4, step=1, show_value=True, label="degree", value=2)
mo.vstack([n_knots, degree, knots])
return degree, knots, n_knots
@app.cell
def __(degree, knots, n_knots):
from sklearn.preprocessing import SplineTransformer
tfm = SplineTransformer(
n_knots=n_knots.value, knots=knots.value, degree=degree.value
)
tfm
return SplineTransformer, tfm
@app.cell(hide_code=True)
def __(mo):
mo.md(r"""When you then take these generated features and pass them to a linear model, you should be able to see that we're indeed able to fit a very non-linear curve with a linear model.""")
return
@app.cell(hide_code=True)
def __(mo):
mo.md(r"""... turn into these features:""")
return
@app.cell
def __(X_tfm, df, y):
import altair as alt
from sklearn.linear_model import Ridge
preds = Ridge().fit(X_tfm, y).predict(X_tfm)
pltr = df.assign(preds=preds)
p1 = alt.Chart(pltr).mark_point().encode(x="x", y="y")
p2 = alt.Chart(pltr).mark_line(color="red").encode(x="x", y="preds")
(p1 + p2).properties(width=1000)
return Ridge, alt, p1, p2, pltr, preds
@app.cell(hide_code=True)
def __(mo):
mo.md(
r"""
## Towards monotonic features
But let's now do a trick. We will take the features that we generated and then we will cumsum over each single feature.
That means that these features ...
"""
)
return
@app.cell
def __(plt, x_range, x_range_tfm):
plt.plot(x_range, x_range_tfm)
return
@app.cell
def __(plt, x_range, x_range_tfm):
plt.plot(x_range, x_range_tfm.cumsum(axis=0))
return
@app.cell(hide_code=True)
def __(mo):
mo.md(r"""Note the correspondence between the lines here. The color in the chart above has a direct correspondence with the line below.""")
return
@app.cell(hide_code=True)
def __(mo):
mo.md(
r"""
You could wonder ... what would happen if I use these 'cumsum' features? Would I still be able to get a nice fit?
The chart below shows you the new predictions.
"""
)
return
@app.cell
def __(mo):
strictly_positive = mo.ui.checkbox(label="Strictly positive")
show_iso = mo.ui.checkbox(label="Show Isotonic Regression")
mo.vstack([strictly_positive, show_iso])
return show_iso, strictly_positive
@app.cell
def __():
import pandas as pd
return (pd,)
@app.cell
def __(Ridge, X, X_tfm, alt, pd, pltr, show_iso, strictly_positive, y):
from sklearn.isotonic import IsotonicRegression
preds_mono = (
Ridge(positive=strictly_positive.value)
.fit(X_tfm.cumsum(axis=0), y)
.predict(X_tfm.cumsum(axis=0))
)
final_df = pd.DataFrame({"preds": preds_mono, "x": X[:, 0]})
p1_mono = alt.Chart(pltr).mark_point().encode(x="x", y="y")
p2_mono = alt.Chart(final_df).mark_line(color="red").encode(x="x", y="preds")
together = p1_mono + p2_mono
if show_iso.value:
iso = IsotonicRegression().fit(X, y)
df_iso = pd.DataFrame({"preds_iso": iso.predict(X), "x": X[:, 0]})
together += (
alt.Chart(df_iso)
.mark_line(color="purple")
.encode(x="x", y="preds_iso")
)
together.properties(width=1000).interactive()
return (
IsotonicRegression,
df_iso,
final_df,
iso,
p1_mono,
p2_mono,
preds_mono,
together,
)
@app.cell(hide_code=True)
def __(mo):
mo.md(
r"""
You can choose to compare the results with a prediction made by an [IsotonicRegression](https://scikit-learn.org/1.5/modules/isotonic.html) model. It may help to appreciate the feature generation technique, especially when you force the linear model to only learn strictly positive weights.
There are a few things to notice here:
1. Take the chart with a grain of salt. It does demonstrate the idea, but it does not represent a proper benchmark and we are showing everything being fit on a train set here.
2. Notice how the feature approach has a slightly more smooth prediction over here compared to the isotonic regressor.
3. Note that this technique is very general. It can be used on whatever estimator that enables you to learn strictly positive weights.
"""
)
return
if __name__ == "__main__":
app.run()