MINI-TABPFN COURSE · LESSON 01

第 1 课讲义:任务表示——模型究竟在学习一行,还是学习一个任务?

这是一节可直接阅读、实现和验收的课程单元。页面正文由 canonical Markdown lesson source 投影生成。

第 01 / 08 课course-v0.5 / owner-review完整讲义 + 动手验收包
课程讲义 · 基础知识层canonical source: course/lectures/01-task-representation.md · 先建立概念、符号、公式与边界,再进入工程实现。

讲义定位

这一课是整个 Mini-TabPFN 的地基。后面的 Attention、Column Encoder、Row Token、Row-Level ICL 和 Prediction Head 都默认我们已经明确回答:模型的输入究竟是什么,它要预测的对象又是什么?

核心直觉:学的是「给定 context 如何预测 query」的程序,不是一张固定表的 \(x\to y\)。

本讲义先区分普通监督学习、task、episode、context/query 与 batch,再把它们落成可被代码、测试 共同检查的数据契约。课程受 TabPFN-3 的公开计算对象启发,但不复现官方参数规模、训练 recipe 或 benchmark 数字。

本课符号(首次落地)

符号含义
\(N_c, N_q\)context / query 的 row 数(与 \(N_{\mathrm{context}}, N_{\mathrm{query}}\) 同义)
\(P\)feature / column 数(论文图注常用 \(C\)=columns;课程用 \(P\) 以免与类别数冲突)
\(B\)episode batch
\(M_{\mathrm{context}}, M_{\mathrm{query}}\)row padding mask,\(1\)=有效

1. 学习目标与先修知识

学完本讲义后,你应该能够:

  1. 解释固定数据集泛化与未见任务泛化为什么是两个不同的问题;
  2. 用统一记号写出 context、query、episode 和 batch 的形状;
  3. 说明为什么 query label 可以用于 evaluation,却不能进入 prediction input;
  4. 写出同时支持分类和回归的 Episode 数据对象;
  5. 设计可 replay 的 task sampler,并按 task 而不是按 row 切分训练/测试;
  6. 通过 shape、dtype、label range 和 leakage tests 证明数据契约没有被破坏。

先修只需要 Python、基本概率和 PyTorch tensor。你不需要先会 Transformer。

2. 从普通监督学习开始

最熟悉的监督学习:给定 \(D_{\mathrm{train}}=\{(x_i,y_i)\}\),学习 \(\theta\),使 \(f_\theta(x)\) 在同一任务 分布的测试样本上更好。经验风险目标常写成:

\[\min_{\theta}\;\frac{1}{|D_{\mathrm{train}}|}\sum_i \mathcal{L}\!\left(f_\theta(x_i),y_i\right)\]

这里数据集通常是一个相对固定的世界:列语义、标签空间、生成规律和 train/test 划分都已给定。随机 打乱 row 再切分,测试集仍属于同一张表或同一任务。

这并不是错误;它只是和 PFN 风格学习的对象不同。

2.1 三个容易混淆的层级

层级它是什么例子
row / sample一次观测一位病人的 12 个特征与一个标签
dataset / table一组来自同一生成机制的观测一张固定医疗表
task规定「如何生成一张表以及如何预测」的完整问题一个分界方向、噪声水平、标签编码

普通监督学习常把 dataset 当作主要边界;PFN 训练则把 task 放到主要边界上。模型要学的是:给我 一个新任务的少量已标记观测,我该如何利用它预测同一任务中的未知观测?

3. Task-conditioned prediction

对一个 task,先观察带标签的 context,再给出只有特征的 query。模型输出的是条件预测分布:

\[p\!\left(Y_{\mathrm{query}}\mid X_{\mathrm{query}},X_{\mathrm{context}},Y_{\mathrm{context}}\right)\]

与论文叙述的对应:context ≈ training rows,query ≈ test rows。课程统一用 context/query,避免和 「模型预训练 / 下游微调」两套 train 词汇混在一起。

条件边界必须钉死:

  • \(X_{\mathrm{context}}, Y_{\mathrm{context}}\):prediction 时允许看到的 task evidence;
  • \(X_{\mathrm{query}}\):要回答的问题;
  • \(Y_{\mathrm{query}}\):只在 loss / evaluation 时作为答案存在;
  • \(Y_{\mathrm{query}}\) 不得进入 model.forward

因此「模型输入」不再是一行独立的 \(x\),而是一个带 context/query 关系的 episode

训练时 loss 的合法形状是:

\[\mathcal{L} =\operatorname{loss\_fn}\!\bigl( f_\theta(X_{\mathrm{context}},Y_{\mathrm{context}},X_{\mathrm{query}},\mathrm{masks}),\, Y_{\mathrm{query}}\bigr)\]

3.1 为什么 context label 是合法信息?

假设 context 中有 (x_1, 0)(x_2, 1)。这些标签告诉模型当前 task 的局部规律,例如分界面大概 在哪里。删掉 \(Y_{\mathrm{context}}\),模型只能靠 query feature 和预训练先验猜测。

这不是数据泄漏,而是任务定义的一部分:context label 是已观察到的 task evidence。真正的泄漏是把 query 的正确答案提前放进模型输入。

4. Episode、Batch 与 padding

一个最小 episode 的未 padding 接口:

\[\begin{aligned} X_{\mathrm{context}}&\in\mathbb{R}^{N_c\times P},& Y_{\mathrm{context}}&\text{与 task 类型一致(分类离散 / 回归连续)},\\ X_{\mathrm{query}}&\in\mathbb{R}^{N_q\times P},& Y_{\mathrm{query}}&\text{仅用于 evaluation,不进入 prediction input}. \end{aligned}\]

task_kindtask_seed 是 provenance / control metadata,不是 feature,不能偷塞进 \(X\)。

批处理时不同 episode 的 \(N_c, N_q\) 可能不同,需要 padding:

\[\begin{aligned} X_{\mathrm{context}}&\in\mathbb{R}^{B\times N_{c,\max}\times P},& M_{\mathrm{context}}&\in\{0,1\}^{B\times N_{c,\max}},\\ X_{\mathrm{query}}&\in\mathbb{R}^{B\times N_{q,\max}\times P},& M_{\mathrm{query}}&\in\{0,1\}^{B\times N_{q,\max}}. \end{aligned}\]

Mini 简化:第一版课程在一个 batch 内固定 \(P\),只对 rows 做 padding。官方模型还要处理可变特征数、 特征子采样与多种预处理 estimator;本课不实现。

padding value 只是占位符;真正决定位置是否参与计算的是 mask。若用 0 padding 却没有 mask,模型 无法区分「真实 feature 恰好为零」和「这里没有 row」。

4.1 四个轴必须在代码中有名字

以后看到 \([B, N, P, D]\),必须能说清楚:

B = episode batch
N = row index(或 N_c / N_q)
P = feature / column index
D = learned representation width

轴语义不清会导致危险错误:代码仍能运行,但把 column attention 写成 row attention,或把不同 episode 的 row 互相混在一起。

5. 固定数据集泛化与未见任务泛化

两个实验都可能被称为「测试集表现」,但证明的能力不同:

\[\begin{aligned} \text{固定数据集泛化:}\quad& \text{同一张表的 train rows}\to\text{test rows},\\ \text{未见任务泛化:}\quad& \text{训练过的 tasks}\to\text{全新 task 的 context/query episode}. \end{aligned}\]

若先把 100 个 task 的所有 rows 合并,再随机按 row 切分,测试 row 可能来自训练时已见过的 task。 模型即使没有真正学会「如何利用 context」,也可能依靠任务痕迹得到较好结果。

正确的 task-level split:

  1. 先采样 task latent state;
  2. 用该 state 生成 context/query;
  3. 按 task id 把完整 episode 放进 train / val / test;
  4. 确认 test task 的 latent state 没有被训练阶段复用。

5.1 一个最小反例

设每个 task 有 20 个 context row 和 5 个 query row,共 100 个 task。随机切 row 时,模型可能在训练 中已见过 task 17 的分界方向,只是没见过其中某个 row;按 task 切分时,测试才必须从 task 17 的新 context 重新推断规律。

因此:

  • 「在固定任务内预测」是较弱、合法的陈述;
  • 「学会了跨任务预测算法」需要 held-out task evidence,不能由 row-level split 单独支持。

6. Task latent state 与 sampler

Synthetic task 通常先采样不可直接输入模型的 latent state,例如:

w             线性方向
function      生成函数族
noise_scale   噪声强度
label_map     标签编码

再用 latent state 生成 feature 和 label。latent state 可以帮助解释数据来源,但不应被当成模型已 观察到的 feature;否则模型是在读取出题答案,而不是从 context 中学习。

这与 TabPFN 系「仅在合成 prior 上预训练、再在真实表上做 in-context 预测」的设定一致:参数学的是 跨任务的推断程序,单次 forward 里的适应来自读取 context。

6.1 分类任务

最小二分类:

x = torch.randn(n_context + n_query, n_features, generator=g)
w = torch.randn(n_features, generator=g)
score = x @ w
y = (score > 0).to(torch.int64)

随后可增加非线性或 label noise。每增加一个选项,都应记录在 TaskSpec 中。

6.2 回归任务

mean = x @ w + 0.4 * torch.sin(x[:, 0])
scale = 0.2 + 0.7 * x[:, 0].abs()
y = mean + scale * torch.randn(mean.shape, generator=g)

mean 是条件均值,scale 描述不确定性随输入变化。它们属于数据生成机制,不是可供模型直接读取的 额外输入。

7. 代码契约:数据对象先于模型

@dataclass(frozen=True)
class Episode:
    x_context: Tensor   # [N_c, P]
    y_context: Tensor   # [N_c]
    x_query: Tensor     # [N_q, P]
    y_query: Tensor     # [N_q], evaluation only
    task_kind: Literal["classification", "regression"]
    task_seed: int

    def assert_contract(self):
        assert self.x_context.ndim == 2 and self.x_query.ndim == 2
        assert self.x_context.shape[1] == self.x_query.shape[1]
        assert self.y_context.shape[0] == self.x_context.shape[0]
        assert self.y_query.shape[0] == self.x_query.shape[0]
        assert self.x_context.dtype == torch.float32

这个对象把「task-conditioned prediction」变成可检查边界。forward 最好只接收 x_context, y_context, x_query, masksy_query 由 loss / evaluator 单独持有。

8. 为什么第一课不急着写 Transformer

Transformer 只能变换它收到的 token。若 episode 定义错了,仍可能在错误输入上得到很好的训练 loss:

  • query label 被拼入 unified labels;
  • task seed 被当成 feature;
  • padding 没有 mask;
  • train/test 是 row split 而不是 task split;
  • classification label 被当成连续回归值。

这些错误不会都触发 shape error,因此必须先固定数据对象、测试和 sampler。

9. 课堂推导顺序

  1. 画 \(x\to y\) 的固定数据集图;
  2. 改成 context → query prediction 的 episode 图;
  3. 标出 prediction 时可见与不可见的变量;
  4. 写出 \(\mathbb{R}^{N_c\times P}\) 与 \(\mathbb{R}^{N_q\times P}\);
  5. 加入 padding 和 mask;
  6. 采样 20 个不同 task seed;
  7. 故意把 \(Y_{\mathrm{query}}\) 拼回输入,让 leakage test 失败;
  8. 修复后再进入第 2 课 Attention。

10. 知识检查

  1. 为什么 context label 是合法输入而 query label 不是?
  2. 一个 episode 与一张固定表的边界有什么不同?
  3. 若 \(N_c\) 在 batch 内不同,为什么需要 context_mask
  4. 为什么 task_seed 是 provenance,而不是 feature?
  5. 为什么 row-level random split 不能证明 held-out task generalization?
  6. 如果 classification generator 的 label map 改变,哪些对象必须同步变化?

11. 本课的硬结论

本课结束时,唯一可以声称的能力是:我们已经建立了一个可 replay、可检查、不会把 query answer 偷给 模型的 task/episode 数据接口。还不能声称模型已经学会跨任务预测;那需要后续模型、训练和 held-out task 评测共同证明。

HAND-ON LAB · 工程实现与验收包

第 1 课:项目介绍与任务表示

本课交付

本课结束时,学习者不是「知道 PFN 是在新任务上预测」,而是已经有一个可被后续模型直接消费的 Episode 对象,并能用同一个 sampler 产生 classification、regression、不同 feature 数和不同 context/query 大小的任务。

核心直觉:学的是「给定 context 如何预测 query」的程序,不是一张固定表的 \(x\to y\)。

本课的硬交付是:

  • data/task_types.pyEpisodeTaskSpecTaskKind
  • data/synthetic_classification.pydata/synthetic_regression.py
  • data/task_sampler.py:可重放的任务采样和 context/query split;
  • models/mlp_baseline.py:只用于建立比较口径的 per-task baseline;
  • tests/test_episode_contract.py:shape、dtype、label range、seed replay;
  • 一页实验记录:固定数据集泛化 vs 未见任务泛化。

本课符号

符号含义
\(N_c, N_q\)context / query row 数
\(P\)feature 数
\(B\)episode batch
\(M_{\mathrm{context}}, M_{\mathrm{query}}\)row padding mask

项目问题

普通监督学习通常写成 \(x\to y\);PFN 风格模型的输入是带标签的 context 和一批未知 query:

\[p\!\left(Y_{\mathrm{query}}\mid X_{\mathrm{query}},X_{\mathrm{context}},Y_{\mathrm{context}}\right)\]

训练 loss 的合法形状(canon §3.1):

\[\mathcal{L} =\operatorname{loss\_fn}\!\bigl( f_\theta(X_{\mathrm{context}},Y_{\mathrm{context}},X_{\mathrm{query}},\mathrm{masks}),\, Y_{\mathrm{query}}\bigr)\]

如果这一层没有被固定,后面的 row token、label injection 和 ICL mask 都会失去明确对象。第一课 因此先不实现 Transformer,而是把「一个任务」「一个 episode」「一个 batch」区别开。

与论文:context ≈ training rows,query ≈ test rows。

3 小时课堂流程

时间内容课堂证据
00:00–00:20从固定数据集训练切换到 task-conditioned prediction学习者画出两种计算图
00:20–00:50Task、Episode、Batch 与 context/query 记号写出所有 tensor shape
00:50–01:35二分类与回归任务生成器采样并打印一个 episode
01:35–01:50休息与错误样本检查找到一个故意注入的 leakage
01:50–02:25per-task MLP baseline产生 query prediction 和 baseline 指标
02:25–02:50replay、padding、mask 与 query 数量实验四个 contract tests 通过
02:50–03:00退出评审与作业说明提交 artifact manifest

1. 先固定四个对象

未 padding 的 episode:

\[\begin{aligned} X_{\mathrm{context}}&\in\mathbb{R}^{N_c\times P},& Y_{\mathrm{context}}&\text{与 task 类型一致},\\ X_{\mathrm{query}}&\in\mathbb{R}^{N_q\times P},& Y_{\mathrm{query}}&\text{仅用于 loss/evaluation,不进入 prediction input}. \end{aligned}\]

批处理时允许 \(N_c, N_q, P\) 因 episode 而异;第一版课程实现先在一个 batch 内固定 \(P\),对 rows 做 padding:

\[\begin{aligned} X_{\mathrm{context}}&\in\mathbb{R}^{B\times N_{c,\max}\times P},& M_{\mathrm{context}}&\in\{0,1\}^{B\times N_{c,\max}},\\ X_{\mathrm{query}}&\in\mathbb{R}^{B\times N_{q,\max}\times P},& M_{\mathrm{query}}&\in\{0,1\}^{B\times N_{q,\max}}. \end{aligned}\]

Y_query 可以存在于 evaluation object,但任何 model.forward 的输入签名都不允许接收它。这个 约束从第一课写入测试,避免第五课才发现 query label 已沿 data loader 泄漏。

2. 课堂讲解:固定数据集泛化不是新任务泛化

\[\begin{aligned} \text{固定数据集训练:}\quad& \text{train rows}\to\text{fit parameters}\to\text{test rows},\\ \text{PFN episode 训练:}\quad& \text{sample task}\to\text{context labels + query features}\to\text{query loss}. \end{aligned}\]

前者可以把列语义、row identity 或固定分布记进参数;后者要求参数学习「如何使用 context」。因此 训练 split 的单位必须是 task/episode,而不是把同一张表随机切 row 后混入 train/test。

最小反例:生成 100 个任务,每个 20 context + 5 query。若先合并所有 rows 再随机切分,模型可能在 测试时看到同一任务的近邻;若按 task id 切分,才是 held-out task。

3. 代码接口:先写数据契约

# data/task_types.py
from dataclasses import dataclass
from typing import Literal
import torch

TaskKind = Literal["classification", "regression"]

@dataclass(frozen=True)
class Episode:
    x_context: torch.Tensor       # [N_c, P]
    y_context: torch.Tensor       # [N_c]
    x_query: torch.Tensor         # [N_q, P]
    y_query: torch.Tensor         # [N_q], evaluation only
    task_kind: TaskKind
    task_seed: int

    def assert_contract(self) -> None:
        assert self.x_context.ndim == 2 and self.x_query.ndim == 2
        assert self.x_context.shape[1] == self.x_query.shape[1]
        assert self.y_context.shape[0] == self.x_context.shape[0]
        assert self.y_query.shape[0] == self.x_query.shape[0]
        assert self.x_context.dtype == torch.float32

不要把 task_kindtask_seed 偷塞进 feature tensor。它们是 provenance / control metadata。

4. 生成两个最小任务族

4.1 二分类

def sample_classification_episode(
    *, seed: int, n_context: int, n_query: int, n_features: int,
    nonlinear: bool = False, flip_prob: float = 0.0,
) -> Episode:
    g = torch.Generator().manual_seed(seed)
    x = torch.randn(n_context + n_query, n_features, generator=g)
    w = torch.randn(n_features, generator=g)
    score = x @ w
    if nonlinear:
        score = score + 0.5 * torch.sin(x[:, 0])
    y = (score > 0).to(torch.int64)
    flips = torch.rand(y.shape, generator=g) < flip_prob
    y = torch.where(flips, 1 - y, y)
    return Episode(x[:n_context], y[:n_context], x[n_context:], y[n_context:],
                   "classification", seed)

4.2 回归

def sample_regression_episode(*, seed, n_context, n_query, n_features,
                               heteroskedastic=False) -> Episode:
    g = torch.Generator().manual_seed(seed)
    x = torch.randn(n_context + n_query, n_features, generator=g)
    w = torch.randn(n_features, generator=g)
    mean = x @ w + 0.4 * torch.sin(x[:, 0])
    scale = 0.2 + (0.7 * x[:, 0].abs() if heteroskedastic else 0.0)
    y = mean + scale * torch.randn(mean.shape, generator=g)
    return Episode(x[:n_context], y[:n_context], x[n_context:], y[n_context:],
                   "regression", seed)

这里的 w、噪声和函数族是任务 latent state;它们可以用于生成数据,但不能作为 model input。

5. Guided lab:写一个不偷看 query 的 baseline

使用 PerTaskMLP:每个 episode 内只用 x_context/y_context 训练一个很小的 MLP,再在 x_query 上预测。它不承担跨任务 amortization,只提供「每个任务重新拟合」参照。

课堂必须实现并打印:

task_seed=17, kind=classification
context: X=[8, 3], Y=[8], labels={0, 1}
query:   X=[4, 3], Y=[4]
baseline: accuracy=...

对照:

  1. 固定同一 episode,增加 query 数量,确认 prediction 接口不依赖固定 query size;
  2. 保持 generator 配置不变,仅改 task_seed,确认面对的是新任务而不是同一表的重排。

6. 必做验证与预期证据

tests/test_episode_contract.py 必须覆盖:

test_context_query_have_same_feature_width
test_query_labels_are_not_in_model_input
test_classification_labels_are_in_0_to_k_minus_1
test_same_seed_replays_exact_episode

故意把 y_query 拼回 input,再让测试失败一次;这次失败是教学证据,不要删测试来「通过」。

常见失败

  • 把 episode 内的 task_seed、真实 w 或 query label 当成 feature;
  • 先合并所有任务再随机按 row 切 train/test;
  • 0 padding 却没有 context_mask,把真实零值和 padding 混为一谈;
  • 只报告固定任务的 accuracy,却称模型学会了 PFN;
  • 生成器只支持固定 \(P/N\),导致后续课程无法做 task distribution。

退出条件与评分

  1. 至少生成 20 个不同 task seed 的 classification 和 regression episodes;
  2. 同 seed 重放逐元素一致;
  3. model.forward 的输入中没有 Y_query
  4. 固定任务和 held-out task 的评测表分开;
  5. 提交一页说明:baseline 在什么问题上有意不等价于 Mini-TabPFN。

评分建议:数据契约 30%,任务生成 25%,replay/leakage tests 25%,解释和 artifact 20%。

课后作业

加入三分类 generator,并让 TaskSpec 显式记录 n_classesn_featuresnonlinearnoise_scale。不允许修改 Episode 的 row/feature 语义;下一课的 attention module 应能直接复用。