anchorboosting 0.0.1__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.
- anchorboosting/__init__.py +4 -0
- anchorboosting/models.py +368 -0
- anchorboosting/objectives/__init__.py +11 -0
- anchorboosting/objectives/anchor_objectives.py +123 -0
- anchorboosting/objectives/mixins.py +237 -0
- anchorboosting/simulate.py +25 -0
- anchorboosting/utils.py +84 -0
- anchorboosting-0.0.1.dist-info/METADATA +224 -0
- anchorboosting-0.0.1.dist-info/RECORD +12 -0
- anchorboosting-0.0.1.dist-info/WHEEL +5 -0
- anchorboosting-0.0.1.dist-info/licenses/LICENSE +13 -0
- anchorboosting-0.0.1.dist-info/top_level.txt +1 -0
anchorboosting/models.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
|
|
3
|
+
import lightgbm as lgb
|
|
4
|
+
import numpy as np
|
|
5
|
+
import scipy
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
_POLARS_INSTALLED = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
_POLARS_INSTALLED = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AnchorBooster:
|
|
16
|
+
"""
|
|
17
|
+
Boost the anchor loss.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
gamma: float
|
|
22
|
+
The gamma parameter for the anchor regression objective function. Must be non-
|
|
23
|
+
negative. If 1, the objective is equivalent to a standard regression objective.
|
|
24
|
+
dataset_params: dict or None
|
|
25
|
+
The parameters for the LightGBM dataset. See LightGBM documentation for details.
|
|
26
|
+
num_boost_round: int
|
|
27
|
+
The number of boosting iterations. Default is 100.
|
|
28
|
+
objective: str, optional, default="regression"
|
|
29
|
+
The objective function to use. Can be "regression" or "binary" for probit
|
|
30
|
+
regression. If "binary", the outcome values must be 0 or 1.
|
|
31
|
+
**kwargs: dict
|
|
32
|
+
Additional parameters for the LightGBM model. See LightGBM documentation for
|
|
33
|
+
details.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
gamma,
|
|
39
|
+
dataset_params=None,
|
|
40
|
+
num_boost_round=100,
|
|
41
|
+
objective="regression",
|
|
42
|
+
**kwargs,
|
|
43
|
+
):
|
|
44
|
+
self.gamma = gamma
|
|
45
|
+
self.params = kwargs
|
|
46
|
+
self.dataset_params = dataset_params or {}
|
|
47
|
+
self.num_boost_round = num_boost_round
|
|
48
|
+
self.booster = None
|
|
49
|
+
self.init_score_ = None
|
|
50
|
+
self.objective = objective
|
|
51
|
+
|
|
52
|
+
def fit(
|
|
53
|
+
self,
|
|
54
|
+
X,
|
|
55
|
+
y,
|
|
56
|
+
Z,
|
|
57
|
+
categorical_feature=None,
|
|
58
|
+
):
|
|
59
|
+
"""
|
|
60
|
+
Fit the model.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
X : polars.DataFrame
|
|
65
|
+
The input data.
|
|
66
|
+
y : np.ndarray
|
|
67
|
+
The outcome.
|
|
68
|
+
Z : np.ndarray
|
|
69
|
+
Anchors.
|
|
70
|
+
categorical_feature : list of str or int
|
|
71
|
+
List of categorical feature names or indices. If None, all features are
|
|
72
|
+
assumed to be numerical.
|
|
73
|
+
"""
|
|
74
|
+
if self.objective != "regression" and not np.isin(y, [0, 1]).all():
|
|
75
|
+
raise ValueError("For binary classification, y values must be in {0, 1}.")
|
|
76
|
+
|
|
77
|
+
if self.objective == "regression":
|
|
78
|
+
self.init_score_ = np.mean(y)
|
|
79
|
+
elif self.objective == "binary":
|
|
80
|
+
self.init_score_ = scipy.stats.norm.ppf(np.mean(y))
|
|
81
|
+
else:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Objective must be 'regression' or 'binary'. Got {self.objective}."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if _POLARS_INSTALLED and isinstance(X, pl.DataFrame):
|
|
87
|
+
feature_name = X.columns
|
|
88
|
+
X = X.to_arrow()
|
|
89
|
+
else:
|
|
90
|
+
feature_name = None
|
|
91
|
+
|
|
92
|
+
self._dtype = np.result_type(Z, y)
|
|
93
|
+
|
|
94
|
+
dataset_params = {
|
|
95
|
+
"data": X,
|
|
96
|
+
"label": y,
|
|
97
|
+
"categorical_feature": categorical_feature,
|
|
98
|
+
"feature_name": feature_name,
|
|
99
|
+
"init_score": np.ones(len(y), dtype=self._dtype) * self.init_score_,
|
|
100
|
+
**self.dataset_params,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
data = lgb.Dataset(**dataset_params)
|
|
104
|
+
|
|
105
|
+
self.booster = lgb.Booster(params=self.params, train_set=data)
|
|
106
|
+
|
|
107
|
+
return self.update(
|
|
108
|
+
X,
|
|
109
|
+
y,
|
|
110
|
+
Z=Z,
|
|
111
|
+
num_iteration=self.num_boost_round,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def update(self, X, y, Z, num_iteration=1):
|
|
115
|
+
if self.booster is None or self.init_score_ is None:
|
|
116
|
+
raise ValueError("AnchorBoost has not yet been fitted.")
|
|
117
|
+
|
|
118
|
+
y = y.flatten()
|
|
119
|
+
|
|
120
|
+
current_iteration = self.booster.current_iteration()
|
|
121
|
+
if current_iteration == 0:
|
|
122
|
+
f = np.ones(len(y), dtype=self._dtype) * self.init_score_
|
|
123
|
+
else:
|
|
124
|
+
f = self.booster.predict(X, raw_score=True) + self.init_score_
|
|
125
|
+
|
|
126
|
+
Q, _ = np.linalg.qr(Z, mode="reduced") # P_Z f = Q @ (Q^T @ f)
|
|
127
|
+
|
|
128
|
+
for idx in range(current_iteration, current_iteration + num_iteration):
|
|
129
|
+
# For regression, the loss (without anchor) is
|
|
130
|
+
# loss(f, y) = 0.5 * || y - f ||^2
|
|
131
|
+
if self.objective == "regression":
|
|
132
|
+
r = f - y # d/df loss(f, y)
|
|
133
|
+
dr = np.ones(len(y), dtype=self._dtype) # d^2/df^2 loss(f, y)
|
|
134
|
+
ddr = np.zeros(len(y), dtype=self._dtype) # d^3/df^3 loss(f, y)
|
|
135
|
+
# For probit regression, the loss (without anchor) is
|
|
136
|
+
# loss(f, y) = - sum_i (y_i log(p_i) + (1 - y_i) log(1 - p_i))
|
|
137
|
+
# where p_i = scipy.stats.cdf(f_i)
|
|
138
|
+
else:
|
|
139
|
+
# We wish to compute the following:
|
|
140
|
+
# p = scipy.stats.norm.cdf(f)
|
|
141
|
+
# dp = scipy.stats.norm.pdf(f) # d/df p(f)
|
|
142
|
+
# r = np.where(y == 1, -dp / p, dp / (1 - p)) # d/df loss(f, y)
|
|
143
|
+
# The equation for r is numerically unstable. Instead, we use
|
|
144
|
+
# scipy.special.log_ndtr, with log_ndtr(f) = log(norm.cdf(f)).
|
|
145
|
+
y_tilde = np.where(y == 1, 1, -1)
|
|
146
|
+
log_phi = -0.5 * f**2 - 0.5 * np.log(2 * np.pi) # log(norm.pdf(f))
|
|
147
|
+
r = -y_tilde * np.exp(log_phi - scipy.special.log_ndtr(y_tilde * f))
|
|
148
|
+
dr = -f * r + r**2 # d^2/df^2 loss(f, y)
|
|
149
|
+
ddr = (f**2 - 1) * r - 3 * f * r**2 + 2 * r**3 # d^3/df^3 loss
|
|
150
|
+
|
|
151
|
+
r_proj = Q @ (Q.T @ r)
|
|
152
|
+
grad = r + (self.gamma - 1) * r_proj * dr
|
|
153
|
+
|
|
154
|
+
# We wish to fit one additional tree. Intuitively, one would use
|
|
155
|
+
# is_finished = self.booster.update(fobj=self.objective.objective)
|
|
156
|
+
# for this. This makes a call to self.__inner_predict(0) to get the current
|
|
157
|
+
# predictions for all existing trees. See:
|
|
158
|
+
# https://github.com/microsoft/LightGBM/blob/18c11f861118aa889b9d4579c2888d\
|
|
159
|
+
# 5c908fd250/python-package/lightgbm/basic.py#L4165
|
|
160
|
+
# To avoid passing data through all trees each time, this uses a cache.
|
|
161
|
+
# However, this cache is based on the "original" tree values, not the one
|
|
162
|
+
# we set below. We thus use "our own" predictions and skip __inner_predict.
|
|
163
|
+
# No idea what the set_objective_to_none does, but lgbm raises if we don't.
|
|
164
|
+
self.booster._Booster__inner_predict_buffer = None
|
|
165
|
+
if not self.booster._Booster__set_objective_to_none:
|
|
166
|
+
self.booster.reset_parameter(
|
|
167
|
+
{"objective": "none"}
|
|
168
|
+
)._Booster__set_objective_to_none = True
|
|
169
|
+
|
|
170
|
+
# is_finished is True if there we no splits satisfying the splitting
|
|
171
|
+
# criteria. c.f. https://github.com/microsoft/LightGBM/pull/6890
|
|
172
|
+
# The hessian is used only for the `min_hessian_in_leaf` parameter to
|
|
173
|
+
# avoid numerical instabilities.
|
|
174
|
+
is_finished = self.booster._Booster__boost(grad, dr)
|
|
175
|
+
|
|
176
|
+
if is_finished:
|
|
177
|
+
print(f"Finished training after {idx} iterations.")
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
leaves = self.booster.predict(
|
|
181
|
+
X, start_iteration=idx, num_iteration=1, pred_leaf=True
|
|
182
|
+
).flatten()
|
|
183
|
+
num_leaves = np.max(leaves) + 1
|
|
184
|
+
|
|
185
|
+
# We wish to do 2nd order updates in the leaves. Since the anchor regression
|
|
186
|
+
# objective is quadratic, for regression a 2nd order update is equal to the
|
|
187
|
+
# global minimizer.
|
|
188
|
+
# Let M be the one-hot encoding of the tree's leaf assignments. That is,
|
|
189
|
+
# M[i, j] = 1 if leaves[i] == j else 0.
|
|
190
|
+
# We have
|
|
191
|
+
# r = d/df loss(f, y)
|
|
192
|
+
# dr = d^2/df^2 loss(f, y)
|
|
193
|
+
# ddr = d^3/df^3 loss(f, y)
|
|
194
|
+
# The anchor loss is
|
|
195
|
+
# L = loss(f, y) + (gamma - 1) / 2 * || P_Z r ||^2
|
|
196
|
+
# d/df L = d/df loss(f, y) + (gamma - 1) P_Z r * dr
|
|
197
|
+
# d^2/df^2 L = diag(d^2/df^2 loss(f, y)) + (gamma - 1) diag(P_Z r * ddr)
|
|
198
|
+
# + (gamma - 1) diag(dr) P_Z diag(dr)
|
|
199
|
+
#
|
|
200
|
+
# We do the 2nd order update
|
|
201
|
+
# beta = - (M^T [d^2/df^2 L] M)^{-1} M^T [d/df L]
|
|
202
|
+
|
|
203
|
+
# M^T x = bincount(leaves_masked, weights=x)
|
|
204
|
+
g = np.bincount(leaves, weights=grad, minlength=num_leaves)
|
|
205
|
+
|
|
206
|
+
# M^T diag(x) M = diag(np.bincount(leaves, weights=x))
|
|
207
|
+
counts = np.bincount(
|
|
208
|
+
leaves,
|
|
209
|
+
weights=dr + (self.gamma - 1) * r_proj * ddr,
|
|
210
|
+
minlength=num_leaves,
|
|
211
|
+
)
|
|
212
|
+
counts += self.params.get("lambda_l2", 0)
|
|
213
|
+
|
|
214
|
+
# Mdr^T P_Z Mdr = (Mdr^T Q) @ (Mdr^T Q)^T
|
|
215
|
+
# One could also compute this using bincount, but it appears this
|
|
216
|
+
# version using a sparse matrix is faster.
|
|
217
|
+
Mdr = scipy.sparse.csr_matrix(
|
|
218
|
+
(
|
|
219
|
+
dr,
|
|
220
|
+
(np.arange(len(leaves)), leaves),
|
|
221
|
+
),
|
|
222
|
+
shape=(len(leaves), num_leaves),
|
|
223
|
+
dtype=self._dtype,
|
|
224
|
+
)
|
|
225
|
+
B = Mdr.T.dot(Q)
|
|
226
|
+
H = (self.gamma - 1) * B @ B.T
|
|
227
|
+
H += np.diag(counts)
|
|
228
|
+
|
|
229
|
+
# Compute the 2nd order update
|
|
230
|
+
leaf_values = -np.linalg.solve(H, g) * self.params.get("learning_rate", 0.1)
|
|
231
|
+
|
|
232
|
+
for ldx, val in enumerate(leaf_values):
|
|
233
|
+
self.booster.set_leaf_output(idx, ldx, val)
|
|
234
|
+
|
|
235
|
+
# Ensure f == self.init_score_ + self.booster.predict(X)
|
|
236
|
+
f += leaf_values[leaves]
|
|
237
|
+
|
|
238
|
+
return self
|
|
239
|
+
|
|
240
|
+
def predict(self, X, raw_score=False, **kwargs):
|
|
241
|
+
"""
|
|
242
|
+
Predict the outcome.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
X : numpy.ndarray, polars.DataFrame, or pyarrow.Table
|
|
247
|
+
The input data.
|
|
248
|
+
num_iteration : int
|
|
249
|
+
Number of boosting iterations to use. If -1, all are used. Else, needs to be
|
|
250
|
+
in [0, num_boost_round].
|
|
251
|
+
raw_score : bool
|
|
252
|
+
If True, returns scores. If False, returns predicted probabilities.
|
|
253
|
+
"""
|
|
254
|
+
if self.booster is None:
|
|
255
|
+
raise ValueError("AnchorBoost has not yet been fitted.")
|
|
256
|
+
|
|
257
|
+
if _POLARS_INSTALLED and isinstance(X, pl.DataFrame):
|
|
258
|
+
X = X.to_arrow()
|
|
259
|
+
|
|
260
|
+
scores = self.booster.predict(X, raw_score=True, **kwargs)
|
|
261
|
+
|
|
262
|
+
if self.objective == "binary" and not raw_score:
|
|
263
|
+
return scipy.stats.norm.cdf(scores + self.init_score_)
|
|
264
|
+
else:
|
|
265
|
+
return scores + self.init_score_
|
|
266
|
+
|
|
267
|
+
def refit(self, X, y, decay_rate=0):
|
|
268
|
+
"""
|
|
269
|
+
Refit the model using new data.
|
|
270
|
+
|
|
271
|
+
Set :math:`f^0_\\mathrm{refit} =` ``init_score_``.
|
|
272
|
+
Starting from :math:`f^j_\\mathrm{refit}`, we drop the new data :math:`(X, y)`
|
|
273
|
+
down the tree :math:`\\hat t^{j+1}`.
|
|
274
|
+
Let :math:`\\hat \\beta_\\mathrm{new}^{j+1}` be the second order optimization
|
|
275
|
+
of the loss :math:`\\ell(\\hat f^j_\\mathrm{refit} + \\hat t^{j+1}(X), y)`
|
|
276
|
+
with respect to the leaf node values :math:`\\beta^{j+1}``of
|
|
277
|
+
:math:`\\hat t^{j+1}(X)`.
|
|
278
|
+
We set
|
|
279
|
+
:math:`\\hat \\beta^{j+1}_\\mathrm{refit} = \\mathrm{decay rate} \\hat \\beta^{j+1}_\\mathrm{old} + (1 - \\mathrm{decay rate}) \\hat \\beta^{j+1}_\\mathrm{new}`.
|
|
280
|
+
Refitting updates the tree's leaf values, but not their structure.
|
|
281
|
+
``AnchorBooster.refit`` differs from ``lgbm.Booster.refit`` in that it supports
|
|
282
|
+
probit regression and leaf nodes with no samples from the new data are not,
|
|
283
|
+
updated, instead of being shrunk towards zero (as in LightGBM).
|
|
284
|
+
|
|
285
|
+
Refit is not in-place, but returns a new instance of ``AnchorBooster``.
|
|
286
|
+
|
|
287
|
+
Parameters
|
|
288
|
+
----------
|
|
289
|
+
X : numpy.ndarray, polars.DataFrame, or pyarrow.Table
|
|
290
|
+
The new data.
|
|
291
|
+
y : np.ndarray
|
|
292
|
+
The new outcomes.
|
|
293
|
+
decay_rate : float
|
|
294
|
+
The decay rate for the leaf values. Must be in [0, 1]. Default is 0. If 0,
|
|
295
|
+
the leaf values are set to the new values. If 1, the leaf values are not
|
|
296
|
+
updated.
|
|
297
|
+
|
|
298
|
+
Returns
|
|
299
|
+
-------
|
|
300
|
+
AnchorBooster
|
|
301
|
+
A new instance of AnchorBooster with the updated leaf values.
|
|
302
|
+
""" # noqa: E501
|
|
303
|
+
self_copied = copy.deepcopy(self)
|
|
304
|
+
|
|
305
|
+
# For some reason, the model params are not copied over.
|
|
306
|
+
# https://github.com/microsoft/LightGBM/issues/6821
|
|
307
|
+
self_copied.booster.params = self.booster.params
|
|
308
|
+
|
|
309
|
+
if self.objective == "binary" and not np.isin(y, [0, 1]).all():
|
|
310
|
+
raise ValueError("For binary classification, y values must be in {0, 1}.")
|
|
311
|
+
|
|
312
|
+
if self.objective == "binary":
|
|
313
|
+
y_tilde = np.where(y == 1, 1, -1)
|
|
314
|
+
|
|
315
|
+
leaves = self_copied.booster.predict(X, pred_leaf=True)
|
|
316
|
+
f = np.full(len(y), self.init_score_, dtype="float64")
|
|
317
|
+
|
|
318
|
+
for idx in range(self.num_boost_round):
|
|
319
|
+
if self.objective == "regression":
|
|
320
|
+
grad_hess_ones = np.empty((len(y), 3), dtype="float64")
|
|
321
|
+
grad_hess_ones[:, 0] = f - y
|
|
322
|
+
grad_hess_ones[:, 1:] = 1.0
|
|
323
|
+
# g = f - y
|
|
324
|
+
# h = 1.0
|
|
325
|
+
elif self.objective == "binary":
|
|
326
|
+
log_phi = -0.5 * f**2 - 0.5 * np.log(2 * np.pi) # log(norm.pdf(f))
|
|
327
|
+
grad_hess_ones = np.empty((len(y), 3), dtype="float64")
|
|
328
|
+
grad_hess_ones[:, 0] = -y_tilde * np.exp(
|
|
329
|
+
log_phi - scipy.special.log_ndtr(y_tilde * f)
|
|
330
|
+
)
|
|
331
|
+
grad_hess_ones[:, 1] = (
|
|
332
|
+
-f * grad_hess_ones[:, 0] + grad_hess_ones[:, 0] ** 2
|
|
333
|
+
)
|
|
334
|
+
grad_hess_ones[:, 2] = 1.0
|
|
335
|
+
# g = -y_tilde * np.exp(log_phi - scipy.special.log_ndtr(y_tilde * f))
|
|
336
|
+
# h = -f * g + g**2
|
|
337
|
+
else:
|
|
338
|
+
raise ValueError("Objective must be 'regression' or 'binary'.")
|
|
339
|
+
|
|
340
|
+
num_leaves = np.max(leaves[:, idx]) + 1
|
|
341
|
+
|
|
342
|
+
# We aggregate gradients, hessians, and counts over the leaves.
|
|
343
|
+
# A bincount is faster than a for loop. Still this passes 3x through the
|
|
344
|
+
# data. We use np.add.at to do this in a single pass and avoid a copy.
|
|
345
|
+
# n_obs = np.bincount(leaves[:, idx], minlength=num_leaves)
|
|
346
|
+
# sum_grad = np.bincount(leaves[:, idx], weights=g, minlength=num_leaves)
|
|
347
|
+
# sum_hess = np.bincount(leaves[:, idx], weights=h, minlength=num_leaves)
|
|
348
|
+
arr = np.zeros((num_leaves, 3), dtype=np.float64)
|
|
349
|
+
np.add.at(arr, leaves[:, idx], grad_hess_ones)
|
|
350
|
+
|
|
351
|
+
sum_grad = arr[:, 0]
|
|
352
|
+
sum_hess = arr[:, 1]
|
|
353
|
+
n_obs = arr[:, 2]
|
|
354
|
+
|
|
355
|
+
values = -sum_grad / sum_hess * self_copied.params.get("learning_rate", 0.1)
|
|
356
|
+
|
|
357
|
+
new_values = np.zeros(num_leaves, dtype="float64")
|
|
358
|
+
|
|
359
|
+
for ldx in np.where(n_obs > 0)[0]:
|
|
360
|
+
old_value = self_copied.booster.get_leaf_output(idx, ldx)
|
|
361
|
+
new_values[ldx] = (
|
|
362
|
+
decay_rate * old_value + (1 - decay_rate) * values[ldx]
|
|
363
|
+
)
|
|
364
|
+
self_copied.booster.set_leaf_output(idx, ldx, new_values[ldx])
|
|
365
|
+
|
|
366
|
+
f += new_values[leaves[:, idx]]
|
|
367
|
+
|
|
368
|
+
return self_copied
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .anchor_objectives import (
|
|
2
|
+
AnchorKookClassificationObjective,
|
|
3
|
+
AnchorKookMultiClassificationObjective,
|
|
4
|
+
AnchorRegressionObjective,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AnchorKookClassificationObjective",
|
|
9
|
+
"AnchorKookMultiClassificationObjective",
|
|
10
|
+
"AnchorRegressionObjective",
|
|
11
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from anchorboosting.objectives.mixins import (
|
|
4
|
+
ClassificationMixin,
|
|
5
|
+
LGBMMixin,
|
|
6
|
+
MultiClassificationMixin,
|
|
7
|
+
ProjMixin,
|
|
8
|
+
RegressionMixin,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AnchorKookClassificationObjective(ClassificationMixin, LGBMMixin, ProjMixin):
|
|
13
|
+
def __init__(self, gamma, precompute_proj=True, n_categories=None):
|
|
14
|
+
super().__init__(precompute_proj=precompute_proj, n_categories=n_categories)
|
|
15
|
+
self.gamma = gamma
|
|
16
|
+
self.name = "kook anchor classification"
|
|
17
|
+
|
|
18
|
+
def __repr__(self):
|
|
19
|
+
return f"AnchorKookClassificationObjective(gamma={self.gamma})"
|
|
20
|
+
|
|
21
|
+
def residuals(self, f, data):
|
|
22
|
+
residuals = self.predictions(f) - data.get_label()
|
|
23
|
+
return residuals
|
|
24
|
+
|
|
25
|
+
def loss(self, f, data):
|
|
26
|
+
return (
|
|
27
|
+
super().loss(f, data)
|
|
28
|
+
+ (self.gamma - 1)
|
|
29
|
+
* self.proj(
|
|
30
|
+
data.anchor,
|
|
31
|
+
self.residuals(f, data),
|
|
32
|
+
copy=False,
|
|
33
|
+
)
|
|
34
|
+
** 2
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def grad(self, f, data):
|
|
38
|
+
predictions = self.predictions(f)
|
|
39
|
+
proj_residuals = self.proj(data.anchor, self.residuals(f, data), copy=False)
|
|
40
|
+
|
|
41
|
+
return super().grad(f, data) + 2 * (
|
|
42
|
+
self.gamma - 1
|
|
43
|
+
) * proj_residuals * predictions * (1 - predictions)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AnchorKookMultiClassificationObjective(
|
|
47
|
+
MultiClassificationMixin, LGBMMixin, ProjMixin
|
|
48
|
+
):
|
|
49
|
+
def __init__(self, gamma, n_classes, precompute_proj=True, n_categories=None):
|
|
50
|
+
self.gamma = gamma
|
|
51
|
+
super().__init__(
|
|
52
|
+
n_classes=n_classes,
|
|
53
|
+
precompute_proj=precompute_proj,
|
|
54
|
+
n_categories=n_categories,
|
|
55
|
+
)
|
|
56
|
+
self.name = "kook anchor multi-classification"
|
|
57
|
+
|
|
58
|
+
def __repr__(self):
|
|
59
|
+
return f"AnchorKookMultiClassificationObjective(gamma={self.gamma})"
|
|
60
|
+
|
|
61
|
+
def residuals(self, f, data):
|
|
62
|
+
predictions = self.predictions(f)
|
|
63
|
+
predictions[self._indices(data.get_label())] -= 1
|
|
64
|
+
return predictions.flatten("F")
|
|
65
|
+
|
|
66
|
+
def loss(self, f, data):
|
|
67
|
+
residuals = self.residuals(f, data).reshape((-1, self.n_classes), order="F")
|
|
68
|
+
proj_residuals = self.proj(data.anchor, residuals, copy=False)
|
|
69
|
+
# Multiply with self.factor to align two-class classification with
|
|
70
|
+
# AnchorKookClassificationObjective
|
|
71
|
+
return super().loss(f, data) + self.factor * (self.gamma - 1) * np.sum(
|
|
72
|
+
proj_residuals**2, axis=1
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def grad(self, f, data):
|
|
76
|
+
residuals = self.residuals(f, data).reshape((-1, self.n_classes), order="F")
|
|
77
|
+
proj_residuals = self.proj(data.anchor, residuals, copy=False)
|
|
78
|
+
|
|
79
|
+
predictions = self.predictions(f)
|
|
80
|
+
proj_residuals -= np.sum(proj_residuals * predictions, axis=1, keepdims=True)
|
|
81
|
+
# Multiply with factor to align two-class classification with
|
|
82
|
+
# AnchorKookClassificationObjective
|
|
83
|
+
anchor_grad = 2 * self.factor * (self.gamma - 1) * predictions * proj_residuals
|
|
84
|
+
return super().grad(f, data) + anchor_grad.flatten("F")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AnchorRegressionObjective(RegressionMixin, LGBMMixin, ProjMixin):
|
|
88
|
+
def __init__(self, gamma, n_categories=None, precompute_proj=True):
|
|
89
|
+
super().__init__(precompute_proj=precompute_proj, n_categories=n_categories)
|
|
90
|
+
self.gamma = gamma
|
|
91
|
+
self.name = "anchor regression"
|
|
92
|
+
|
|
93
|
+
def __repr__(self):
|
|
94
|
+
return f"AnchorRegressionObjective(gamma={self.gamma})"
|
|
95
|
+
|
|
96
|
+
def residuals(self, f, data):
|
|
97
|
+
return data.get_label() - f
|
|
98
|
+
|
|
99
|
+
def loss(self, f, data):
|
|
100
|
+
if self.gamma == 1:
|
|
101
|
+
return super().loss(f, data)
|
|
102
|
+
|
|
103
|
+
# For gamma <= 1, this is equivalent to kappa := (gamma - 1) / gamma and
|
|
104
|
+
# loss = (1 - kappa) | y - f |^2 + kappa | P_Z (y - f) |^2
|
|
105
|
+
return (
|
|
106
|
+
super().loss(f, data)
|
|
107
|
+
+ 0.5
|
|
108
|
+
* (self.gamma - 1)
|
|
109
|
+
* self.proj(
|
|
110
|
+
data.anchor,
|
|
111
|
+
self.residuals(f, data),
|
|
112
|
+
copy=False,
|
|
113
|
+
)
|
|
114
|
+
** 2
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def grad(self, f, data):
|
|
118
|
+
residuals = self.residuals(f, data)
|
|
119
|
+
if self.gamma == 1:
|
|
120
|
+
return -residuals
|
|
121
|
+
|
|
122
|
+
proj_residuals = self.proj(data.anchor, residuals, copy=True)
|
|
123
|
+
return -residuals - (self.gamma - 1) * proj_residuals
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from anchorboosting.utils import proj
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProjMixin:
|
|
7
|
+
def __init__(self, precompute_proj=False, n_categories=None):
|
|
8
|
+
self.n_categories = n_categories
|
|
9
|
+
self.precompute_proj = precompute_proj
|
|
10
|
+
self.pinvZ = None
|
|
11
|
+
|
|
12
|
+
def proj(self, Z, *args, copy=False):
|
|
13
|
+
if self.n_categories is not None:
|
|
14
|
+
return proj(Z, *args, n_categories=self.n_categories, copy=copy)
|
|
15
|
+
elif self.precompute_proj:
|
|
16
|
+
if self.pinvZ is None:
|
|
17
|
+
self.pinvZ = np.linalg.pinv(Z)
|
|
18
|
+
if len(args) == 1:
|
|
19
|
+
return np.dot(Z, self.pinvZ @ args[0])
|
|
20
|
+
else:
|
|
21
|
+
return (*(np.dot(Z, self.pinvZ @ f) for f in args),)
|
|
22
|
+
else:
|
|
23
|
+
return proj(Z, *args, n_categories=None, copy=copy)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LGBMMixin:
|
|
27
|
+
higher_is_better = False
|
|
28
|
+
|
|
29
|
+
def objective(self, f, data):
|
|
30
|
+
"""Objective function for LGBM."""
|
|
31
|
+
return self.grad(f, data), self.hess(f, data)
|
|
32
|
+
|
|
33
|
+
def score(self, f, data):
|
|
34
|
+
"""Score function for LGBM."""
|
|
35
|
+
return (
|
|
36
|
+
f"{self.name} ({self.gamma})",
|
|
37
|
+
self.loss(f, data).mean(),
|
|
38
|
+
self.higher_is_better,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RegressionMixin:
|
|
43
|
+
def init_score(self, y):
|
|
44
|
+
return np.tile(y.mean(), len(y))
|
|
45
|
+
|
|
46
|
+
def grad(self, f, data):
|
|
47
|
+
# Replicate LGBM behaviour
|
|
48
|
+
# https://github.com/microsoft/LightGBM/blob/e9fbd19d7cbaeaea1ca54a091b160868fc\
|
|
49
|
+
# 5c79ec/src/objective/regression_objective.hpp#L130-L131
|
|
50
|
+
return -(data.get_label() - f)
|
|
51
|
+
|
|
52
|
+
def hess(self, f, data):
|
|
53
|
+
# Replicate LGBM behaviour
|
|
54
|
+
# https://github.com/microsoft/LightGBM/blob/e9fbd19d7cbaeaea1ca54a091b160868fc\
|
|
55
|
+
# 5c79ec/src/objective/regression_objective.hpp#L130-L131
|
|
56
|
+
return np.ones(len(data.get_label()))
|
|
57
|
+
|
|
58
|
+
def loss(self, f, data):
|
|
59
|
+
return 0.5 * (data.get_label() - f) ** 2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ClassificationMixin:
|
|
63
|
+
def init_score(self, y):
|
|
64
|
+
"""Initial score for LGBM.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
y: np.ndarray of dimension (n,)
|
|
69
|
+
Vector with true labels in (0, 1).
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
np.ndarray of length n
|
|
74
|
+
Initial scores for LGBM.
|
|
75
|
+
"""
|
|
76
|
+
p = np.sum(y) / len(y)
|
|
77
|
+
return np.ones(len(y)) * np.log(p / (1 - p))
|
|
78
|
+
|
|
79
|
+
def loss(self, f, data):
|
|
80
|
+
"""Two-class negative log-likelihood loss.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
f: np.ndarray of dimension (n)
|
|
85
|
+
Vector with scores.
|
|
86
|
+
data: lgbm.Dataset
|
|
87
|
+
LGBM dataset with labels of dimension (n,) in (0, 1).
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
np.ndarray of dimension (n,)
|
|
92
|
+
Loss.
|
|
93
|
+
"""
|
|
94
|
+
return np.log(1 + np.exp((-2 * data.get_label() + 1) * f))
|
|
95
|
+
|
|
96
|
+
def predictions(self, f):
|
|
97
|
+
"""Compute probability predictions from scores via softmax.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
f: np.ndarray of dimension (n,)
|
|
102
|
+
Vector with scores.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
np.ndarray of dimension (n,)
|
|
107
|
+
Vector with probabilities.
|
|
108
|
+
"""
|
|
109
|
+
return 1 / (1 + np.exp(-f))
|
|
110
|
+
|
|
111
|
+
def grad(self, f, data):
|
|
112
|
+
"""
|
|
113
|
+
Gradient of the two-class log-likelihood loss.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
f: np.ndarray of dimension (n,)
|
|
118
|
+
Vector with scores.
|
|
119
|
+
data: lgbm.Dataset
|
|
120
|
+
LGBM dataset with labels of dimension (n,) in (0, 1).
|
|
121
|
+
"""
|
|
122
|
+
return self.predictions(f) - data.get_label()
|
|
123
|
+
|
|
124
|
+
def hess(self, f, data):
|
|
125
|
+
"""
|
|
126
|
+
Diagonal of the Hessian of the multi-class log-likelihood loss.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
f: np.ndarray of dimension (n,)
|
|
131
|
+
Vector with scores.
|
|
132
|
+
data: lgbm.Dataset
|
|
133
|
+
LGBM dataset with labels of dimension (n,) in (0, 1).
|
|
134
|
+
"""
|
|
135
|
+
predictions = self.predictions(f)
|
|
136
|
+
return predictions * (1.0 - predictions)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class MultiClassificationMixin:
|
|
140
|
+
def __init__(self, n_classes, **kwargs):
|
|
141
|
+
super().__init__(**kwargs)
|
|
142
|
+
self.n_classes = n_classes
|
|
143
|
+
self.factor = (n_classes - 1) / n_classes
|
|
144
|
+
|
|
145
|
+
def init_score(self, y):
|
|
146
|
+
"""Initial score for LGBM.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
y: np.ndarray of dimension (n,)
|
|
151
|
+
Vector with true labels in (0, ..., n_classes - 1).
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
np.ndarray of dimension (n * n_classes,)
|
|
156
|
+
Initial scores for LGBM. Note that this is flattened.
|
|
157
|
+
"""
|
|
158
|
+
unique_values, unique_counts = np.unique(y, return_counts=True)
|
|
159
|
+
assert len(unique_values) == self.n_classes
|
|
160
|
+
assert (sorted(unique_values) == unique_values).all()
|
|
161
|
+
|
|
162
|
+
odds = np.array(unique_counts) / np.sum(unique_counts)
|
|
163
|
+
return np.log(np.tile(odds, (len(y), 1)).flatten("F"))
|
|
164
|
+
|
|
165
|
+
def loss(self, f, data):
|
|
166
|
+
"""Multi-class negative log-likelihood loss.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
f: np.ndarray of dimension (n * n_classes,)
|
|
171
|
+
Vector with scores.
|
|
172
|
+
data: lgbm.Dataset
|
|
173
|
+
LGBM dataset with labels of dimension (n,) in (0, ..., n_classes - 1).
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
np.ndarray of dimension (n,).
|
|
178
|
+
Loss.
|
|
179
|
+
"""
|
|
180
|
+
y = data.get_label()
|
|
181
|
+
f = f.reshape((-1, self.n_classes), order="F") # (n, n_classes)
|
|
182
|
+
f = f - np.max(f, axis=1)[:, np.newaxis] # normalize f to avoid overflow
|
|
183
|
+
log_divisor = np.log(np.sum(np.exp(f), axis=1))
|
|
184
|
+
return -f[self._indices(y)] + log_divisor
|
|
185
|
+
|
|
186
|
+
def _indices(self, y):
|
|
187
|
+
return (np.arange(len(y)), y.astype(int))
|
|
188
|
+
|
|
189
|
+
def predictions(self, f):
|
|
190
|
+
"""Compute probability predictions from scores via softmax.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
f: np.ndarray of dimension (n * n_classes,).]
|
|
195
|
+
Vector with scores.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
np.ndarray of dimension (n, n_classes)
|
|
200
|
+
Vector with probabilities.
|
|
201
|
+
"""
|
|
202
|
+
f = f.reshape((-1, self.n_classes), order="F") # (n, n_classes)
|
|
203
|
+
f = f - np.max(f, axis=1)[:, np.newaxis] # normalize f to avoid overflow
|
|
204
|
+
predictions = np.exp(f)
|
|
205
|
+
predictions /= np.sum(predictions, axis=1)[:, np.newaxis]
|
|
206
|
+
return predictions
|
|
207
|
+
|
|
208
|
+
def grad(self, f, data):
|
|
209
|
+
"""
|
|
210
|
+
Gradient of the multi-class log-likelihood loss.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
f: np.ndarray of dimension (n * n_classes,)
|
|
215
|
+
Vector with scores.
|
|
216
|
+
data: lgbm.Dataset
|
|
217
|
+
LGBM dataset with labels of dimension (n,) in (0, ..., n_classes - 1).
|
|
218
|
+
"""
|
|
219
|
+
y = data.get_label()
|
|
220
|
+
predictions = self.predictions(f)
|
|
221
|
+
predictions[self._indices(y)] -= 1
|
|
222
|
+
|
|
223
|
+
return predictions.flatten("F")
|
|
224
|
+
|
|
225
|
+
def hess(self, f, data):
|
|
226
|
+
"""
|
|
227
|
+
Diagonal of the Hessian of the multi-class log-likelihood loss.
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
f: np.ndarray of dimension (n * n_classes,)
|
|
232
|
+
Vector with scores.
|
|
233
|
+
data: lgbm.Dataset
|
|
234
|
+
LGBM dataset with labels of dimension (n,) in (0, ..., n_classes - 1).
|
|
235
|
+
"""
|
|
236
|
+
predictions = self.predictions(f).flatten("F")
|
|
237
|
+
return 1 / self.factor * predictions * (1.0 - predictions)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def simulate(f, n=100, shift=0, seed=0):
|
|
5
|
+
rng = np.random.RandomState(seed)
|
|
6
|
+
|
|
7
|
+
p = 2
|
|
8
|
+
|
|
9
|
+
a = rng.normal(size=(n, 2)) + shift
|
|
10
|
+
h = rng.normal(size=(n, 1))
|
|
11
|
+
x_noise = 0.5 * rng.normal(size=(n, p))
|
|
12
|
+
x = x_noise + np.repeat(a[:, 0] + a[:, 1] + 2 * h[:, 0], p).reshape((n, p))
|
|
13
|
+
|
|
14
|
+
y_noise = 0.25 * rng.normal(size=n)
|
|
15
|
+
y = f(x[:, 0], x[:, 1]) - 2 * a[:, 0] + 3 * h[:, 0] + y_noise
|
|
16
|
+
|
|
17
|
+
return x, y, a
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def f1(x2, x3):
|
|
21
|
+
return (x2 <= 0) + (x2 <= -0.5) * (x3 <= 1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def f2(x2, x3):
|
|
25
|
+
return x2 + x3 + (x2 <= 0) + (x2 <= -0.5) * (x3 <= 1)
|
anchorboosting/utils.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# copy-pasted from https://github.com/mlondschien/ivmodels/blob/09f8bc5a78b3793ae5\
|
|
2
|
+
# 1f0bb7f0331b70a0971ebc/ivmodels/utils.py
|
|
3
|
+
import numpy as np
|
|
4
|
+
import scipy
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def proj(Z, f, n_categories=None, copy=False):
|
|
8
|
+
"""
|
|
9
|
+
Project f onto the subspace spanned by Z.
|
|
10
|
+
|
|
11
|
+
If `Z` is a matrix of floats, uses a copy of `ivmodel`'s `proj:
|
|
12
|
+
https://github.com/mlondschien/ivmodels/blob/main/ivmodels/utils.py#L12
|
|
13
|
+
If `Z` is a 1d array of unsigned integers, assumes that `Z` is categorical with
|
|
14
|
+
values 0, 1, ..., n_categories-1. In this case, the `n_categories` argument must be
|
|
15
|
+
be supplied. The projection is done by averaging the values of f within each
|
|
16
|
+
category. If `Z` is None, returns np.zeros_like(f).
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
Z: np.ndarray of dimension (n, d_Z) or (n,)
|
|
21
|
+
The `Z` matrix or 1d array of integers. If None, returns np.zeros_like(f).
|
|
22
|
+
*args: np.ndarrays of dimension (n, d_f) or (n,)
|
|
23
|
+
Vectors or matrices to project.
|
|
24
|
+
n_categories: int, optional, default=None
|
|
25
|
+
If not None, then Z is assumed to be categorical with categories 0, 1, ...,
|
|
26
|
+
n_categories-1. The projection is done by averaging the values of f within each
|
|
27
|
+
category.
|
|
28
|
+
copy: bool, default=False
|
|
29
|
+
If False, overwrites *args.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
np.ndarray of dimension (n, d_f) or (n,)
|
|
34
|
+
Projection of args onto the subspace spanned by Z. Same number of
|
|
35
|
+
outputs as args. Same dimension as args
|
|
36
|
+
"""
|
|
37
|
+
if Z is None:
|
|
38
|
+
return np.zeros_like(f)
|
|
39
|
+
|
|
40
|
+
if len(f.shape) > 2:
|
|
41
|
+
raise ValueError(f"*args should have shapes (n, d_f) or (n,). Got {f.shape}.")
|
|
42
|
+
if f.shape[0] != Z.shape[0]:
|
|
43
|
+
raise ValueError(f"Shape mismatch: Z.shape={Z.shape}, f.shape={f.shape}.")
|
|
44
|
+
|
|
45
|
+
if n_categories is not None:
|
|
46
|
+
f = f.copy() if copy else f
|
|
47
|
+
|
|
48
|
+
if len(Z.shape) != 1 or not np.issubdtype(Z.dtype, np.integer):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"If n_categories is not None, then Z should be a single column of "
|
|
51
|
+
f"integers. Got shape {Z.shape} and dtype {Z.dtype}."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if len(f.shape) == 1:
|
|
55
|
+
means = np.zeros((n_categories,))
|
|
56
|
+
counts = np.zeros(n_categories)
|
|
57
|
+
|
|
58
|
+
# np.add.at(a, indices, b) is equivalent to a[indices] += b
|
|
59
|
+
np.add.at(means, Z, f)
|
|
60
|
+
np.add.at(counts, Z, 1)
|
|
61
|
+
|
|
62
|
+
means[counts > 0] = means[counts > 0] / counts[counts > 0]
|
|
63
|
+
|
|
64
|
+
f[:] = means[Z]
|
|
65
|
+
else:
|
|
66
|
+
means = np.zeros((n_categories, f.shape[1]))
|
|
67
|
+
counts = np.zeros(n_categories)
|
|
68
|
+
|
|
69
|
+
# np.add.at(a, indices, b) is equivalent to a[indices] += b
|
|
70
|
+
np.add.at(means, Z, f)
|
|
71
|
+
np.add.at(counts, Z, 1)
|
|
72
|
+
|
|
73
|
+
means[counts > 0, :] = means[counts > 0, :] / counts[counts > 0, None]
|
|
74
|
+
|
|
75
|
+
f[:] = means[Z, :]
|
|
76
|
+
|
|
77
|
+
return f
|
|
78
|
+
|
|
79
|
+
# The gelsy driver raises in this case - we handle it separately
|
|
80
|
+
if len(f.shape) == 2 and f.shape[1] == 0:
|
|
81
|
+
return np.zeros_like(f)
|
|
82
|
+
|
|
83
|
+
# return np.dot(Z, scipy.linalg.pinv(Z.T @ Z) @ Z.T @ args[0])
|
|
84
|
+
return np.dot(Z, scipy.linalg.lstsq(Z, f, cond=None, lapack_driver="gelsy")[0])
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anchorboosting
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Anchor Boosting
|
|
5
|
+
Author-email: Malte Londschien <malte@londschien.ch>
|
|
6
|
+
Project-URL: homepage, https://github.com/mlondschien/anchorboosting/
|
|
7
|
+
Classifier: Programming Language :: Python
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: scipy
|
|
13
|
+
Requires-Dist: lightgbm
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# Anchor Boosting
|
|
17
|
+
|
|
18
|
+
This repository combines ideas from [1, 2 and 3].
|
|
19
|
+
[1] suggests regularizing linear regression with the correlation between a so-called anchor variable and the regression's residuals.
|
|
20
|
+
The anchor variable is assumed to be exogenous to the system, i.e., not causally affected by covariates, the outcome, or relevant hidden variables.
|
|
21
|
+
[1] show that such regularization induces robustness of the linear regression model to shift interventions with limited magnitude in directions as observed in the data.
|
|
22
|
+
|
|
23
|
+
[2] proposes to generalize this to nonlinear regression by simply optimizing, e.g., via boosting, the regularized anchor regression loss, replacing linear regression with a more flexible model class.
|
|
24
|
+
|
|
25
|
+
Lastly, [3] give ideas to generalize anchor regression to classification.
|
|
26
|
+
|
|
27
|
+
This repository combines these approaches and implements non-linear anchor (multiclass-) classification using LGBM.
|
|
28
|
+
|
|
29
|
+
## Classification via boosting
|
|
30
|
+
|
|
31
|
+
We first describe how to explicitly solve a classification problem with boosting.
|
|
32
|
+
This includes calculating the gradient and the diagonal of the hessian of the loss with respect to model parameters.
|
|
33
|
+
In each boosting step, the gradient is fit with a weak learner (CART tree).
|
|
34
|
+
The diagonal of the hessian is used as a stopping criterium only (`min_hessian_in_leaf`).
|
|
35
|
+
|
|
36
|
+
We separately describe two-class and multi-class classification, even though the former is a special case of the latter.
|
|
37
|
+
$K$-class classification involves learning $K$ scores (parameters), each fitted with a single weak learner.
|
|
38
|
+
I.e., the boosting algorithm will train $K$ trees in each set.
|
|
39
|
+
|
|
40
|
+
### Two-class classification
|
|
41
|
+
|
|
42
|
+
Consider a setup with observations $x_i \in \mathbb{R}^p$ with outcomes $y_i \in \{-1, 1\}$ for $i=1, \ldots, n$.
|
|
43
|
+
We assign raw scores $f_i \in \mathbb{R}$ to each observation and use the expit to obtain probability predictions.
|
|
44
|
+
The estimated probability of observation $x_i$ with raw score $f_i$ to belong to class 1 is $p_i = \frac{e^f}{1 + e^f} = (1 + e^{-f})^{-1}$.
|
|
45
|
+
The log-likelihood of raw scores $`f=(f_i)_{i=1}^n`$ is
|
|
46
|
+
|
|
47
|
+
$$\ell(f, y) = -\sum_{i=1}^n \log(1 + e^{-yf}).$$
|
|
48
|
+
|
|
49
|
+
The gradient of the log-likelihood is
|
|
50
|
+
|
|
51
|
+
$$
|
|
52
|
+
\frac{d}{d f_i} \ell(f, y) = y \frac{e^{-yf}}{1 + e^{-yf}} =
|
|
53
|
+
\begin{cases}
|
|
54
|
+
1 - p & y = 1 \\
|
|
55
|
+
-p & y = 0
|
|
56
|
+
\end{cases}
|
|
57
|
+
$$
|
|
58
|
+
|
|
59
|
+
The (diagonal of the) Hessian of the log-likelihood is
|
|
60
|
+
|
|
61
|
+
$$
|
|
62
|
+
\frac{d^2}{d^2f_i} \ell(f, y) = (1 - p_i) p_i.
|
|
63
|
+
$$
|
|
64
|
+
|
|
65
|
+
### Multiclass classification
|
|
66
|
+
Consider a setup with observations $x_i \in \mathbb{R}^p$ with outcomes $y_i \in \{1, \ldots, K\}$ for $i=1, \ldots, n$.
|
|
67
|
+
We assign raw scores $f_i = (f_{i, 1}, \ldots, f_{i, K}) \in \mathbb{R}^K$ to each observation and use cross-entropy to obtain probability predictions.
|
|
68
|
+
For $k = 1, \ldots, K$ and $i=1, \ldots, n$ the estimated probability of observation $x_i$ with raw score $f_i$ to belong to class $k$ is $p_{i, k} := \exp(f_{i, k}) / \sum_{j=1}^K \exp(f_{i, j})$. The log-likelihood of raw scores $`(f_i)_{i=1}^n`$ is then
|
|
69
|
+
```math
|
|
70
|
+
\ell\left((f_{i, k})_{i=1, \ldots, n}^{k=1, \ldots, K}, (y_i)_{i=1}^n\right) = \sum_{i=1}^n \left(f_{i, y} - \log\left(\sum_{j=1}^K \exp(f_{i,j})\right)\right).
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The gradient of the log-likelihood is
|
|
74
|
+
|
|
75
|
+
$$
|
|
76
|
+
\frac{d}{df_{i, k}} \ell(f, y) = \begin{cases}
|
|
77
|
+
1 - p_{i, k} & y_i = k \\
|
|
78
|
+
-p_{i, k} & y_i \neq k \\
|
|
79
|
+
\end{cases}
|
|
80
|
+
$$
|
|
81
|
+
|
|
82
|
+
The (diagonal of the) Hessian of the log-likelihood is
|
|
83
|
+
```math
|
|
84
|
+
\frac{d^2}{d^2 f_{i, k}} \ell(f, y) = (1 - p_{i, k}) p_{i, k}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Anchorized classification
|
|
88
|
+
|
|
89
|
+
[1] suggest adding a regularization term based on an "anchor variable" $A$ to the linear least squares optimization problem to improve distributional robustness.
|
|
90
|
+
|
|
91
|
+
Say additional to features and outcomes we observe anchor values $a_i \in \mathbb{R}^q, i=1,\ldots K$.
|
|
92
|
+
Write $A = (a_i)_{i=1}^n \in \mathbb{R}^{n \times q}$ and $\pi_A$ for the linear projection onto the column space of $A$.
|
|
93
|
+
[1] show that
|
|
94
|
+
|
|
95
|
+
$$
|
|
96
|
+
b^\gamma = \underset{b}{\arg\min} \|(\mathrm{Id} - \pi_A)(Y - Xb)\|_2^2 + \gamma\|\pi_A(Y - Xb)\|_2^2
|
|
97
|
+
$$
|
|
98
|
+
|
|
99
|
+
minimizes the linear model's worst-case risk with respect to certain shift interventions as seen in the data.
|
|
100
|
+
|
|
101
|
+
[2] suggests applying anchor regression to nonlinear regression by boosting the above anchor loss with a flexible (nonlinear) learner.
|
|
102
|
+
[3] presents an idea to generalize anchor regression to different distributions than the Gaussian distribution, i.e., using different losses than the squared error.
|
|
103
|
+
One interesting case is the logistic loss for (two-class) classification.
|
|
104
|
+
This is mostly based on intelligently defining residuals to be used in the equivalent classification anchor loss.
|
|
105
|
+
|
|
106
|
+
[4] proposes the usage of a different type of residuals for classification outside of the use with anchor regression.
|
|
107
|
+
We apply their residuals in the context of anchor classification.
|
|
108
|
+
|
|
109
|
+
### Anchorized two-class classification according to [3]
|
|
110
|
+
|
|
111
|
+
Note that again $y \in \{-1, 1\}$.
|
|
112
|
+
[3] suggest defining residuals as
|
|
113
|
+
|
|
114
|
+
$$r_i = \frac{d}{d f}(\ell(f, y)) = y \frac{e^{-yf}}{1 + e^{-yf}} =
|
|
115
|
+
\begin{cases}
|
|
116
|
+
1 - p & y = 1 \\
|
|
117
|
+
-p & y = 0
|
|
118
|
+
\end{cases}
|
|
119
|
+
$$
|
|
120
|
+
|
|
121
|
+
For some tuning parameter $\gamma$, we add the regularization term $(\gamma - 1) \| \pi_A r \|_2^2$ to our optimization problem.
|
|
122
|
+
|
|
123
|
+
$$
|
|
124
|
+
\hat f = \underset{f}{\arg \min} \ \ell(f, Y) + (\gamma - 1) \|\pi_A r\|_2^2
|
|
125
|
+
$$
|
|
126
|
+
|
|
127
|
+
This encourages uncorrelatedness between the residuals and the anchor and, hopefully, better domain generalization. To optimize this, we also calculate the gradient of the regularization term. First, note that
|
|
128
|
+
|
|
129
|
+
$$
|
|
130
|
+
\frac{d}{d f_i} p_i = p_i (1 - p_i)
|
|
131
|
+
$$
|
|
132
|
+
|
|
133
|
+
such that
|
|
134
|
+
|
|
135
|
+
$$
|
|
136
|
+
\frac{d}{d f_i} \| \pi_A r \|_2^2 = 2 \pi_A r \cdot p (1 - p)
|
|
137
|
+
$$
|
|
138
|
+
|
|
139
|
+
### Anchorized multiclass classification according to [3]
|
|
140
|
+
|
|
141
|
+
We combine the notions of [1, 2, 3].
|
|
142
|
+
Motivated by [3], define residuals
|
|
143
|
+
```math
|
|
144
|
+
r_{i, k} =
|
|
145
|
+
\frac{d}{df_{i, k}} \ell(f, y) =
|
|
146
|
+
\begin{cases}
|
|
147
|
+
1 - p_{i, k} & y_i = k \\
|
|
148
|
+
-p_{i, k} & y_i \neq k
|
|
149
|
+
\end{cases}
|
|
150
|
+
```
|
|
151
|
+
such that for all $i$ we have $\sum_{k} r_{i, k} = 0$.
|
|
152
|
+
|
|
153
|
+
For some tuning parameter $\gamma$, we add the regularization term $(\gamma - 1) \| \pi_A r \|_2^2$ to our optimization problem.
|
|
154
|
+
|
|
155
|
+
$$
|
|
156
|
+
\hat f = \underset{f}{\arg \min} \ \ell(f, Y) + (\gamma - 1) \|\pi_A r\|_2^2
|
|
157
|
+
$$
|
|
158
|
+
|
|
159
|
+
This encourages uncorrelatedness between the residuals and the anchor and, hopefully, better domain generalization. To optimize this, we also calculate the gradient of the regularization term. First, note that
|
|
160
|
+
|
|
161
|
+
$$
|
|
162
|
+
\frac{d}{d f_{i, k}} p_{i, j} =
|
|
163
|
+
\frac{d}{d f_{i, k}} \frac{\exp(f_{i, j})}{\sum_l \exp(f_{i, l})} =
|
|
164
|
+
\begin{cases}
|
|
165
|
+
\frac{\exp(f_{i, k})}{\sum_l \exp(f_{i, l})} - \frac{\exp(f_{i, k})^2}{(\sum_l \exp(f_{i, l}))^2} & j = k \\
|
|
166
|
+
-\frac{\exp(f_{i, k})\exp{(f_{i, j})}}{(\sum_l \exp(f_{i, l}))^2} & j \neq k
|
|
167
|
+
\end{cases} =
|
|
168
|
+
\begin{cases}
|
|
169
|
+
p_{i, j} - p_{i, j}p_{i, k} & j = k \\
|
|
170
|
+
-p_{i, j} p_{i, k} & j \neq k
|
|
171
|
+
\end{cases}.
|
|
172
|
+
$$
|
|
173
|
+
|
|
174
|
+
Then,
|
|
175
|
+
|
|
176
|
+
```math
|
|
177
|
+
\frac{d}{d f_{i, k}} \| \pi_A r\|_2^2 = 2 \pi_A r \cdot \left(\begin{cases}
|
|
178
|
+
p_{i, j} - p_{i, j}p_{i, k} & l=i, j = k \\
|
|
179
|
+
-p_{i, j} p_{i, k} & l=i, j \neq k \\
|
|
180
|
+
0 & l \neq i
|
|
181
|
+
\end{cases}\right)_{l=1, \ldots, n}^{j=1, \ldots K}
|
|
182
|
+
=(\pi_A r)_{i, k} \ p_{i, k} - \sum_{l=1}^K (\pi_A r)_{i, l} \ p_{i, l}.
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
<!---
|
|
186
|
+
The diagonal of the hessian is equal to
|
|
187
|
+
|
|
188
|
+
$$
|
|
189
|
+
\frac{d^2}{d f_{i, k}^2} \| \pi_A r\|^2 = 2 \pi_A r \cdot
|
|
190
|
+
\left(\begin{cases}
|
|
191
|
+
p_{i, j} - 3 p_{i, j}^2 + 2 p_{i, j}^3 & l=i, j = k \\
|
|
192
|
+
2 p_{i, j} p_{i, k}^2 - p_{i, j} p{i, k} & l=i, j \neq k \\
|
|
193
|
+
0 & l \neq i
|
|
194
|
+
\end{cases}\right)_{l=1, \ldots, n}^{j=1, \ldots K} +
|
|
195
|
+
2 \pi_A \left(\begin{cases}
|
|
196
|
+
p_{i, j} - p_{i, j}p_{i, k} & l=i, j = k \\
|
|
197
|
+
- p_{i, j} p_{i, k} & l=i, j \neq k \\
|
|
198
|
+
0 & l \neq i
|
|
199
|
+
\end{cases}\right)_{l=1, \ldots, n}^{j=1, \ldots K} \cdot \left(\begin{cases}
|
|
200
|
+
p_{i, j} - p_{i, j}p_{i, k} & l=i, j = k \\
|
|
201
|
+
- p_{i, j} p_{i, k} & l=i, j \neq k \\
|
|
202
|
+
0 & l \neq i
|
|
203
|
+
\end{cases}\right)_{l=1, \ldots, n}^{j=1, \ldots K}
|
|
204
|
+
$$
|
|
205
|
+
|
|
206
|
+
$$
|
|
207
|
+
\frac{d^2}{d f_{i, k} d f_{i, l}} p_{i, j} =
|
|
208
|
+
\begin{cases}
|
|
209
|
+
(1 - 2 p_{i, j}) p_{i, j} (1 - p_{i, j}) & j = k \\
|
|
210
|
+
p_{i, j}^2 p_{i, k} & j \neq k
|
|
211
|
+
\end{cases}
|
|
212
|
+
$$ -->
|
|
213
|
+
|
|
214
|
+
## Anchorized two-class classification with residuals motivated by [4]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
## References
|
|
219
|
+
|
|
220
|
+
[1] Rothenhäusler, D., N. Meinshausen, P. Bühlmann, and J. Peters (2021). Anchor regression: Heterogeneous data meet causality. Journal of the Royal Statistical Society Series B (Statistical Methodology) 83(2), 215–246.
|
|
221
|
+
|
|
222
|
+
[2] Bühlmann, P. (2020). Invariance, causality and robustness. Statistical Science 35(3), 404– 426.
|
|
223
|
+
|
|
224
|
+
[3] Kook, L., B. Sick, and P. Bühlmann (2022). Distributional anchor regression. Statistics and Computing 32(3), 1–19.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
anchorboosting/__init__.py,sha256=MGNvLzDGE19NKIwN6muC6P6c-dznFfn-VoQi_kd08Yc,160
|
|
2
|
+
anchorboosting/models.py,sha256=7MEPp9lvSr0_FEO_Dxgf-GWLz_N5R2GRoU2cerGMULM,14787
|
|
3
|
+
anchorboosting/simulate.py,sha256=eMBwbOsE9U9t8l1uCNg8asLHSV5v6Rff8CHGmltlaKI,577
|
|
4
|
+
anchorboosting/utils.py,sha256=Bh439YcvTYQ6QBIISuL1BPSo_v7nq8VAalSONosxeEg,3152
|
|
5
|
+
anchorboosting/objectives/__init__.py,sha256=hbBiAd6Ng_sfc1aaBc559DaCn1oGacC5XJDeq2Eoiz8,284
|
|
6
|
+
anchorboosting/objectives/anchor_objectives.py,sha256=rloFuorTs-kcGvE2L1zGlJJlPj3r_fukN-PtTwo9nOo,4223
|
|
7
|
+
anchorboosting/objectives/mixins.py,sha256=-ceTSWUFyOBBOrFltHTYFTLoW31qlUOC683nnaLfTk0,7182
|
|
8
|
+
anchorboosting-0.0.1.dist-info/licenses/LICENSE,sha256=DZyvhSIAeCQbdMQXcvAU_5VZo7j6Iqh5wxwNUNIkgXY,1506
|
|
9
|
+
anchorboosting-0.0.1.dist-info/METADATA,sha256=FP5A3K_iLtFRn_jVDPI2IUgEWxmLqK0T2BH7ZvRAc1Y,8811
|
|
10
|
+
anchorboosting-0.0.1.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
|
|
11
|
+
anchorboosting-0.0.1.dist-info/top_level.txt,sha256=MQhJ5Jg216IFXbvNuArVQZPT8NEYMbU1k-PkG_EnBXc,15
|
|
12
|
+
anchorboosting-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
BSD 3-clause license
|
|
2
|
+
Copyright (c) 2022 Malte Londschien
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
8
|
+
|
|
9
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
10
|
+
|
|
11
|
+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
12
|
+
|
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
anchorboosting
|