dmlc / dmlc/xgboost

Consider deprecating/removing lambdarank_unbiased due to stateful objective and model-IO complexity

Open
#12,302 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
28.8k
Forks
8.9k
Avg merge
1d 12h
Merged PRs (30d)
54

Description

`lambdarank_unbiased=True` is architecturally unusual among XGBoost objectives: it maintains learned objective state across boosting rounds.

The unbiased LambdaMART implementation tracks position-bias vectors, currently serialized as:

- `ti+`
- `tj-`

These are not ordinary objective parameters. They are learned training state. For correct checkpoint/resume behavior, this state must be saved with the model and restored before subsequent boosting rounds. That makes this objective different from the usual XGBoost objective contract, where objectives are mostly stateless functions of predictions, labels, weights, and parameters.

This also means model IO has to carry potentially large objective state. With `lambdarank_pair_method="topk"`, the position-bias vector size is controlled by `lambdarank_num_pair_per_sample`, so serialized model/config size can grow with a user-controlled ranking parameter.

Given ongoing cleanup/deprecation removal, it may be worth deprecating/removing `lambdarank_unbiased` instead of expanding the model-IO contract around a single stateful objective.

Related: #12301 tracks a separate checkpoint determinism issue for `lambdarank_pair_method="mean"` pair sampling. This issue is specifically about the stateful unbiased LambdaMART path.

## Why this matters

This statefulness creates several maintenance and compatibility problems:

1. Model serialization includes learned objective state, not just model structure and stable configuration.
2. Training continuation depends on restoring this state exactly.
3. The state shape depends on ranking parameters and training data/cache-derived quantities.
4. Parameter changes during continuation can leave stale state dimensions.
5. The state is currently saved through `SaveConfig`, which blurs the line between configuration and learned model/training state.

## Possible alternative

Users who need experimental unbiased LambdaMART behavior could implement it as a custom Python objective with `xgboost.train`, keeping the position-bias state in the Python objective closure or callback-owned training state.

That would make the unusual statefulness explicit and training-session-local, instead of requiring every model serialization path to preserve objective-internal learned state. The tradeoff is that model-file continuation would not automatically carry this Python-side state, which is a clearer contract than silently serializing mutable objective state inside model config.

This would mainly be an alternative for `xgboost.train`; the sklearn `XGBRanker` interface currently does not support custom objectives.

## Concrete issues observed

### 1. Checkpoint continuation ignores restored bias state

For `lambdarank_unbiased=True`, training 5 rounds in one call does not match training 4 rounds, saving/loading, then training 1 more round.

The likely cause is that `ti+` / `tj-` are loaded from model/config, but scratch buffers like `li_full_` / `lj_full_` are empty after deserialization. The initialization path treats missing scratch buffers as full unbiased-state initialization and resets `ti+` / `tj-` back to ones on the first resumed update.

### 2. Mutating restored `ti+` / `tj-` has no effect on the next resumed tree

Two identical models were created. I changed only the serialized `ti+` / `tj-` values in one copy, then continued both for one boosting round. The resulting models were byte-for-byte identical, suggesting the restored learned bias state is discarded or ignored on resumed training.

### 3. Changing `lambdarank_num_pair_per_sample` can leave stale state dimensions

```text
start with k=4, update, set k=8, update -> config says k=8 but len(ti+) == 4
start with k=8, update, set k=4, update -> config says k=4 but len(ti+) == 8
```

This is another symptom of learned objective state living outside the usual parameter/cache lifecycle.

## Reproducer

```python
import hashlib
import os
import tempfile

import numpy as np
import xgboost as xgb

rng = np.random.default_rng(20260714)

n_groups = 24
group_size = 10
n = n_groups * group_size

pos = np.tile(np.arange(group_size), n_groups)
gid = np.repeat(np.arange(n_groups), group_size)

X = rng.normal(size=(n, 6)).astype(np.float32)
X[:, 0] = (group_size - pos).astype(np.float32) + rng.normal(scale=0.2, size=n)
X[:, 1] = gid.astype(np.float32) / n_groups

y = (
(pos == 0)
| ((pos <= 2) & (rng.random(n) < 0.45))
| (rng.random(n) < 0.04)
).astype(np.float32)

dtrain = xgb.DMatrix(X, label=y)
dtrain.set_group(np.full(n_groups, group_size, dtype=np.uint32))

params = {
"objective": "rank:ndcg",
"tree_method": "hist",
"max_depth": 2,
"eta": 0.25,
"min_child_weight": 0,
"lambda": 0,
"lambdarank_unbiased": True,
"lambdarank_pair_method": "topk",
"lambdarank_num_pair_per_sample": 8,
"base_score": 0.5,
"seed": 7,
"nthread": 1,
}

full = xgb.train(params, dtrain, num_boost_round=5)

part = xgb.train(params, dtrain, num_boost_round=4)
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "rank.ubj")
part.save_model(path)

loaded = xgb.Booster()
loaded.load_model(path)

resumed = xgb.train(params, dtrain, num_boost_round=1, xgb_model=loaded)

raw_full = full.save_raw(raw_format="json")
raw_resumed = resumed.save_raw(raw_format="json")

print(raw_full == raw_resumed)
print(hashlib.sha256(raw_full).hexdigest())
print(hashlib.sha256(raw_resumed).hexdigest())
```

## Observed output

Tested locally with XGBoost `3.4.0-dev` from the current checkout:

```text
False
1d3602adb1a39cf0c10771579fade962d57b9267914d907ced4334267eed3520
522258dbd1c43192ba9dea9bbf08a951f1646c716304202d854e1de322d26557
```

For comparison, the same checkpoint-resume test with `lambdarank_unbiased=False` and `lambdarank_pair_method="topk"` produced identical models.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with the xgb.train checkpoint/resume reproducer and trace the lambdarank_unbiased path through SaveConfig, model loading, and the ti+/tj- and li_full_/lj_full_ state mentioned in the report. Compare uninterrupted and resumed training, then determine whether the state can be made reliable or whether deprecating/removing this path is the agreed outcome.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, python
Domain
machine-learning
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.