跳到主内容
快讯直播
AI智模界
教程

复现看猫片学 ARC:视频预训练加测试时适配

这篇教程能做出什么

跟着走完,你会得到一个可运行的最小流程:拿一批无标注视频做自监督预训练,得到一个通用的视觉编码器;把这个编码器搬到 ARC 的彩色网格上,用每个任务自带的少量示例做测试时适配,最后跑一个评测脚本,得到逐任务的准确率。

"看猫片学 ARC"这个思路的直觉是:视频里天然包含物体恒存、遮挡、计数、对称、复制、移动这些结构。一只猫从椅子后面走过去再出现,模型要能"补全"中间那段;几个球滚来滚去,模型要学到"数量守恒"。这些先验和 ARC 要求的抽象规则高度重叠,而视频是免费的、海量的、不需要人工标注的。所以先看片、后解题,是一条值得试的迁移路线。

需要先说清楚预期:这条路线在公开评测上的绝对分数不会高,它的价值在于验证"视觉自监督预训练能带来可迁移的抽象先验"这个假设。教程里所有超参数都只是起点值,需要你按自己的数据和算力调整。

前置条件清单

  • 一台带 GPU 的机器,能跑 224×224 分辨率的视频片段训练即可;纯 CPU 也能跑通流程,只是慢
  • Python 环境,安装 PyTorch、torchvision、NumPy、Pillow、tqdm
  • ffmpeg 命令行工具,用来抽帧
  • 一批无标注视频:自己用手机拍的、公开动作识别数据集的视频都可以。数据集的使用条款和下载方式以各数据集官方页面为准
  • ARC 的公开数据文件(JSON 格式,包含 train/test 两组示例对),获取方式以官方仓库当前说明为准
  • 不需要预训练权重,一切从零开始;如果你想省时间,也可以先用公开的视觉预训练权重初始化,再跑本流程

第一步:搭好目录与配置

先固定目录结构,后面每一步的文件都有明确去处,排错时不会翻乱。

```text

arc-video-repro/

├── configs/

│ └── default.yaml

├── data/

│ ├── videos/ # 原始视频文件

│ ├── frames/ # 抽帧结果,每个视频一个子目录

│ └── arc/ # ARC 的 JSON 任务文件

├── checkpoints/

├── src/

│ ├── dataset.py # 视频片段数据集

│ ├── model.py # 自监督视频模型

│ ├── train_pretrain.py # 预训练入口

│ ├── arc_data.py # 网格与张量的互转

│ ├── transfer.py # 表征迁移与测试时适配

│ └── evaluate.py # 评测入口

└── requirements.txt

```

写下依赖清单,方便别人复现:

```text

torch

torchvision

numpy

pillow

tqdm

pyyaml

```

第二步:把无标注视频切成训练用的帧

自监督目标的输入是"视频片段",也就是连续的若干帧。先把视频统一抽成帧序列存盘,训练时随取随用,比每次解码视频快很多。

```bash

每个视频抽成 4 fps 的帧,统一缩放到 224x224

mkdir -p data/frames

for f in data/videos/*.mp4; do

name=$(basename "$f" .mp4)

mkdir -p "data/frames/$name"

ffmpeg -i "$f" -vf "fps=4,scale=224:224" -q:v 3 "data/frames/$name/%06d.jpg"

done

```

帧率不用太高:ARC 里的规则大多是静态或定性变化,4 fps 已经能覆盖"物体移动、遮挡、复制"这类模式。分辨率保持能被 patch 尺寸整除(224 能被 16 整除),省去后面处理边界的麻烦。

写数据集类,每次返回一个定长片段:

```python

src/dataset.py

import os

import random

import torch

from torch.utils.data import Dataset

from torchvision import transforms

from PIL import Image

class ClipDataset(Dataset):

"""从每个帧目录里随机截取一段连续帧,返回 (T, 3, H, W) 张量。"""

def __init__(self, root, clip_len=8, size=224):

self.dirs = [

os.path.join(root, d)

for d in sorted(os.listdir(root))

if os.path.isdir(os.path.join(root, d))

]

self.clip_len = clip_len

self.tf = transforms.Compose([

transforms.Resize((size, size)),

transforms.ToTensor(), # 到 [0, 1]

])

def __len__(self):

return len(self.dirs)

def __getitem__(self, idx):

files = sorted(os.listdir(self.dirs[idx]))

k = self.clip_len

if len(files) < k:

files = files + [files[-1]] * (k - len(files))

start = random.randint(0, len(files) - k)

frames = [

self.tf(Image.open(os.path.join(self.dirs[idx], f)).convert("RGB"))

for f in files[start:start + k]

]

return torch.stack(frames) # (T, 3, H, W)

```

第三步:自监督视频预训练(掩码重建)

自监督目标选最简单的:把一部分时空 patch 遮住,让模型重建被遮住的内容。这是 MAE 系列思路在视频上的直接推广。也可以用"预测下一帧"或"对比学习",思路相通,先用重建跑通再说。

```python

src/model.py

import torch

import torch.nn as nn

class VideoMAE(nn.Module):

"""极简版视频掩码自编码器:Conv3d 切 patch + Transformer 编码 + 反卷积重建。"""

def __init__(self, dim=384, depth=6, heads=6, patch=16, t_patch=2,

in_ch=3, max_tokens=4096, mask_ratio=0.75):

super().__init__()

self.patch = patch

self.t_patch = t_patch

self.mask_ratio = mask_ratio

self.embed = nn.Conv3d(in_ch, dim, (t_patch, patch, patch),

(t_patch, patch, patch))

self.pos = nn.Parameter(torch.zeros(1, max_tokens, dim))

self.norm = nn.AI 词典:LayerNorm">LayerNorm(dim)

layer = nn.TransformerEncoderLayer(dim, heads, dim * 4,

batch_first=True, norm_first=True)

self.encoder = nn.TransformerEncoder(layer, depth)

self.mask_token = nn.Parameter(torch.zeros(1, 1, dim))

self.decoder = nn.Sequential(

nn.ConvTranspose3d(dim, 128, (t_patch, patch, patch),

(t_patch, patch, patch)),

nn.GELU(),

nn.Conv3d(128, in_ch, 3, padding=1),

)

def features(self, x):

"""x: (B, 3, T, H, W) -> (B, dim, H/patch, W/patch),时间维平均。"""

z = self.embed(x) # (B, dim, T', H', W')

z = z.mean(dim=2) # (B, dim, H', W')

b, d, h, w = z.shape

tokens = z.flatten(2).transpose(1, 2) + self.pos[:, :h * w]

tokens = self.encoder(self.norm(tokens))

return tokens.transpose(1, 2).reshape(b, d, h, w)

def forward(self, x):

"""x: (B, 3, T, H, W),返回被遮住 patch 上的重建损失。"""

z = self.embed(x)

b, d, t, h, w = z.shape

tokens = z.flatten(2).transpose(1, 2) + self.pos[:, :t * h * w]

n = tokens.size(1)

keep = torch.rand(b, n, device=x.device) > self.mask_ratio

keep[:, 0] = True # 至少保留一个 token

tokens = torch.where(keep.unsqueeze(-1), tokens, self.mask_token)

feat = self.encoder(self.norm(tokens))

feat = feat.transpose(1, 2).reshape(b, d, t, h, w)

rec = self.decoder(feat)

mask = (~keep).reshape(b, 1, t, h, w).float()

loss = ((rec - x) ** 2 * mask).sum() / (mask.sum() + 1e-6)

return loss

```

训练入口,把片段的时间维和通道维顺序对齐:

```python

src/train_pretrain.py

import os

import torch

from torch.utils.data import DataLoader

from dataset import ClipDataset

from model import VideoMAE

def main():

device = "cuda" if torch.cuda.is_available() else "cpu"

os.makedirs("checkpoints", exist_ok=True)

ds = ClipDataset("data/frames", clip_len=8, size=224)

dl = DataLoader(ds, batch_size=8, shuffle=True,

num_workers=4, drop_last=True)

model = VideoMAE().to(device)

opt = torch.optim.AdamW(model.parameters(), lr=1.5e-4, weight_decay=0.05)

use_amp = device == "cuda"

scaler = torch.cuda.amp.GradScaler(enabled=use_amp)

for epoch in range(20):

model.train()

total = 0.0

for clip in dl:

clip = clip.to(device).permute(0, 2, 1, 3, 4) # (B, 3, T, H, W)

with torch.cuda.amp.autocast(enabled=use_amp):

loss = model(clip)

opt.zero_grad(set_to_none=True)

scaler.scale(loss).backward()

scaler.step(opt)

scaler.update()

total += loss.item()

print(f"epoch {epoch} loss {total / max(1, len(dl)):.4f}")

torch.save(model.state_dict(), "checkpoints/video_mae.pt")

if __name__ == "__main__":

main()

```

跑起来之后,重点看损失有没有稳定下降。如果损失很快掉到很低但下游迁移没起色,通常是任务太简单(mask 比例太低)或者数据太单一,可以调 mask 比例、加数据增强、换个数据集。

第四步:把 ARC 网格变成模型能吃的张量

ARC 的输入是 0 到 9 的整数网格,每个整数是一个颜色编号。视频编码器吃的是三通道图像,所以需要一座桥。最小可跑的桥是:给 10 个颜色配一组固定的 RGB 值,把网格当成一张小图。

注意两个关键细节:

一是必须用最近邻插值放大。双线性插值会把相邻颜色混合出中间色,模型的输入分布就完全变了。

二是网格尺寸可变。公开的 ARC 数据里网格边长最大通常不超过 30,具体以官方数据为准。统一补零到固定边长,同时额外记录一个"有效区域掩码",否则补出来的零会被当成真实的黑色格子,训练和评测都会错。

```python

src/arc_data.py

import json

import numpy as np

import torch

import torch.nn.functional as F

一组固定调色板即可,关键是全流程保持一致

PALETTE = torch.tensor([

[0, 0, 0], [0, 116, 217], [255, 65, 54], [46, 204, 64], [255, 220, 0],

[170, 170, 170], [240, 18, 190], [255, 133, 27], [127, 219, 255], [135, 12, 37],

], dtype=torch.float32) / 255.0

GRID = 30

IMG = 224

def load_task(path):

with open(path, "r", encoding="utf-8") as f:

return json.load(f)

def grid_to_tensor(grid, size=GRID):

"""返回 (3, size, size) 图像张量和 (1, size, size) 有效区域掩码。"""

g = np.asarray(grid, dtype=np.int64)

h, w = g.shape

assert h <= size and w <= size, f"网格 {h}x{w} 超过 {size}"

rgb = PALETTE[g].permute(2, 0, 1) # (3, h, w)

x = torch.zeros(3, size, size)

valid = torch.zeros(1, size, size)

x[:, :h, :w] = rgb

valid[:, :h, :w] = 1.0

return x, valid

def to_model_input(x, img=IMG):

"""最近邻放大,保持颜色为纯色。x: (B, 3, grid, grid)"""

return F.interpolate(x, size=(img, img), mode="nearest")

```

第五步:表征迁移——用视频编码器读 ARC 网格

现在把预训练好的编码器拿过来,接一个逐格子的颜色分类头。编码器输出的是 patch 级特征,用双线性插值拉回到 30×30 的格子分辨率,每个格子做一次 10 分类。

```python

src/transfer.py

import copy

import torch

import torch.nn as nn

import torch.nn.functional as F

from arc_data import GRID, IMG, grid_to_tensor, to_model_input

class ARCPredictor(nn.Module):

def __init__(self, encoder, dim=384, n_colors=10, grid=GRID, img=IMG):

super().__init__()

self.encoder = encoder

self.grid = grid

self.img = img

self.head = nn.Linear(dim, n_colors)

def forward(self, x):

"""x: (B, 3, img, img) -> logits: (B, grid, grid, n_colors)"""

feats = self.encoder.features(x) # (B, dim, h, w)

feats = F.interpolate(feats, size=(self.grid, self.grid),

mode="bilinear", align_corners=False)

return self.head(feats.permute(0, 2, 3, 1))

def build_model(ckpt_path, device="cpu"):

from model import VideoMAE

encoder = VideoMAE()

state = torch.load(ckpt_path, map_location="cpu")

只加载编码器相关的权重,解码器在迁移阶段用不到

enc_state = {k: v for k, v in state.items()

if k.startswith(("embed", "encoder", "norm", "pos"))}

encoder.load_state_dict(enc_state, strict=False)

model = ARCPredictor(encoder).to(device)

return model

```

第六步:测试时适配

这是整条路线里最关键的一步。ARC 每个任务只给几对示例,指望一个通用模型直接答对不现实,所以要在解题时针对当前任务做适配。常用两段式:先用无标签的自监督适配调编码器,再用示例对调分类头。

自监督适配的做法是:把该任务所有输入网格(示例的输入加上测试输入)拿来做格子级掩码重建。因为颜色只有 10 种,重建本质上就是逐格分类,非常自然。

```python

src/transfer.py 追加

import numpy as np

def random_cell_mask(h, w, ratio=0.5, size=GRID):

"""在 h x w 的有效区域内随机选格子,返回 (1, size, size) 掩码。"""

m = torch.zeros(size, size)

n = max(1, int(h * w * ratio))

flat = torch.randperm(h * w)[:n]

m[flat // w, flat % w] = 1.0

return m.unsqueeze(0)

def tta_finetune(model, task, ssl_steps=100, sup_steps=200, lr=3e-4):

"""返回针对当前任务适配过的模型副本。"""

model = copy.deepcopy(model)

model.train()

冻结编码器主体,只调 LayerNorm 和分类头,样本太少时更稳

for name, p in model.encoder.named_parameters():

p.requires_grad = "norm" in name

opt = torch.optim.AdamW(

[p for p in model.parameters() if p.requires_grad], lr=lr)

inputs = [p["input"] for p in task["train"]] + [p["input"] for p in task["test"]]

阶段一:无标签的自监督适配

for step in range(ssl_steps):

grid = inputs[step % len(inputs)]

h, w = len(grid), len(grid[0])

x, valid = grid_to_tensor(grid)

m = random_cell_mask(h, w, ratio=0.5) # (1, 30, 30)

x_pert = x * (1 - m[0]) # 被遮处涂成颜色 0

logits = model(to_model_input(x_pert.unsqueeze(0))) # (1, 30, 30, 10)

target = torch.tensor(grid, dtype=torch.long)

ce = F.cross_entropy(logits[0].permute(2, 0, 1), target,

reduction="none") # (30, 30)

loss = (ce * m[0]).sum() / (m[0].sum() + 1e-6)

opt.zero_grad(set_to_none=True)

loss.backward()

opt.step()

阶段二:用示例对做监督适配

for step in range(sup_steps):

pair = task["train"][step % len(task["train"])]

x_in, valid = grid_to_tensor(pair["input"])

y_out = torch.tensor(pair["output"], dtype=torch.long)

h, w = y_out.shape

if (h, w) != (len(pair["input"]), len(pair["input"][0])):

continue # 简化版只处理同尺寸任务,见"常见坑"

logits = model(to_model_input(x_in.unsqueeze(0)))

ce = F.cross_entropy(logits[0].permute(2, 0, 1), y_out, reduction="none")

loss = (ce * valid[0]).sum() / (valid[0].sum() + 1e-6)

opt.zero_grad(set_to_none=True)

loss.backward()

opt.step()

model.eval()

return model

@torch.no_grad()

def predict_grid(model, grid):

x, _ = grid_to_tensor(grid)

logits = model(to_model_input(x.unsqueeze(0)))

pred = logits.argmax(-1)[0].cpu().numpy()

h, w = len(grid), len(grid[0])

return pred[:h, :w].tolist()

```

这里有个设计取舍:适配阶段只训练 LayerNorm 和分类头。样本只有几对,全量微调很容易过拟合到示例上,反而拉低测试表现。等你跑通之后可以再对比"只调头""调最后几层""全量微调"三种设置。

第七步:评测脚本骨架

ARC 的评测口径是整张输出网格逐格完全一致才算对,部分正确不给分。这个口径以官方评测脚本为准,自己写的时候别放宽。

```python

src/evaluate.py

import os

import glob

import torch

from arc_data import load_task

from transfer import build_model, tta_finetune, predict_grid

def main():

device = "cuda" if torch.cuda.is_available() else "cpu"

base = build_model("checkpoints/video_mae.pt", device=device)

task_files = sorted(glob.glob("data/arc/*.json"))

solved, total = 0, 0

for path in task_files:

task = load_task(path)

model = tta_finetune(base, task) # 每个任务单独适配

ok, n = 0, 0

for pair in task["test"]:

if "output" not in pair:

continue # 隐藏测试集没有答案,跳过

pred = predict_grid(model, pair["input"])

h, w = len(pair["output"]), len(pair["output"][0])

ok += int(pred == pair["output"])

n += 1

solved += ok

total += n

print(f"{os.path.basename(path)}: {ok}/{n}")

print(f"总计 {solved}/{total} = {solved / max(1, total):.3f}")

if __name__ == "__main__":

main()

```

跑完之后,你会看到哪些任务被解出来了。更值得看的是"哪些任务被解出来了"而不是总分——如果被解出来的任务集中在对称、填充、颜色映射这几类,说明视频预训练确实带进来了对应的先验。

常见坑与排错

输出尺寸不等于输入尺寸。 上面所有代码都在"输出与输入同尺寸"的简化假设下工作,而 ARC 里有相当一部分任务的输出尺寸会变。最小版本先只统计同尺寸任务,能解出来就有意义。要往前走一步,可以枚举一个候选尺寸集合,让模型额外预测输出尺寸,或者把输出尺寸当作可搜索的超参数。

颜色 0 和补零分不开。 补到 30×30 用的也是颜色 0,如果不带有效区域掩码,模型会把补出来的区域也当成任务内容去拟合。所有损失都必须乘上掩码。

放大网格用了双线性插值。 颜色会被混成新颜色,模型见到的是它从没见过的输入。网格到图像的转换里只用最近邻。

测试时适配过拟合。 症状是示例对全部答对、测试全部答错。对策:减少适配步数、降低学习率、只调 LayerNorm、加早停。也可以把示例对留一对做验证,但样本会变得更少。

数据泄漏。 测试对的输出绝不能进入适配过程。测试对的输入可以进:自监督适配本来就允许使用无标签输入,这一点在写论文式报告时要说清楚。

预训练的域差距太大。 视频是自然图像,ARC 是纯色格子,中间隔着一条沟。两个常见桥接手段:一是预训练时混入合成的网格视频(把网格的行序列当成时间轴);二是把网格按行铺成多帧,让编码器看到"时序"结构。哪个更有效,和自己的数据量有关。

每任务单独适配很慢。 任务数一多,评测时间会成倍增长。把基础模型常驻显存、只对副本做浅层适配,或者把适配限制在几百步以内,速度会好很多。

结果不可复现。 抽帧的随机起点、掩码随机数、数据加载的多进程顺序都会影响结果。固定随机种子,并把抽帧脚本版本一起记录。

损失下降但下游没提升。 掩码比例太低、重建太容易时,编码器学到的多是低层纹理。把掩码比例调高、把片段拉长、或者换成"预测未来帧"这类更强调语义的目标,通常有帮助。

下一步建议

跑通最小版本之后,可以按这个顺序往下推:

1. 换自监督目标。掩码重建、未来帧预测、对比学习三种各跑一轮,对比下游 ARC 准确率,这是最能说明问题的一组实验。

2. 处理变尺寸输出。把尺寸预测单独拆成一个分类子任务,让主分支专心做逐格颜色预测。

3. 加任务级数据增强。颜色置换、旋转翻转、网格裁剪,都可以在适配阶段当一致性正则用,缓解示例过少的问题。

4. 做消融。随机初始化的编码器、只训分类头、全量微调三种对照跑一遍,才能说清楚"到底是谁在起作用"。

5. 用公开评测集验证。自己划分的验证集容易乐观,公开评测的提交方式与口径以官方页面说明为准。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。