NVIDIA Structured Data Models 实测:零训练表格预测与多个默认行为陷阱
作者用可运行代码实测 NVIDIA Structured Data Models,通过同一 PyTorch 接口调用 TabICLv2、KumoTabular 和 KumoRelational,在不训练的情况下完成表格预测。
MarkTechPost· Sana Hassan·2026-10-18 13:56· 2 小时前AI 评分69
如何使用 NVIDIA Structured Data Models 进行零训练表格预测
AI 导读
作者用可运行代码实测 NVIDIA Structured Data Models 库,将 TabICLv2、KumoTabular 和 KumoRelational 等预训练模型通过同一 PyTorch 接口做零训练表格预测。
正文 · 原文
在本教程中,我们实现 Structured Data Models,这是 NVIDIA 的新库,将多个面向表格的预训练基础模型统一在一个 PyTorch 接口之后,并用它进行无需训练的预测。该库封装了用于单表的 TabICLv2 和 KumoTabular,以及用于整个关系数据库的 KumoRelational,它们都通过上下文学习来作答:我们向模型提供一组带标签的行以及我们希望预测的行,模型在一次前向传播中返回概率或完整的预测分布。我们从该库的类型化表格张量开始,将两个表格模型与训练过的 scikit-learn 基线进行比较,在多次查询中复用已编码的上下文,从回归中读取校准后的预测区间,并扩展到二十个类别。然后我们来看默认设置悄悄出错的地方:一个会丢弃时间戳和文本列的预处理配方,以及一个包含两个陷阱的解释器,之后构建一个小型三表商店数据库并直接从中预测客户活跃度,包括那个区分诚实关系模型与泄漏模型的关键参数。查看完整代码。
import os
import sys
import time
import textwrap
import traceback
import subprocess
RESULTS = {}
def banner(title):
print("\n" + "=" * 78)
print(title)
print("=" * 78)
def section(name):
def wrap(fn):
def run(*a, **kw):
banner(name)
try:
out = fn(*a, **kw)
RESULTS[name] = out if isinstance(out, str) else "ok"
return out
except Exception as e:
RESULTS[name] = f"SKIPPED / FAILED -> {type(e).__name__}: {e}"
print(f"\n[!] {name} did not complete: {type(e).__name__}: {e}")
traceback.print_exc(limit=3)
return None
return run
return wrap
banner("1. Install SDM, and the one wheel URL its own error message gets wrong")
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "git+https://github.com/NVIDIA/structured-data-models.git@b982fbf4188ea8db17249fe81338723106100b63"], check=True)
import torch
# The relational sampler needs pyg-lib on CPU. Its wheels are published per MINOR torch version
# (torch-2.14.0+cpu.html), but SDM's own ImportError builds the URL from the exact patch version
# (torch-2.14.1+cpu.html), which is an empty page. Build it from MAJOR.MINOR.0 instead.
major, minor = torch.__version__.split("+")[0].split(".")[:2]
flavour = "cpu" if torch.version.cuda is None else "cu" + torch.version.cuda.replace(".", "")
PYG_INDEX = f"https://data.pyg.org/whl/torch-{major}.{minor}.0+{flavour}.html"
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "pyg-lib", "-f", PYG_INDEX], check=True)
import numpy as np
import pandas as pd
import pyg_lib
import sdm
import sdm.processing as sp
from sklearn.datasets import load_breast_cancer, load_diabetes, make_classification
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
from sklearn.linear_model import LogisticRegression, RidgeCV
from sklearn.metrics import roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
DEVICE = "cpu" # verified end to end on CPU; on a T4 runtime set "cuda" for the tabular models
print(f" sdm {sdm.__version__} | torch {torch.__version__} | pyg-lib {pyg_lib.__version__}"
f" | device {DEVICE}")
print(f" pyg-lib wheels from {PYG_INDEX}")
print("\n One interface over several pretrained structured-data foundation models:")
for name, what, weights in [
("TabICLv2", "tabular, in-context learning (ICML '26)", "~110 MB per task head, BSD-3"),
("KumoTabular", "tabular, small / medium / large", "small ~110 MB, large ~855 MB, OpenMDW"),
("KumoRelational", "multi-table, KumoRFM-2", "~120 MB, OpenMDW"),
("TabFM, TimesFM 3", "Google tabular / time series", "6.5 GB / 1.3 GB, non-commercial: not used here"),
]:
print(f" {name:18s} {what:42s} {weights}")
print("\n No model below is trained or fine-tuned. Every prediction is a forward pass that reads")
print(" labelled 'context' rows and answers 'query' rows, like a prompt with examples in it.")
我们从 GitHub 仓库安装 Structured Data Models,并固定到本 notebook 验证时所对应的提交,因为 PyPI 上的包只是一个占位符。关系采样器还需要 pyg-lib,而第一个陷阱就在这里。pyg-lib 的 wheel 按 PyTorch 次版本发布,页面名称类似 torch-2.14.0+cpu,但该库自身的 ImportError 会用精确的补丁版本构建 URL,即 torch-2.14.1+cpu,这是一个空页面,因此按照它的建议操作会失败。我们改为根据主版本和次版本来构建索引 URL。我们在 CPU 上运行所有内容,这是此处经过端到端验证的路径,并打印该库暴露的模型及其检查点大小和许可证;它另外封装的两个 Google 模型 TabFM 和 TimesFM 3 体积大且非商用,因此未包含在内。查看完整代码。
@section("2. Typed tables: what infer_stypes decides, and what a round trip changes")
def typed_tables():
rng = np.random.default_rng(0)
words = ["late", "refund", "great", "broken", "fast", "charged", "app", "order", "support", "item"]
df = pd.DataFrame({
"ticket_id": np.arange(400),
"amount": rng.gamma(2, 30, 400).round(2),
"units": pd.array(rng.integers(1, 5, 400), dtype="Int64"),
"channel": rng.choice(["web", "app", "phone", None], 400),
"opened_at": pd.Timestamp("2026-01-01") + pd.to_timedelta(rng.integers(0, 86_400 * 90, 400), unit="s"),
"comment": [" ".join(rng.choice(words, rng.integers(3, 9))) + f" #{i}" for i in range(400)],
})
df.loc[rng.choice(400, 25, replace=False), "units"] = pd.NA
print(" infer_stypes(df) with the defaults, then asking it to look for text and ids:")
for kw in ({}, {"text": "infer", "id": "infer"}):
stypes = sdm.infer_stypes(df, **kw)
print(f" {str(kw or 'defaults'):34s} {', '.join(f'{c}={s}' for c, s in stypes.items())}")
print(" With the defaults the free-text comment becomes a 400-level categorical and the id column a")
print(" number. Both would reach the model as features. Override, or ask: text='infer', id='infer'.")
table = sdm.TableTensor.from_pandas(df, sdm.infer_stypes(df, text="infer", id="infer"))
print("\n" + textwrap.indent(repr(table), " "))
back = table.to_pandas()
print("\n to_pandas() round trip:")
for col in df.columns:
a, b = df[col], back[col]
if col in ("amount", "units"):
equal_values = bool(np.allclose(a.astype(float), b.astype(float), equal_nan=True))
else:
na_a, na_b = a.isna().to_numpy(), b.isna().to_numpy()
equal_values = bool((na_a == na_b).all() and
(a[~na_a].astype(str).to_numpy() == b[~na_b].astype(str).to_numpy()).all())
print(f" {col:10s} {str(a.dtype):16s} -> {str(b.dtype):16s} values preserved: {equal_values}")
print(" Values survive, including the missing ones; dtypes do not always. Numbers come back float32")
print(" (the numerical block is one float tensor), nullable Int64 becomes float with NaN, and strings")
print(" marked categorical come back as a pandas category.")
base = pd.to_datetime(["2026-01-01 08:00:00"] * 3).as_unit("ns")
whole = pd.DataFrame({"t": base})
nanos = pd.DataFrame({"t": base + pd.to_timedelta([0, 0, 1], unit="ns")})
print(f"\n datetime64[ns], whole seconds: {sdm.TableTensor.from_pandas(whole, {'t': 'datetime'}).size()} OK")
try:
sdm.TableTensor.from_pandas(nanos, {"t": "datetime"})
except Exception as e:
print(f" same column, one value 1 ns later: {type(e).__name__}: {str(e).split(':')[0]}")
ok = sdm.TableTensor.from_pandas(nanos.assign(t=nanos.t.dt.floor("us")), {"t": "datetime"})
print(f" floor('us') first: {ok.size()} OK")
print(" SDM stores datetimes as int64 microseconds and Arrow refuses any cast that drops a digit. The")
print(" unit is not the problem; a single value with nanosecond digits fails the whole column.")
return "6 columns -> 5 typed blocks; text and id need opting in"
typed_tables()
库中的所有内容都流经 TableTensor,这是一个 PyTorch 张量子类,它为每种语义类型保留一个带类型的块:数值、分类、日期时间、文本和 id。infer_stypes 决定类型,而在一个真实的客服工单数据框上,它的默认设置会弄错两件事:自由文本评论变成了有四百个水平的分类变量,工单 id 变成了数字,因此两者都会作为特征进入模型。传入 text 和 id 推断标志即可修复。往返回 pandas 会保留每个值,包括缺失值,但不是每种 dtype:数字以 float32 返回,因为数值块是单个浮点张量,可空整数变成带 NaN 的浮点数,分类字符串以 pandas category 返回。在 pandas 2.x 上,字符串列打印为 object 而不是 str,时间戳以纳秒分辨率进入;无论哪种方式,相同的值都会返回。在加载真实事件日志之前,还有一个陷阱值得了解:该库将日期时间存储为整数微秒,而 Arrow 拒绝任何会丢失一位数字的转换。整秒的纳秒列可以正常转换,但单个带纳秒数字的值会使整列失败,因此我们先将时间戳向下取整到微秒。在此查看完整代码。
def as_table(df, target):
return sdm.TableTensor.from_pandas(df, sdm.infer_stypes(df, overrides={target: "categorical"}), device=DEVICE)
@section("3. Zero-training classification: two foundation models vs. two trained baselines")
def zero_training():
df = load_breast_cancer(as_frame=True).frame.sample(frac=1, random_state=0).reset_index(drop=True)
table = as_table(df, "target")
n_ctx = 300
x_ctx, y_ctx = table[:n_ctx].drop_columns("target"), table[:n_ctx, "target"]
x_q, y_q = table[n_ctx:].drop_columns("target"), df.target.to_numpy()[n_ctx:]
rows = []
for name, make in [("TabICLv2", lambda: sdm.models.TabICLv2(device=DEVICE)),
("KumoTabular (small)", lambda: sdm.models.KumoTabular(size="small", device=DEVICE))]:
model = make()
torch.manual_seed(0)
proba = model(x_context=x_ctx, y_context=y_ctx, x_query=x_q, num_estimators=4).to_pandas()
rows.append((name, "none", proba.idxmax(axis=1).astype(int).to_numpy(), proba["1"].to_numpy()))
X, y = df.drop(columns="target"), df.target
for name, est in [("LogisticRegression", make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000))),
("HistGradientBoosting", HistGradientBoostingClassifier(random_state=0))]:
est.fit(X[:n_ctx], y[:n_ctx])
rows.append((name, "fit on 300 rows", est.predict(X[n_ctx:]), est.predict_proba(X[n_ctx:])[:, 1]))
print(f" breast cancer: {n_ctx} labelled rows, {len(y_q)} to predict, 30 numeric features\n")
print(f" {'model':22s} {'training':16s} {'accuracy':>9s} {'AUC':>7s}")
for name, training, pred, score in rows:
print(f" {name:22s} {training:16s} {np.mean(pred == y_q):9.3f} {roc_auc_score(y_q, score):7.3f}")
print("\n The output is itself a TableTensor: one probability column per class, named by label.")
best = max(rows[:2], key=lambda r: np.mean(r[2] == y_q))
return f"{best[0]} {np.mean(best[2] == y_q):.3f} accuracy with no training"
zero_training()
表格模型接收带标签的上下文行和不带标签的查询行,并返回一个 TableTensor,每个类别一列概率,以标签命名,因此没有拟合步骤,也没有超参数。在乳腺癌数据集上,有三百个带标签的行和二百六十九个待预测行,TabICLv2 和 KumoTabular 在无训练的情况下达到 0.970 的准确率以及 0.996 和 0.995 的 AUC,而逻辑回归为 0.970 和 0.993,梯度提升为 0.967 和 0.992,两者都在同样的三百行上拟合。num_estimators 设置模型对多少种不同预处理的数据视图进行平均,这是该库内置的集成。
@section("4. fit() once, predict() many: the cached context")
def kv_cache():
df = load_breast_cancer(as_frame=True).frame.sample(frac=1, random_state=0).reset_index(drop=True)
table = as_table(df, "target")
x_ctx, y_ctx = table[:400].drop_columns("target"), table[:400, "target"]
batches = [table[400 + 30 * i: 430 + 30 * i].drop_columns("target") for i in range(5)]
model = sdm.models.TabICLv2(device=DEVICE)
torch.manual_seed(0)
t0 = time.time()
for b in batches:
model(x_context=x_ctx, y_context=y_ctx, x_query=b, num_estimators=4)
t_forward = time.time() - t0
torch.manual_seed(0)
t0 = time.time()
model.fit(x=x_ctx, y=y_ctx, num_estimators=4)
t_fit = time.time() - t0
t0 = time.time()
for b in batches:
model.predict(b)
t_predict = time.time() - t0
model.clear()
print(" five batches of 30 query rows against the same 400 context rows:")
print(f" forward() x5 {t_forward:6.2f}s (context re-encoded every call)")
print(f" fit() once + predict() x5 {t_fit + t_predict:6.2f}s (fit {t_fit:.2f}s, predicts {t_predict:.2f}s)")
print(f" per batch: {t_forward / 5:.2f}s vs {t_predict / 5:.2f}s")
print("\n fit() encodes the context once into a key/value cache; predict() only runs the queries.")
print(" clear() frees it. Nothing is learned: fit() here means 'remember these examples'.")
return f"{t_forward / (t_fit + t_predict):.1f}x faster for five batches"
kv_cache()
直接调用模型会在每次调用时重新编码上下文。当相同的带标签行要回答多批查询时,fit 将上下文编码一次到键和值缓存中,然后 predict 只运行查询行;clear 释放缓存。fit 中不学习任何内容,这里的含义是记住这些示例。在我们的 CPU 上,针对相同的四百个上下文行回答五批三十行,这种方式完成速度快了好几倍,而且一旦上下文被缓存,每增加一批的成本只是完整前向传播的一小部分。确切比例取决于机器,这就是为什么该单元测量它而不是假设它。
@section("5. Regression returns a distribution: 999 quantiles, and how honest they are")
def quantile_regression():
df = load_diabetes(as_frame=True).frame.sample(frac=1, random_state=0).reset_index(drop=True)
table = sdm.TableTensor.from_pandas(df, sdm.infer_stypes(df), device=DEVICE)
model = sdm.models.TabICLv2(device=DEVICE)
torch.manual_seed(0)
q = model(x_context=table[:300].drop_columns("target"), y_context=table[:300, "target"],
x_query=table[300:].drop_columns("target"), num_estimators=4).to_pandas()
y = df.target.to_numpy()[300:]
print(f" output: {q.shape[1]} columns {list(q.columns[:3])} ... {list(q.columns[-2:])} per query row")
X, Y = df.drop(columns="target"), df.target
rmse = lambda p: float(np.sqrt(np.mean((np.asarray(p) - y) ** 2)))
print(f"\n {'point prediction':34s} {'RMSE':>7s}")
print(f" {'TabICLv2 median (q500), no training':34s} {rmse(q['q500']):7.2f}")
print(f" {'RidgeCV, fit on 300 rows':34s} {rmse(RidgeCV().fit(X[:300], Y[:300]).predict(X[300:])):7.2f}")
print(f" {'HistGradientBoosting, fit on 300':34s} "
f"{rmse(HistGradientBoostingRegressor(random_state=0).fit(X[:300], Y[:300]).predict(X[300:])):7.2f}")
print(f"\n {'interval':12s} {'nominal':>8s} {'empirical coverage':>19s} {'mean width':>11s}")
coverage = {}
for lo, hi in [("q250", "q750"), ("q100", "q900"), ("q050", "q950")]:
nominal = (int(hi[1:]) - int(lo[1:])) / 1000
coverage[nominal] = float(np.mean((y >= q[lo]) & (y <= q[hi])))
print(f" {lo}-{hi:6s} {nominal:8.0%} {coverage[nominal]:19.3f} {np.mean(q[hi] - q[lo]):11.1f}")
print("\n The quantiles come from one forward pass over 300 examples, yet the 80% and 90% intervals")
print(" cover close to 80% and 90% of held-out targets. The 50% band runs a little narrow.")
return f"median RMSE {rmse(q['q500']):.1f}; 80% interval covers {coverage[0.8]:.1%}"
quantile_regression()
对于回归任务,模型不会返回单个数值。它针对每个查询行返回 999 个分位数,即通过一次前向传播得到的完整预测分布。在包含三百行带标签数据的糖尿病数据集上,该分布的中位数 RMSE 为 53.6,略优于 RidgeCV 的 54.3,并明显优于梯度提升的 64.7,后两者均在同一批数据行上训练。更有用的特性是校准:第 10 到第 90 百分位之间的区间覆盖了 81.0% 的留出目标,第 5 到第 95 百分位区间覆盖了 91.5%,接近其标称的 80% 和 90%,而 50% 区间则略窄,为 43.7%。
@section("6. Twenty classes from one forward pass")
def many_classes():
X, y = make_classification(n_samples=800, n_features=12, n_informative=8, n_redundant=2, n_classes=20,
n_clusters_per_class=1, class_sep=1.5, random_state=0)
df = pd.DataFrame(X, columns=[f"f{i}" for i in range(12)])
df["label"] = [f"c{v:02d}" for v in y]
table = as_table(df, "label")
model = sdm.models.TabICLv2(device=DEVICE)
torch.manual_seed(0)
t0 = time.time()
proba = model(x_context=table[:600].drop_columns("label"), y_context=table[:600, "label"],
x_query=table[600:].drop_columns("label"), num_estimators=1).to_pandas()
secs = time.time() - t0
acc = float(np.mean(proba.idxmax(axis=1).to_numpy() == df.label.to_numpy()[600:]))
hgb = HistGradientBoostingClassifier(random_state=0).fit(X[:600], y[:600])
acc_hgb = float(np.mean(hgb.predict(X[600:]) == y[600:]))
print(f" 20 balanced classes, 600 labelled rows (30 per class), 200 queries")
print(f" TabICLv2, no training {proba.shape[1]} probability columns, accuracy {acc:.3f} ({secs:.1f}s)")
print(f" HistGradientBoosting, trained accuracy {acc_hgb:.3f}")
print("\n The pretrained head has a fixed number of class slots. Past that, SDM splits the problem")
print(" into error-correcting output codes (sdm.models.ecoc) and recombines the log-probabilities,")
print(" so the caller still gets one column per label without doing anything.")
return f"20 classes: {acc:.3f} vs {acc_hgb:.3f} for gradient boosting"
many_classes()
预训练分类头具有固定数量的类别槽位,库通过纠错输出码来处理类别数超过该槽位的问题,将问题拆分为更小的编码子问题并重新组合对数概率。调用方不会看到这一过程:在一个平衡的二十类合成问题上,每类有三十行带标签数据,TabICLv2 返回二十个概率列,并在单次前向传播中达到 0.760 的准确率,而梯度提升在同一批数据行上训练后为 0.615。
def make_tickets(n=1600, seed=1):
"""Support tickets whose escalation depends on WHEN they were opened and WHAT they say."""
rng = np.random.default_rng(seed)
opened = pd.Timestamp("2026-03-02") + pd.to_timedelta(rng.integers(0, 86_400 * 56, n), unit="s")
neutral = ["order", "item", "delivery", "account", "app", "invoice", "question", "help", "update", "status"]
hot = ["refund", "broken", "charged", "twice"]
comments, has_hot = [], []
for _ in range(n):
words = list(rng.choice(neutral, rng.integers(4, 9)))
flag = rng.random() < 0.3
if flag:
words.insert(rng.integers(len(words)), rng.choice(hot))
comments.append(" ".join(words))
has_hot.append(flag)
hour, weekend = np.asarray(opened.hour), np.asarray(opened.dayofweek >= 5)
amount = rng.gamma(2, 40, n).round(2)
logit = (-1.6 + 2.2 * np.array(has_hot) + 1.4 * ((hour >= 22) | (hour < 6)) + 1.0 * weekend
+ 0.004 * (amount - 80))
escalated = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
return pd.DataFrame({"amount": amount, "channel": rng.choice(["web", "app", "phone"], n),
"opened_at": opened, "comment": comments, "escalated": escalated})
@section("7. Recipes: the default one drops your timestamps and your text, and says so once")
def recipes():
df = make_tickets()
stypes = sdm.infer_stypes(df, overrides={"escalated": "categorical"}, text="infer")
table = sdm.TableTensor.from_pandas(df, stypes, device=DEVICE)
x_ctx, y_ctx = table[:1000].drop_columns("escalated"), table[:1000, "escalated"]
x_q, y_q = table[1000:].drop_columns("escalated"), df.escalated.to_numpy()[1000:]
print(f" columns: {', '.join(f'{c}={s}' for c, s in stypes.items())}")
print(" escalation is driven by night-time / weekend opening and by words like 'refund'\n")
default = sdm.models.TabICLv2.default_recipe()
with_calendar = sdm.models.TabICLv2.default_recipe()
with_calendar.prepend_features(sp.StypeDispatch(
datetime=sp.AddCalendarFields(["hour", "weekday"], encoding="cyclic")))
calendar_and_text = sp.StypeDispatch(
datetime=sp.AddCalendarFields(["hour", "weekday"], encoding="cyclic"),
text=sp.TFIDF(ngram_range=(1, 1), max_features=64))
with_both = sdm.models.TabICLv2.default_recipe()
with_both.prepend_features(calendar_and_text)
model = sdm.models.TabICLv2(device=DEVICE)
aucs = {}
for label, recipe in [("default recipe", default),
("+ AddCalendarFields(hour, weekday)", with_calendar),
("+ AddCalendarFields + TFIDF(64 terms)", with_both)]:
torch.manual_seed(0)
proba = model(x_context=x_ctx, y_context=y_ctx, x_query=x_q, num_estimators=4, recipe=recipe).to_pandas()
aucs[label] = roc_auc_score(y_q, proba["1"])
print(f" {label:40s} AUC {aucs[label]:.3f}")
print("\n The default TabICLv2 recipe dispatches on stype and only has branches for categorical and")
print(" numerical columns, so datetime and text columns never become features. SDM does say so, in")
print(" the RuntimeWarning above, but it is a warn-once: it fires the first time and is suppressed for")
print(" the rest of the process. Every later model in the same notebook or service drops those columns")
print(" in silence. The fix is two processors, prepended to the features:")
print(textwrap.indent(repr(calendar_and_text), " "))
return " -> ".join(f"{v:.3f}" for v in aucs.values()) + " AUC"
recipes()
库中的预处理是一个由可组合处理器构成的 Recipe,默认的 TabICLv2 recipe 根据语义类型进行分派,仅对类别列和数值列设有分支。因此,时间戳列和文本列永远不会成为特征。在升级取决于工单开启时间以及诸如 refund 等词语的支持工单上,默认 recipe 的 AUC 得分为 0.525,接近随机水平。库确实会发出一个 RuntimeWarning,指明被忽略的列类型。然而,这是一个仅警告一次:它在第一次触发后,在进程的剩余时间内被抑制,因此同一 notebook 或服务中后续的每个模型都会静默丢弃这些列。在前面添加两个处理器即可修复:AddCalendarFields 将时间戳转换为循环的小时和星期特征,将 AUC 提升至 0.634,而 TFIDF 添加评论中的词项,将其提升至 0.789。
@section("8. Explaining a prediction: two traps inside the explainer")
def explain():
rng = np.random.default_rng(2)
X = rng.normal(size=(500, 8))
true = {"x0": 2.0, "x3": -1.5, "x5": 1.0}
logit = sum(w * X[:, int(c[1:])] for c, w in true.items())
df = pd.DataFrame(X, columns=[f"x{i}" for i in range(8)])
df["y"] = (rng.random(500) < 1 / (1 + np.exp(-logit))).astype(int)
table = as_table(df, "y")
model = sdm.models.TabICLv2(device=DEVICE)
ctx = dict(x_context=table[:400].drop_columns("y"), y_context=table[:400, "y"])
x_q = table[400:].drop_columns("y")
p1 = lambda pred: pred.numerical[..., pred.columns[sdm.Stype.numerical].index("1")] # P(y=1), by NAME
seen = []
def peek(pred):
seen.append(pred.columns[sdm.Stype.numerical])
return pred.numerical.sum() * 0
torch.manual_seed(0)
sdm.explain.GradientExplainer(output=peek).explain(model, x_q, num_estimators=4, **ctx)
print(f" num_estimators=4: the output function is called {len(seen)} times, class order per call:")
print(f" {seen}")
def only_call(n):
calls = {"i": 0}
def f(pred):
calls["i"] += 1
return p1(pred) if calls["i"] == n else p1(pred) * 0
return f
sizes = {}
for n in (1, len(seen)):
torch.manual_seed(0)
g = sdm.explain.GradientExplainer(output=only_call(n)).explain(model, x_q, num_estimators=4, **ctx)
sizes[n] = float(g.x.to_pandas().abs().mean().mean())
print(f"\n Trap 1 - only the LAST estimator is explained. Output function live only on call 1:"
f" mean |grad| {sizes[1]:.4f};")
print(f" live only on call {len(seen)}: {sizes[len(seen)]:.4f}. Each call overwrites the previous result, so")
print(" with several estimators you get one of them, not their average. Explain with num_estimators=1.")
print("\n Trap 2 - class order differs per estimator. num_estimators=1, six seeds, P(y=1) selected two ways:")
print(f" {'seed':>6s} {'order':>12s} " + "".join(f"{c + f' ({true[c]:+.1f})':>20s}" for c in true))
print(f" {'':6s} {'':12s} " + "".join(f"{'position / name':>20s}" for _ in true))
by_name = []
flipped = name_ok = 0
for seed in range(6):
order = []
def note(pred):
order.append(pred.columns[sdm.Stype.numerical])
return pred.numerical.sum() * 0
torch.manual_seed(seed)
sdm.explain.GradientExplainer(output=note).explain(model, x_q, num_estimators=1, **ctx)
res = {}
for label, fn in (("position", lambda pred: pred.numerical[..., 1]), ("name", p1)):
torch.manual_seed(seed)
res[label] = sdm.explain.GradientExplainer(output=fn).explain(
model, x_q, num_estimators=1, **ctx).x.to_pandas().mean()
by_name.append(res["name"])
flipped += any(np.sign(res["position"][c]) != np.sign(w) for c, w in true.items())
name_ok += all(np.sign(res["name"][c]) == np.sign(w) for c, w in true.items())
print(f" {seed:>6d} {str(order[0]):>12s} " +
"".join(f"{res['position'][c]:>10.2f} /{res['name'][c]:>7.2f}" for c in true))
avg = pd.concat(by_name, axis=1).mean(axis=1)
noise = float(avg.drop(list(true)).abs().max())
print(f"\n When the shuffle puts class '1' first, column 1 is P(y=0) and every sign flips. Selecting by")
print(f" name gives the right sign on {name_ok}/6 seeds. Averaged over the seeds, by name: " +
", ".join(f"{c} {avg[c]:+.2f}" for c in true) + f"; largest noise feature {noise:.2f}.")
print(" Gradients are with respect to the PREPROCESSED, i.e. standardized, inputs.")
return f"position flipped signs on {flipped}/6 seeds; name correct on {name_ok}/6; last estimator only"
explain()
库的 GradientExplainer 返回所选输出相对于预处理后查询输入的梯度,我们在合成数据上测试它,其中八个特征中只有三个重要,且符号已知。它有两个陷阱,只有通过查看输出函数接收到的内容才能发现。第一,该函数对每个估计器调用一次,每次调用都会覆盖前一次的结果,因此对于四个估计器,一个仅在第一次调用时有效的输出函数会产生全零解释,而你得到的解释只是最后一个估计器的。第二,每个估计器的原始输出都有自己的类别顺序,因为默认 recipe 会为每个估计器打乱类别标签。在六个种子上使用单个估计器进行解释时,按列位置选择类别一的概率会在顺序反转的两个种子上翻转每个特征的符号,而按列名选择则在所有六个种子上给出正确的符号,并且对种子取平均可以恢复三个真实特征,噪声特征接近零。
def make_shop(seed=0, n_customers=800, n_products=60):
"""Customers, products and a year of orders. Some customers' activity collapses at a random date."""
rng = np.random.default_rng(seed)
plans = rng.choice(["basic", "pro"], n_customers, p=[0.7, 0.3])
customers = pd.DataFrame({"customer_id": np.arange(n_customers),
"region": rng.choice(["NA", "EU", "APAC", "LATAM"], n_customers), "plan": plans})
products = pd.DataFrame({"product_id": np.arange(n_products),
"category": rng.choice(["books", "tech", "home", "toys"], n_products),
"price": rng.gamma(2.0, 20.0, n_products).round(2)})
weekly_rate = rng.gamma(1.5, 0.6, n_customers) * np.where(plans == "pro", 1.6, 1.0)
fade_day = rng.uniform(60, 500, n_customers)
rows, start = [], pd.Timestamp("2025-01-01")
for c in range(n_customers):
t = 0.0
while True:
rate = weekly_rate[c] / 7 * (1.0 if t < fade_day[c] else 0.08)
t += rng.exponential(1 / max(rate, 1e-6))
if t > 364:
break
p = int(rng.integers(n_products))
rows.append((c, p, start + pd.Timedelta(float(t), unit="D"), float(products.price[p] * rng.integers(1, 4))))
orders = pd.DataFrame(rows, columns=["customer_id", "product_id", "order_date", "amount"])
orders.insert(0, "order_id", np.arange(len(orders)))
orders["order_date"] = orders["order_date"].dt.floor("s")
return customers, products, orders
def task_rows(customers, orders, cutoff, horizon_days=30):
"""Label: does the customer order again within 30 days of the cutoff?"""
window = orders[(orders.order_date > cutoff) & (orders.order_date <= cutoff + pd.Timedelta(horizon_days, unit="D"))]
active = set(window.customer_id)
return pd.DataFrame({"customer_id": customers.customer_id, "timestamp": cutoff,
"label": [int(c in active) for c in customers.customer_id]})
@section("9. A relational database as input: tables, keys, and a time-aware sampler")
def relational_setup():
customers, products, orders = make_shop()
data = sdm.RelationalData(
tables={
"customers": sdm.TableTensor.from_pandas(customers, {"customer_id": "id", "region": "categorical",
"plan": "categorical"}),
"products": sdm.TableTensor.from_pandas(products, {"product_id": "id", "category": "categorical",
"price": "numerical"}),
"orders": sdm.TableTensor.from_pandas(orders, {"order_id": "id", "customer_id": "id", "product_id": "id",
"order_date": "datetime", "amount": "numerical"}),
},
relationships=[
dict(left_table="orders", left_column="customer_id", right_table="customers", right_column="customer_id"),
dict(left_table="orders", left_column="product_id", right_table="products", right_column="product_id"),
],
)
cutoffs_ctx = [pd.Timestamp(d) for d in ("2025-06-01", "2025-07-01", "2025-08-01")]
cutoff_q = pd.Timestamp("2025-09-15")
task_df = pd.concat([task_rows(customers, orders, c) for c in cutoffs_ctx + [cutoff_q]], ignore_index=True)
task = sdm.TableTensor.from_pandas(task_df, {"customer_id": "id", "timestamp": "datetime", "label": "categorical"})
n_ctx = len(cutoffs_ctx) * len(customers)
link = dict(task_link={"task_column": "customer_id", "table": "customers", "table_column": "customer_id"},
num_neighbors=[16, 16], task_time_column="timestamp")
print(f" {len(customers)} customers, {len(products)} products, {len(orders):,} orders over 2025")
print(" relationships: orders.customer_id -> customers, orders.product_id -> products")
print(f" task: will the customer order in the 30 days after a cutoff? positive rate {task_df.label.mean():.2f}")
print(f" context: cutoffs {[str(c.date()) for c in cutoffs_ctx]} ({n_ctx:,} rows); query: {cutoff_q.date()}")
sampler = data.sampler(time_columns={"orders": "order_date"})
sample, related = sampler(task[:3].drop_columns("label"), **link)
print("\n what the sampler hands the model for the first 3 task rows (16 neighbours, then 16 more):")
for name, tbl in related.tables.items():
print(f" {name:10s} {tuple(tbl.size())}")
latest = related.tables["orders"].to_pandas()["order_date"].max()
print(f" latest order the sampler returned: {latest} task timestamp: {cutoffs_ctx[0]}")
print(" The sampler walks the foreign keys backwards in time: no order dated after the task row's")
print(" own timestamp is eligible, so the rows that define the label cannot leak in.")
globals().update(SHOP=dict(customers=customers, orders=orders, data=data, task=task, task_df=task_df,
n_ctx=n_ctx, link=link, cutoffs=cutoffs_ctx + [cutoff_q]))
return f"{len(orders):,} orders across 3 linked tables"
relational_setup()
KumoRelational 接收的是关系型数据库,而非单张表。我们生成一个小型商店,包含八百名客户、六十件产品和一年的订单,其中部分客户的活动在某个随机日期骤降,然后询问每位客户是否会在截止日期后三十天内再次下单。RelationalData 保存各表及其之间的外键,其采样器从每个任务行出发,沿这些键向外遍历,每跳最多收集十六个邻居。将订单日期声明为时间列后,遍历便具有时间性:我们检查采样器为日期为六月一日的任务行返回的最新订单是否下单于五月三十一日,因此定义标签的行便无法触及。
def relational_auc(time_columns):
s = SHOP
sampler = s["data"].sampler(time_columns=time_columns)
model = sdm.models.KumoRelational(device=DEVICE)
torch.manual_seed(0)
context, related = sampler(s["task"][:s["n_ctx"]], **s["link"])
model.fit(x=context.drop_columns("label"), y=context["label"], related_tables=related,
generator=torch.Generator().manual_seed(0))
query = s["task"][s["n_ctx"]:].drop_columns("label")
proba = model.predict(*sampler(query, **s["link"])).to_pandas()
return roc_auc_score(s["task_df"].label.to_numpy()[s["n_ctx"]:], proba["1"])
def history_features(customers, orders, cutoff):
"""The feature engineering a data scientist would write by hand, strictly before the cutoff."""
past = orders[orders.order_date <= cutoff]
by_customer = past.groupby("customer_id")
f = pd.DataFrame({"customer_id": customers.customer_id})
for days in (30, 90):
recent = past[past.order_date > cutoff - pd.Timedelta(days, unit="D")].groupby("customer_id")
f[f"orders_{days}d"] = f.customer_id.map(recent.size()).fillna(0)
f[f"spend_{days}d"] = f.customer_id.map(recent.amount.sum()).fillna(0)
f["days_since_last"] = f.customer_id.map((cutoff - by_customer.order_date.max()).dt.days).fillna(999)
f["orders_total"] = f.customer_id.map(by_customer.size()).fillna(0)
return f.merge(customers, on="customer_id").drop(columns="customer_id")
def flat_auc(frame_fn):
s = SHOP
X = pd.concat([frame_fn(c) for c in s["cutoffs"]], ignore_index=True)
X["label"] = s["task_df"].label.values
table = as_table(X, "label")
model = sdm.models.TabICLv2(device=DEVICE)
torch.manual_seed(0)
proba = model(x_context=table[:s["n_ctx"]].drop_columns("label"), y_context=table[:s["n_ctx"], "label"],
x_query=table[s["n_ctx"]:].drop_columns("label"), num_estimators=4).to_pandas()
return roc_auc_score(s["task_df"].label.to_numpy()[s["n_ctx"]:], proba["1"]), list(X.columns[:-1])
@section("10. KumoRelational vs. hand-built features: the same question, three ways")
def relational_vs_features():
s = SHOP
results = []
t0 = time.time()
auc, cols = flat_auc(lambda c: s["customers"].drop(columns="customer_id"))
results.append(("TabICLv2 on the customers table only", auc, f"{len(cols)} raw columns", time.time() - t0))
t0 = time.time()
auc, cols = flat_auc(lambda c: history_features(s["customers"], s["orders"], c))
results.append(("TabICLv2 on hand-engineered features", auc, f"{len(cols) - 2} engineered + 2 raw", time.time() - t0))
t0 = time.time()
results.append(("KumoRelational on the raw tables", relational_auc({"orders": "order_date"}), "3 raw tables", time.time() - t0))
print(f" {'approach':40s} {'AUC':>6s} {'inputs':22s} {'seconds':>7s}")
for label, auc, inputs, secs in results:
print(f" {label:40s} {auc:6.3f} {inputs:22s} {secs:7.1f}")
print("\n Demographics alone barely beat a coin. Purchase history is where the signal is, and the")
print(" relational model reaches it without anyone writing orders_30d, spend_90d or days_since_last:")
print(" it reads the order rows themselves through the keys, as of each task row's timestamp.")
globals()["REL_AUC"] = results[2][1]
return (f"customers only {results[0][1]:.3f} / hand-built {results[1][1]:.3f}"
f" / KumoRelational {results[2][1]:.3f} AUC")
relational_vs_features()
随后我们用三种方式回答同一个问题。仅使用客户表、只有地区和套餐可依据的 TabICLv2 达到 0.556 的 AUC。在六个工程化历史特征(最近三十天和九十天的订单与消费、距上次下单天数以及生命周期订单数,均严格在每个截止日期之前计算)上运行的 TabICLv2 达到 0.828。在原始表上运行、没有任何人编写特征的 KumoRelational 达到 0.823。该关系模型通过键读取订单行本身(截至每个任务行的时间戳),与手工构建的流水线相比仅差一个舍入误差。
@section("11. The one argument that keeps a relational model honest")
def leakage():
leaky = relational_auc(None)
print(f" KumoRelational with time_columns={{'orders': 'order_date'}} AUC {REL_AUC:.3f}")
print(f" KumoRelational with time_columns=None AUC {leaky:.3f}")
print("\n Without a time column the sampler is free to return orders placed AFTER the cutoff, which")
print(" are exactly the orders that define the label. The model is not predicting anymore, it is")
print(" reading the answer. Nothing errors and the number looks like a breakthrough. On a real")
print(" database, a relational model that is suspiciously better than your hand-built baseline")
print(" should be the first thing you re-run with every time column declared.")
return f"{REL_AUC:.3f} honest vs {leaky:.3f} with future rows visible"
leakage()
最后,我们在不声明时间列的情况下运行同一个关系模型。AUC 升至 0.940。没有时间列时,采样器可以自由返回截止日期之后下的订单,而这些订单恰恰定义了标签,因此模型不再是在预测,而是在读取答案,并且不会报任何错误。在真实数据库上,如果一个关系模型可疑地优于手工构建的基线,首先要做的就是声明所有时间列并重新运行。
banner("SUMMARY")
for name, res in RESULTS.items():
print(f" {name:<76s} {res}")
print("""
What to carry over
- Declare stypes deliberately: text and id columns are not detected unless you ask, and the
default TabICLv2 recipe drops datetime and text columns with a warning that fires once.
- In an explainer output function, select classes by name, never by position.
- Every table with a time column goes in time_columns. A relational model that is not time-aware
is a leakage detector, not a forecaster.
Where to go next
- Fine-tuning: examples/tabular/finetune.py disables the output recipe and trains on raw logits.
- Real relational benchmarks: examples/relational/rel_bench.py runs KumoRelational on RelBench.
- On a T4 runtime, set DEVICE = "cuda" for the tabular models; cudf keeps dataframe work on GPU.
- Docs: nvidia.github.io/structured-data-models Code: github.com/NVIDIA/structured-data-models
""")
摘要会打印每个部分返回的单行结果,以及三个值得沿用的习惯:有意声明语义类型和配方,在解释器内按名称选择类别并用一个估计器进行解释,以及将每个带时间列的表放入 time_columns。随后它会指向微调、RelBench 示例和 GPU 执行。
总之,NVIDIA 的 Structured Data Models 兑现了其核心承诺:在分类、回归、一个二十类问题以及一个三表数据库上,其预训练模型在未经自身训练的情况下,匹配或超越了在相同行上训练的 scikit-learn 基线,免费返回经过校准的预测分布,并且在关系型场景中,从原始表达到了手工构建特征管线的准确率。默认设置比模型本身更值得审视。自由文本和 ID 除非被要求,否则不会被检测到;默认配方会丢弃时间戳列和文本列,并且每个进程只警告一次;解释器只报告最后一个估计器,并且需要按名称选择类别;库自身错误消息中的 pyg-lib URL 指向一个空页面;而缺少时间列的关系型模型会产生一个令人印象深刻却毫无意义的数字。这些问题每一个都有一行修复方案,而每一个否则都会悄无声息地通过,这就是带入真实数据的实际教训:在信任输出之前,先检查进入模型的内容。
查看完整代码。所有功劳归于该项目的研究人员。另外,欢迎在Twitter上关注我们,别忘了加入我们的150k+ ML SubReddit并订阅我们的新闻通讯。等等!你在 Telegram 上吗?现在你也可以在 Telegram 上加入我们。
文章如何使用 NVIDIA Structured Data Models 进行零训练表格预测首次出现在MarkTechPost上。
来源:MarkTechPost · marktechpost.com
来源:RadarAI · aihot.news