Personalization¶
In this notebook, we personalize the loss as a convex piecewise-affine loss. We use the same loss with exact \(f\)-DRO and Wasserstein DRO models, then carry its asymmetric decision costs into the neural approximation classes.
The tree-ensemble section illustrates the same 3:1 cost ratio with a cost-sensitive binary cross-entropy loss in XGBoost.
For post-training worst-case-loss bounds, see the Neural DRO robustness certificates guide.
A general convex piecewise-affine loss¶
Let \(t=\theta^\top x+b\) be the prediction and \(z=t-y\) its residual. We parameterize the loss as
A maximum of affine pieces is convex. Changing the arrays of slopes \(a_j\) and offsets \(c_j\) creates many losses without rewriting the DRO formulation. LAD, pinball loss, and epsilon-insensitive loss are special cases.
Original newsvendor loss¶
This loss covers a lot of operational examples. For example, the original asymmetric newsvendor loss
where under-prediction costs three times as much as over-prediction. Thus its piecewise-affine slopes are [-3, 1] and both offsets are zero. The helper functions below remain general: substitute any finite slope and offset arrays to define another convex piecewise-affine loss.
[1]:
from types import MethodType
import cvxpy as cp
import numpy as np
from dro.linear_model.chi2_dro import Chi2DRO
from dro.linear_model.wasserstein_dro import WassersteinDRO
UNDERAGE_COST = 3.0
OVERAGE_COST = 1.0
NEWSVENDOR_SLOPES = np.array([-UNDERAGE_COST, OVERAGE_COST])
NEWSVENDOR_OFFSETS = np.zeros(2)
X = np.array([
[1.0, 1.0],
[2.0, 1.0],
[3.0, 1.0],
[4.0, 1.0],
])
y = np.array([1.0, 1.0, 0.0, 0.0])
def piecewise_affine_numpy(residual, slopes, offsets):
pieces = slopes[:, None] * residual[None, :] + offsets[:, None]
return np.max(pieces, axis=0)
def piecewise_affine_cvxpy(residual, slopes, offsets):
pieces = cp.vstack([
slope * residual + offset
for slope, offset in zip(slopes, offsets)
])
return cp.max(pieces, axis=0)
def newsvendor_loss(self, X, y):
prediction = X @ self.theta + self.b
residual = prediction - y
return piecewise_affine_numpy(
residual, NEWSVENDOR_SLOPES, NEWSVENDOR_OFFSETS
)
def newsvendor_cvx_loss(self, X, y, theta, b, design_matrix=None):
features = X if design_matrix is None else design_matrix
residual = features @ theta + b - y
return piecewise_affine_cvxpy(
residual, NEWSVENDOR_SLOPES, NEWSVENDOR_OFFSETS
)
Exact \(f\)-DRO¶
For an \(f\)-divergence ambiguity set, the inner problem changes the probabilities assigned to the observed samples. It only needs the numerical per-sample newsvendor loss (_loss) and its CVXPY representation (_cvx_loss). The same two hooks can be attached to KLDRO, Chi2DRO, TVDRO, or CVaRDRO; Chi-squared DRO is used here as a representative.
[2]:
fdro_model = Chi2DRO(
input_dim=X.shape[1], model_type='lad', solver='CLARABEL'
)
fdro_model._loss = MethodType(newsvendor_loss, fdro_model)
fdro_model._cvx_loss = MethodType(newsvendor_cvx_loss, fdro_model)
fdro_model.update({'eps': 0.10})
fdro_parameters = fdro_model.fit(X, y)
fdro_parameters
[2]:
{'theta': [-0.47101600668852506, 0.9420320649788056], 'b': array(0.94203196)}
Exact Wasserstein DRO¶
Wasserstein DRO also needs the loss’s Lipschitz penalty. For ground cost
let \(q\) be dual to \(p\). On an unrestricted input-output space, the residual loss above has penalty
When kappa='inf', labels cannot move and the \(1/\kappa\) term is omitted. This is the Lipschitz reformulation behind Theorem 4 of Regularization via Mass Transportation. The helper below targets the linear-kernel case; a custom kernel representation must supply its corresponding feature norm.
[3]:
def newsvendor_penalization(self, theta):
if self.kernel != 'linear':
raise NotImplementedError(
'This tutorial penalty is written for a linear feature map.'
)
if self.p == 1:
dual_norm = np.inf
elif self.p == 'inf':
dual_norm = 1
else:
dual_norm = 1.0 / (1.0 - 1.0 / self.p)
residual_lipschitz = float(np.max(np.abs(NEWSVENDOR_SLOPES)))
feature_term = cp.norm(
self.cost_inv_transform @ theta,
dual_norm,
)
if self.kappa == 'inf':
return residual_lipschitz * feature_term
return residual_lipschitz * cp.maximum(
feature_term,
1.0 / self.kappa,
)
[4]:
wdro_model = WassersteinDRO(
input_dim=X.shape[1], model_type='lad', solver='CLARABEL'
)
wdro_model._loss = MethodType(newsvendor_loss, wdro_model)
wdro_model._cvx_loss = MethodType(newsvendor_cvx_loss, wdro_model)
wdro_model._penalization = MethodType(newsvendor_penalization, wdro_model)
wdro_model.update({'eps': 0.05, 'p': 2, 'kappa': 'inf'})
wdro_parameters = wdro_model.fit(X, y)
wdro_parameters
[4]:
{'theta': [-0.4999999974041001, 3.802290229295084e-14], 'b': array(2.)}
Neural approximation with a decision-aware piecewise loss¶
The same newsvendor decision problem can be approximated with a neural predictor. More generally, suppose under-prediction and over-prediction lead to different downstream costs. The corresponding unbalanced L1 loss is
For positive costs, only one term can be active, so this is equivalently the maximum of two affine pieces. It is therefore a personalized, convex, piecewise-affine decision loss. The loss function below returns one value per sample: neural \(f\)-DRO robustly aggregates that vector, while neural Wasserstein DRO evaluates it after generating perturbed inputs.
[5]:
import torch
from dro.neural_model.fdro_nn import Chi2NNDRO
from dro.neural_model.fdro_utils import RobustLoss
from dro.neural_model.wdro_nn import WNNDRO
def unbalanced_l1(
predictions,
targets,
underage_cost=UNDERAGE_COST,
overage_cost=OVERAGE_COST,
):
predictions = predictions.squeeze(-1)
targets = targets.reshape_as(predictions)
pieces = torch.stack([
underage_cost * (targets - predictions),
overage_cost * (predictions - targets),
])
return torch.maximum(pieces[0], pieces[1])
Neural \(f\)-DRO¶
Subclass RobustLoss to replace its individual loss while retaining its chi-squared or CVaR probability optimization. Bind the resulting scalar robust criterion to the neural model.
[6]:
class DecisionAwareRobustLoss(RobustLoss):
def __init__(self, underage_cost, overage_cost, **kwargs):
super().__init__(is_regression=True, **kwargs)
self.underage_cost = underage_cost
self.overage_cost = overage_cost
def _compute_individual_loss(self, outputs, targets):
return unbalanced_l1(
outputs,
targets,
underage_cost=self.underage_cost,
overage_cost=self.overage_cost,
)
decision_robust_loss = DecisionAwareRobustLoss(
underage_cost=UNDERAGE_COST,
overage_cost=OVERAGE_COST,
geometry='chi-square',
size=0.2,
reg=0.1,
)
fdro_nn = Chi2NNDRO(
input_dim=2,
num_classes=1,
task_type='regression',
model_type='linear',
size=0.2,
reg=0.1,
device=torch.device('cpu'),
)
def decision_aware_fdro_criterion(self, outputs, labels):
return decision_robust_loss(outputs, labels)
fdro_nn._criterion = MethodType(decision_aware_fdro_criterion, fdro_nn)
Neural Wasserstein DRO¶
WNNDRO._loss must return the per-sample loss vector. Its existing criterion handles adversarial input generation and then averages these personalized losses. The rest of the training API is unchanged.
[7]:
wdro_nn = WNNDRO(
input_dim=2,
num_classes=1,
task_type='regression',
model_type='linear',
robust_objective='transport_penalty',
gamma=2.0,
adversarial_steps=2,
adversarial_step_size=0.02,
device=torch.device('cpu'),
)
def decision_aware_wdro_loss(self, outputs, labels):
return unbalanced_l1(
outputs,
labels,
underage_cost=UNDERAGE_COST,
overage_cost=OVERAGE_COST,
)
wdro_nn._loss = MethodType(decision_aware_wdro_loss, wdro_nn)
Call fdro_nn.fit(...) or wdro_nn.fit(...) after binding the hooks. The Wasserstein regression model above uses robust_objective='transport_penalty', so both its inner perturbation search and outer objective use the custom loss with continuous targets. gamma penalizes squared feature transport; it is not a norm-ball radius.
[8]:
batch_predictions = torch.tensor([[0.8], [1.6], [2.5]], requires_grad=True)
batch_targets = torch.tensor([1.0, 1.4, 3.0])
fdro_objective = fdro_nn._criterion(batch_predictions, batch_targets)
wdro_individual_losses = wdro_nn._loss(batch_predictions, batch_targets)
combined_check = fdro_objective + wdro_individual_losses.mean()
combined_check.backward()
print('f-DRO robust objective:', float(fdro_objective.detach()))
print('WDRO individual losses:', wdro_individual_losses.detach().numpy())
print('gradient shape:', tuple(batch_predictions.grad.shape))
f-DRO robust objective: 1.0904502868652344
WDRO individual losses: [0.59999996 0.20000005 1.5 ]
gradient shape: (3, 1)
Matching the Wasserstein inner search to a custom loss¶
The preceding wdro_nn._loss check evaluates the custom loss without running the inner search. The model above uses transport-penalty mode. By comparison, with the default robust_objective='norm_ball' and positive epsilon, Torchattacks generates perturbations using classification cross-entropy; overriding _loss changes the outer loss only. For continuous targets, robust_objective='transport_penalty' uses _loss in both the inner search and outer objective. The example below
differentiates that complete objective on a small batch.
[9]:
wdro_transport = WNNDRO(
input_dim=2,
num_classes=1,
task_type='regression',
model_type='linear',
robust_objective='transport_penalty',
gamma=2.0,
adversarial_steps=2,
adversarial_step_size=0.02,
device=torch.device('cpu'),
)
wdro_transport._loss = MethodType(decision_aware_wdro_loss, wdro_transport)
transport_inputs = torch.tensor([[0.2, 0.8], [0.6, 0.4], [0.9, 0.1]])
transport_targets = torch.tensor([0.3, 0.5, 0.7])
wdro_transport.current_inputs = transport_inputs
transport_outputs = wdro_transport.model(transport_inputs)
transport_objective = wdro_transport._criterion(transport_outputs, transport_targets)
transport_objective.backward()
print('Transport-penalty objective:', float(transport_objective.detach()))
Transport-penalty objective: 3.6209213733673096
Custom data loaders, optimizers, and schedulers¶
The neural classes now accept train_loader, val_loader, optimizer, and scheduler in fit(...). Both loaders are required on this path, and X and y are omitted. Each batch must yield (inputs, labels). Build the optimizer after calling update_user_mode(...) so it follows the replacement network’s parameters. A scheduler requires an explicit optimizer; scheduler_step='epoch' advances it once per epoch, while 'batch' advances it after every training
batch.
The next toy classification data stay in [0, 1], which also meets the input range expected by Torchattacks in the HR-DRO example. The class-1 examples have twice the cost in the personalized \(f\)-DRO loss below.
[10]:
from torch.utils.data import DataLoader, TensorDataset
import torch.nn.functional as F
from dro.neural_model.fdro_nn import CVaRNNDRO
from dro.neural_model.hrdro_nn import HRNNDRO
torch.manual_seed(7)
train_positions = torch.linspace(0.05, 0.95, steps=16)
val_positions = torch.tensor([0.15, 0.35, 0.65, 0.85])
def make_classification_data(positions):
inputs = torch.stack([positions, 1.0 - positions], dim=1)
labels = (positions >= 0.5).long()
return inputs, labels
train_inputs, train_labels = make_classification_data(train_positions)
val_inputs, val_labels = make_classification_data(val_positions)
train_loader = DataLoader(
TensorDataset(train_inputs, train_labels), batch_size=4, shuffle=True
)
val_loader = DataLoader(TensorDataset(val_inputs, val_labels), batch_size=4)
Chi-squared and CVaR neural \(f\)-DRO¶
Both models use the same per-example cost-sensitive classification loss. Their RobustLoss geometry determines how those losses are aggregated. The ResidualClassifier below is a user-defined torch.nn.Module: its skip connection makes it different from the package’s default MLP. Pass an instance to update_user_mode(...) before fitting. Its forward method maps each two-feature input to two class logits. Each run also supplies an SGD optimizer and a StepLR scheduler to the
shared fitting loop. The learning-rate value printed at the end confirms that the scheduler was stepped.
[11]:
class CostSensitiveRobustLoss(RobustLoss):
def __init__(self, **kwargs):
super().__init__(is_regression=False, **kwargs)
def _compute_individual_loss(self, outputs, targets):
per_sample_ce = F.cross_entropy(outputs, targets, reduction='none')
return (1.0 + targets.float()) * per_sample_ce
class ResidualClassifier(torch.nn.Module):
def __init__(self):
super().__init__()
self.skip = torch.nn.Linear(2, 2)
self.hidden = torch.nn.Sequential(
torch.nn.Linear(2, 8), torch.nn.Tanh(), torch.nn.Linear(8, 2)
)
def forward(self, inputs):
return self.skip(inputs) + self.hidden(inputs)
f_dro_results = {}
for name, model_class, geometry in [
('chi-squared', Chi2NNDRO, 'chi-square'),
('CVaR', CVaRNNDRO, 'cvar'),
]:
neural_model = model_class(
input_dim=2, num_classes=2, task_type='classification',
model_type='mlp', size=0.5, reg=0.1, device=torch.device('cpu'),
)
custom_network = ResidualClassifier()
neural_model.update_user_mode(
input_dim=2, num_classes=2, model=custom_network,
task_type='classification', device=torch.device('cpu'),
)
robust_loss = CostSensitiveRobustLoss(
geometry=geometry, size=0.5, reg=0.1
)
def cost_sensitive_criterion(self, outputs, labels, loss_module=robust_loss):
return loss_module(outputs, labels)
neural_model._criterion = MethodType(cost_sensitive_criterion, neural_model)
optimizer = torch.optim.SGD(neural_model.model.parameters(), lr=0.05)
scheduler = torch.optim.lr_scheduler.StepLR(
optimizer, step_size=1, gamma=0.8
)
metrics = neural_model.fit(
train_loader=train_loader, val_loader=val_loader,
optimizer=optimizer, scheduler=scheduler, scheduler_step='epoch',
epochs=2, verbose=False,
)
f_dro_results[name] = {
'metrics': metrics,
'final_learning_rate': optimizer.param_groups[0]['lr'],
}
f_dro_results
[11]:
{'chi-squared': {'metrics': {'acc': 0.5, 'f1': 0.3333333333333333},
'final_learning_rate': 0.03200000000000001},
'CVaR': {'metrics': {'acc': 0.5, 'f1': 0.3333333333333333},
'final_learning_rate': 0.03200000000000001}}
HR-DRO with a replacement network¶
HRNNDRO.update_user_mode(...) also rebuilds its attack against the replacement network. The optimizer and scheduler are created afterward. HR-DRO still uses its built-in cross-entropy loss here; the cost-sensitive \(f\)-DRO hook above does not alter HR-DRO’s CVXPY weighting or Torchattacks objective. Set train_batch_size to the loader batch size to avoid needless CVXPY problem rebuilds. A shorter last batch is handled dynamically.
[12]:
hr_dro = HRNNDRO(
input_dim=2, num_classes=2, task_type='classification',
model_type='linear', alpha=0.1, r=0.01, epsilon=0.02,
learning_approach='HD', train_batch_size=4,
adversarial_params={
'method': 'FGSM', 'norm': 'l-inf', 'steps': 1, 'step_size': 0.01,
},
device=torch.device('cpu'),
)
hr_network = torch.nn.Sequential(
torch.nn.Linear(2, 8), torch.nn.ReLU(), torch.nn.Linear(8, 2)
)
hr_dro.update_user_mode(
input_dim=2, num_classes=2, model=hr_network,
task_type='classification', device=torch.device('cpu'),
)
assert hr_dro.attack.model is hr_dro.model
hr_optimizer = torch.optim.AdamW(hr_dro.model.parameters(), lr=0.01)
hr_scheduler = torch.optim.lr_scheduler.StepLR(
hr_optimizer, step_size=1, gamma=0.8
)
hr_metrics = hr_dro.fit(
train_loader=train_loader, val_loader=val_loader,
optimizer=hr_optimizer, scheduler=hr_scheduler, scheduler_step='epoch',
epochs=2, verbose=False,
)
print('HR-DRO validation:', hr_metrics)
print('Final learning rate:', hr_optimizer.param_groups[0]['lr'])
HR-DRO validation: {'acc': 0.75, 'f1': 0.7333333333333334}
Final learning rate: 0.0064
Tree-based ensemble with a personalized loss¶
For binary XGBoost KL-DRO, loss(preds, labels) supplies one loss per training example. The wrapper uses those values for DRO sample weights and its reported robust objective. Replacing loss() alone does not change the ordinary cross-entropy gradients and Hessians used by boosting. To train with a prediction-dependent cost-sensitive loss, we also override _kl_dro_loss().
Here positive-class log loss costs three times as much as negative-class log loss, matching the 3:1 ratio above. The Hessian uses the same weighted diagonal approximation as the built-in KL-DRO objective. This example covers binary classification; other losses require matching derivatives and a compatible booster objective. The small toy run limits numerical-library threads during fitting and prediction.
[13]:
from scipy.special import expit
from threadpoolctl import threadpool_limits
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from dro.tree_model.xgb import KLDRO_XGB
class CostSensitiveKLDRO_XGB(KLDRO_XGB):
def __init__(self, positive_cost, negative_cost, eps=0.1):
super().__init__(eps=eps, kind='classification')
self.positive_cost = positive_cost
self.negative_cost = negative_cost
def loss(self, preds, labels):
probabilities = np.clip(np.asarray(preds).reshape(-1), 1e-8, 1 - 1e-8)
labels = np.asarray(labels).reshape(-1)
costs = np.where(labels == 1, self.positive_cost, self.negative_cost)
log_loss = -labels * np.log(probabilities) - (1 - labels) * np.log1p(-probabilities)
return costs * log_loss
def _kl_dro_loss(self, raw_scores, dtrain, epsilon=0.1):
labels = dtrain.get_label()
probabilities = expit(raw_scores)
losses = self.loss(probabilities, labels)
scaled_losses = epsilon * losses
weights = np.exp(scaled_losses - np.max(scaled_losses))
weights *= len(labels) / weights.sum()
costs = np.where(labels == 1, self.positive_cost, self.negative_cost)
gradients = weights * costs * (probabilities - labels)
hessians = weights * costs * probabilities * (1 - probabilities)
return gradients, hessians
tree_X, tree_y = make_classification(
n_samples=120, n_features=2, n_informative=2, n_redundant=0,
random_state=7,
)
tree_X_train, tree_X_test, tree_y_train, tree_y_test = train_test_split(
tree_X, tree_y, test_size=0.25, stratify=tree_y, random_state=7,
)
tree_model = CostSensitiveKLDRO_XGB(
positive_cost=UNDERAGE_COST, negative_cost=OVERAGE_COST, eps=0.1,
)
tree_model.update({
'objective': 'binary:logistic', 'max_depth': 2,
'learning_rate': 0.2, 'num_boost_round': 6,
'nthread': 1, 'seed': 7,
})
with threadpool_limits(limits=1):
tree_model.fit(tree_X_train, tree_y_train)
tree_accuracy = (tree_model.predict(tree_X_test) == tree_y_test).mean()
print('Losses at probability 0.5:', tree_model.loss(
np.array([0.5, 0.5]), np.array([0, 1]),
))
print(f'Test accuracy: {tree_accuracy:.3f}')
print(f'Robust training objective: {tree_model.robust_obj:.3f}')
Losses at probability 0.5: [0.69314718 2.07944154]
Test accuracy: 0.933
Robust training objective: 0.445