dmlc / dmlc/xgboost

feature_types silently discarded in certain situations

Open
#12,527 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

We ran into an issue recently with xgboost 3.2.0 where we ran incremental learning atop an existing xgboost model and the type information for the existing columns was lost / ignored. This created problems for our categorical columns.

Claude wrote up this bug report.

# Training continuation silently discards `feature_types` when the base model holds a
# valueless `Categories` container — and CPU/GPU disagree on the outcome

**Version:** 3.2.0 · **Impact:** silent training of a wrong model (GPU) / spurious hard error (CPU)

## Summary

A `CatContainer` can be **allocated but valueless** — one column per feature, zero total categories:

```json
"cats": {"enc": [{"offsets": [], "values": []}, ...N], "feature_segments": [0, ...N+1], "sorted_idx": []}
```

We have a production booster in exactly this state (many columns, no categories), produced by a
multi-GPU cuDF training run. **We have not isolated which step allocates it**, and we would value a
maintainer's read on that — see "Open question" below. The defect reported here is what xgboost does
*given* a container in that state, which is reproducible by constructing one directly.

Two different predicates disagree about whether such a container holds categories:

| predicate | definition | source |
|---|---|---|
| `CatContainer::Empty()` | `cpu_impl_->columns.empty()` | `src/data/cat_container.cc:285` |
| `ColumnsViewImpl::Empty()` | `columns.size() == 0` | `src/encoder/ordinal.h:135` |
| `ColumnsViewImpl::HasCategorical()` | `n_total_cats != 0` | `src/encoder/ordinal.h:137` |
| `ColumnarAdapter::HasRefCategorical()` | `!ref_cats_.Empty()` (**column count**) | `src/data/adapter.h:468` |
| `CudfAdapter::HasRefCategorical()` | `ref_cats_.n_total_cats != 0` (**category count**) | `src/data/device_adapter.cuh:91` |

The container above is *non-empty* by the first definition and has *no categories* by the second.

## The chain

`xgboost/sklearn.py:617 get_model_categories` (called from `dask/data.py:290 _extract_data` and from
`XGBModel.fit` at `sklearn.py:1340/1784/2286`) uses the **column-count** definition:

```python
categories = model.get_categories()
if not categories.empty():
# override the `feature_types`.
return model, categories
```

So the user's `["c", "q", …]` list is discarded and replaced by an object carrying **no categories**.
`_data_utils.py:731 get_ref_categories` then sets `feature_types = None`, and
`data.py::_transform_cudf_df` / `_transform_pandas_df` fall through to dtype inference. With `float32`
code columns, **every slot is inferred numeric**.

What happens next depends on which adapter you are on:

- **GPU (`CudfAdapter`)** — `HasRefCategorical()` is `n_total_cats != 0` → **false** → the recode in
`proxy_dmatrix.cuh:35` is skipped and the batch is used as-is. Training completes and produces a
booster whose trees split the declared-categorical slots as ordinals. The booster still *reports*
`feature_types == ["c", …]`, inherited from the base by `core.py:3384 _assign_dmatrix_features`.
The model is silently wrong; the first symptom is a much later
`tree_model.cc:127` `CHECK` failure in `get_dump` (see report 01 for that message being inverted).
- **CPU (`ColumnarAdapter`)** — `HasRefCategorical()` is `!ref_cats_.Empty()` → **true** → `Recode` →
`BasicChecks` compares `orig_enc.Size()` (N) with `new_enc.Size()` (0) and aborts with
`cat_container.h:29: New and old encoding should have the same number of columns.`

Same input, same defect, two incompatible outcomes — neither of which is "use the `feature_types` the
caller passed".

## Reproduction (CPU, no GPU required)

```python
import json, numpy as np, pandas as pd, xgboost as xgb
from xgboost.sklearn import get_model_categories
from xgboost._data_utils import Categories

names, ftypes = ["cat_a", "num_b", "cat_c"], ["c", "q", "c"]
rng = np.random.default_rng(0)
pdf = pd.DataFrame({
"cat_a": rng.integers(0, 12, 3000).astype("float32"),
"num_b": rng.normal(size=3000).astype("float32"),
"cat_c": rng.integers(0, 7, 3000).astype("float32"),
})
y = (pdf.cat_a < 4).to_numpy().astype("float32")
x = pdf.to_numpy(dtype="float32")

dm = xgb.QuantileDMatrix(x, label=y, feature_names=names, feature_types=ftypes, enable_categorical=True)
base = xgb.train({"objective": "binary:logistic", "tree_method": "hist"}, dm, num_boost_round=25)

# What a cuDF/GPU run serializes: N columns, zero categories. (A numpy run writes {"enc": [], ...}.)
raw = json.loads(base.save_raw("json").decode())
raw["learner"]["gradient_booster"]["model"]["cats"] = {
"enc": [{"offsets": [], "values": []} for _ in names],
"feature_segments": [0] * (len(names) + 1),
"sorted_idx": [],
}
b = xgb.Booster(); b.load_model(bytearray(json.dumps(raw), "utf-8"))

print(b.get_categories().empty()) # False <- but it holds no categories
print(type(get_model_categories(pdf, b, ftypes)[1]).__name__) # Categories <- ftypes discarded
print(get_model_categories(x, b, ftypes)[1]) # ['c','q','c'] <- numpy escapes

_, hijacked = get_model_categories(pdf, b, ftypes)
xgb.QuantileDMatrix(pdf, label=y, feature_names=names, feature_types=hijacked, enable_categorical=True)
# XGBoostError: cat_container.h:29: New and old encoding should have the same number of columns.
```

On a cuDF frame the last call does **not** raise; it builds an all-numeric matrix, and
`xgb.dask.train(..., xgb_model=b)` then appends numeric-split trees to a categorical base. We hit this
in production: 2000 warm-start rounds over 26 `"c"` slots produced 1748 numeric splits and **zero**
categorical splits, in a model whose base had 14088 categorical split nodes and no numeric ones.

## Suggested fixes (any one of these breaks the chain)

1. **Make "empty" mean "holds no categories."** `CatContainer::Empty()` returning
`columns.empty() || n_total_cats == 0` would make `get_model_categories` fall through to the
caller's `feature_types`, which is the correct behaviour here. This also aligns
`ColumnarAdapter::HasRefCategorical()` with `CudfAdapter::HasRefCategorical()`.
2. **Do not allocate a per-column container when there are no categories** — whatever path produces
the allocated-but-valueless state should serialize `{"enc": [], "feature_segments": [], "sorted_idx": []}`
instead, as the CPU paths already do. (Conditional on the open question below; we cannot name the
producing code path.)
3. **Do not discard `feature_types` for nothing.** In `get_model_categories`, fall back to the
caller's `feature_types` when `categories` carries no actual categories, rather than overriding
with an object that carries none.
4. Independently: reconcile `ColumnarAdapter::HasRefCategorical()` and
`CudfAdapter::HasRefCategorical()`. Whatever the intended semantics, a reference container that
hard-errors on CPU and is silently ignored on GPU is a bug on one side or the other.

Fix 3 alone leaves fix 4's CPU/GPU divergence reachable by other routes; fix 1 or 2 is the durable one.

## Open question

Which code path allocates a per-column `CatContainer` with zero categories? We observe it in a
booster produced by `xgboost.dask.train` over cuDF frames of `float32` code columns with
`feature_types=["c", …]`, on xgboost 3.2.0. We could not reproduce the allocation from any CPU input
(see the note in the repro), so we cannot point at a specific line, and fix 2 above is contingent on
this. Fixes 1, 3 and 4 stand regardless — they are about what happens once such a container exists,
which is demonstrated above.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with xgboost/sklearn.py:get_model_categories and trace the category checks through src/data/cat_container.cc, src/encoder/ordinal.h, src/data/adapter.h, and src/data/device_adapter.cuh. Run the CPU reproduction in the issue, then compare it with the described cuDF path. Done means valueless category containers preserve caller feature_types and CPU/GPU handling no longer diverges.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, python
Domain
distributed-systems, machine-learning
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.