关于AI元学习的几点记录
前阵子AI元学习连续改了几轮,趁还记得写成备忘。
先搞清楚元学习是什么
元学习,也叫"学会学习"(Learning to Learn),它的核心思想是把学习过程本身当成一个优化问题。传统深度学习是在参数空间里找最优参数,元学习则是在"学习策略"空间里找最优学习策略。
用一个类比:传统学习像是教学生解题,每种题型都要练;元学习像是教学生解题方法,遇到新题型也能自己上手。
用伪代码表示一下:
# 传统学习
def traditional_learning(train_data):
model = initialize_model()
for batch in train_data:
loss = compute_loss(model(batch), batch.label)
model = update_parameters(model, loss)
return model
# 元学习
def meta_learning(support_sets, query_sets):
meta_model = initialize_meta_model()
for support_set, query_set in zip(support_sets, query_sets):
# 在支持集上快速适应
adapted_model = meta_model.copy()
adapted_model = adapt(adapted_model, support_set)
# 在查询集上评估适应效果
loss = evaluate(adapted_model, query_set)
# 根据适应效果更新元模型
meta_model = update_meta_parameters(meta_model, loss)
return meta_model
关键区别在于:元学习的训练目标是让模型在面对新任务时,只需少量样本就能快速适应。
第一次尝试:自己手搓一个原型
想快速验证思路,先写了个简单的原型。用的是 N-way K-shot 设定:N 个类别,每类 K 个样本。
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
class SimpleMetaLearner(nn.Module):
def __init__(self, input_dim=28*28, hidden_dim=256, output_dim=10):
super().__init__()
self.encoder = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, output_dim)
)
def forward(self, x):
return self.encoder(x)
def adapt(self, support_x, support_y, lr=0.01, steps=5):
"""在支持集上快速适应"""
adapted_model = SimpleMetaLearner(
input_dim=self.encoder[0].in_features,
hidden_dim=self.encoder[0].out_features,
output_dim=self.encoder[-1].out_features
)
adapted_model.load_state_dict(self.state_dict())
optimizer = torch.optim.SGD(adapted_model.parameters(), lr=lr)
for _ in range(steps):
optimizer.zero_grad()
logits = adapted_model(support_x)
loss = F.cross_entropy(logits, support_y)
loss.backward()
optimizer.step()
return adapted_model
# 构造一个简单的 5-way 5-shot 任务
def create_n_way_k_shot_task(dataset, n_way=5, k_shot=5):
classes = torch.randperm(len(dataset.classes))[:n_way]
support_data = []
query_data = []
for cls in classes:
cls_indices = (dataset.targets == cls).nonzero(as_tuple=True)[0]
selected_indices = torch.randperm(len(cls_indices))[:k_shot * 2]
for idx in selected_indices[:k_shot]:
support_data.append((dataset[idx][0], cls))
for idx in selected_indices[k_shot:]:
query_data.append((dataset[idx][0], cls))
return support_data, query_data
训练循环也写了:
def train_meta_learning(model, dataset, num_episodes=1000, n_way=5, k_shot=5):
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
for episode in range(num_episodes):
optimizer.zero_grad()
# 采样任务
support_data, query_data = create_n_way_k_shot_task(dataset, n_way, k_shot)
# 整理数据
support_x = torch.stack([x for x, _ in support_data])
support_y = torch.tensor([y for _, y in support_data])
query_x = torch.stack([x for x, _ in query_data])
query_y = torch.tensor([y for _, y in query_data])
# 适应
adapted_model = model.adapt(support_x, support_y)
# 在查询集上评估
query_logits = adapted_model(query_x)
query_loss = F.cross_entropy(query_logits, query_y)
# 更新元参数
query_loss.backward()
optimizer.step()
if episode % 100 == 0:
accuracy = (query_logits.argmax(dim=1) == query_y).float().mean()
print(f"Episode {episode}, Loss: {query_loss.item():.4f}, Accuracy: {accuracy:.2f}")
跑起来后发现效果很差,准确率一直在 20%-30% 左右,基本上等于随机猜测。
问题定位:训练策略不对
调试了很久才发现问题所在。我用的"适应"方式是梯度下降,但适应步数和学习率都调得不对。步数太少学不到东西,步数太多又把元模型学到的东西给破坏了。
改了几个关键参数:
def adapt(self, support_x, support_y, lr=0.01, steps=10):
adapted_model = SimpleMetaLearner(...)
adapted_model.load_state_dict(self.state_dict())
optimizer = torch.optim.SGD(adapted_model.parameters(), lr=lr)
for _ in range(steps):
optimizer.zero_grad()
logits = adapted_model(support_x)
loss = F.cross_entropy(logits, support_y)
loss.backward()
optimizer.step()
return adapted_model
# 训练时用更保守的学习率
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
还是不太稳定,有时候训练 loss 能降下去,但验证准确率忽上忽下。
正式上手 MAML
这时候才意识到,自己手搓的元学习框架太简陋了。决定正式实现 MAML(Model-Agnostic Meta-Learning),这是目前最经典的元学习算法之一。
MAML 的核心思想是:找到一个初始参数,使得从这个初始参数出发,只需少量梯度步就能在新任务上达到不错的效果。
用代码表示:
import torch
import torch.nn as nn
import torch.nn.functional as F
from copy import deepcopy
class MAML:
def __init__(self, model, inner_lr=0.01, meta_lr=1e-3, inner_steps=5):
self.model = model
self.inner_lr = inner_lr
self.meta_lr = meta_lr
self.inner_steps = inner_steps
self.meta_optimizer = torch.optim.Adam(model.parameters(), lr=meta_lr)
def inner_loop(self, support_x, support_y):
"""内层循环:在支持集上适应"""
temp_model = deepcopy(self.model)
optimizer = torch.optim.SGD(temp_model.parameters(), lr=self.inner_lr)
for _ in range(self.inner_steps):
optimizer.zero_grad()
logits = temp_model(support_x)
loss = F.cross_entropy(logits, support_y)
loss.backward()
optimizer.step()
return temp_model
def outer_loop(self, support_x, support_y, query_x, query_y):
"""外层循环:在查询集上评估并更新元参数"""
self.meta_optimizer.zero_grad()
# 内层适应
adapted_model = self.inner_loop(support_x, support_y)
# 查询集评估
query_logits = adapted_model(query_x)
query_loss = F.cross_entropy(query_logits, query_y)
# 反向传播到原始参数
# 这里需要手动计算梯度的梯度
grads = torch.autograd.grad(
query_loss,
self.model.parameters(),
create_graph=True,
retain_graph=True
)
# 更新元参数
for param, grad in zip(self.model.parameters(), grads):
param.data.sub_(self.meta_lr * grad.data)
return query_loss.item()
def train_episode(self, support_set, query_set):
support_x, support_y = support_set
query_x, query_y = query_set
support_x = torch.stack(support_x)
support_y = torch.tensor(support_y)
query_x = torch.stack(query_x)
query_y = torch.tensor(query_y)
loss = self.outer_loop(support_x, support_y, query_x, query_y)
return loss
这个实现有个关键点:create_graph=True。因为 MAML 需要计算二阶梯度(梯度的梯度),所以要保留计算图。
踩坑记录:梯度消失和显存爆炸
第一次跑 MAML 的时候直接遇到了显存爆炸。查了半天日志,发现是 create_graph=True 导致的计算图保留太多。
解决办法有两个:
办法一:减小 batch size 和内层步数
maml = MAML(
model=SimpleMetaLearner(),
inner_lr=0.01, # 内层学习率保持
meta_lr=1e-4, # 外层学习率调小
inner_steps=3 # 内层步数从 5 降到 3
)
办法二:用 First-Order MAML(FOMAML)
FOMAML 是 MAML 的简化版本,它不计算二阶梯度,只计算一阶梯度:
class FOMAML(MAML):
def outer_loop(self, support_x, support_y, query_x, query_y):
self.meta_optimizer.zero_grad()
# 内层适应
adapted_model = self.inner_loop(support_x, support_y)
# 查询集评估
query_logits = adapted_model(query_x)
query_loss = F.cross_entropy(query_logits, query_y)
# 只计算一阶梯度
query_loss.backward()
self.meta_optimizer.step()
return query_loss.item()
FOMAML 虽然理论上不如 MAML 精准,但实际效果差不太多,而且显存占用大幅降低。我的 RTX 3090 上,MAML 用 5-way 5-shot 时 batch size 只能到 4,换成 FOMAML 后能跑到 16。
数据集选择:从 MNIST 到 MiniImageNet
最开始用 MNIST 做实验,结果好得惊人,5-way 1-shot 就能达到 90% 准确率。后来换了 MiniImageNet,直接掉到 40%。
才意识到 MNIST 太简单了,根本测试不出元学习的真实能力。换成 MiniImageNet 后:
from torchvision import datasets, transforms
# 数据预处理
transform = transforms.Compose([
transforms.Resize(84),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
# 加载 MiniImageNet
train_dataset = datasets.ImageFolder('miniimagenet/train', transform=transform)
val_dataset = datasets.ImageFolder('miniimagenet/val', transform=transform)
test_dataset = datasets.ImageFolder('miniimagenet/test', transform=transform)
# 模型改成简单的 CNN
class SimpleCNN(nn.Module):
def __init__(self, num_classes=64):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 32, 3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(64, 64, 3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(2),
)
self.classifier = nn.Linear(64 * 21 * 21, num_classes)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
return self.classifier(x)
模型结构改好后,又调了好几轮超参数。MiniImageNet 上的最终效果:5-way 1-shot 达到 48% 准确率,5-way 5-shot 达到 65%。虽然不算顶尖,但足够验证思路了。
调参心得:那些试错换来的经验
折腾了一个月,总结几个关键调参点:
内层学习率和步数
内层学习率太大会破坏元模型学到的东西,太小又适应不够:
# 错误示范:内层学习率太大
maml = MAML(model, inner_lr=0.1, inner_steps=5) # 一适应就崩
# 错误示范:内层学习率太小
maml = MAML(model, inner_lr=0.001, inner_steps=5) # 根本适应不到新任务
# 合理设置
maml = MAML(model, inner_lr=0.01, inner_steps=3) # 保守但稳定
外层学习率
外层学习率决定了元参数更新的幅度,太大容易震荡,太小收敛慢:
# 外层学习率需要比内层小一个数量级
maml = MAML(model, inner_lr=0.01, meta_lr=1e-3) # 常见配置
任务采样策略
任务采样对元学习影响很大。一开始用的随机采样,后来改成了难度感知的采样:
class DifficultyAwareSampler:
def __init__(self, dataset, num_classes=64):
self.dataset = dataset
self.num_classes = num_classes
self.class_difficulty = {i: 0.5 for i in range(num_classes)}
def sample_task(self, n_way, k_shot):
# 根据难度加权采样
probs = np.array(list(self.class_difficulty.values()))
probs = probs / probs.sum()
selected_classes = np.random.choice(
self.num_classes,
n_way,
replace=False,
p=probs
)
# 构造支持集和查询集
support_data, query_data = self._construct_sets(
selected_classes, k_shot
)
return support_data, query_data
def update_difficulty(self, task_classes, accuracy):
# 根据任务准确率更新难度估计
for cls in task_classes:
if accuracy < 0.5:
# 表现不好,增加该类别的采样概率
self.class_difficulty[cls] = min(
self.class_difficulty[cls] * 1.1, 1.0
)
else:
# 表现好,降低采样概率
self.class_difficulty[cls] = max(
self.class_difficulty[cls] * 0.9, 0.1
)
这个策略能保证模型多训练困难任务,但实现复杂,调参成本高。最后还是回到了最简单的随机采样,稳定省心。
Few-shot 场景的实战应用
最后回到客户的真实需求。客户有个工业缺陷检测场景,新的缺陷类型不断出现,每类只有几十个样本。
基于 MAML 的训练流程:
# 1. 构造元学习数据集
def construct_defect_meta_dataset(base_defects, novel_defects):
"""
base_defects: 有足够样本的基础缺陷类别
novel_defects: 新出现的缺陷类别,样本很少
"""
train_tasks = []
for _ in range(1000):
# 从基础缺陷中随机采样 5 个类别
task_classes = random.sample(base_defects, 5)
support_data, query_data = create_n_way_k_shot_task(
task_classes, k_shot=5
)
train_tasks.append((support_data, query_data))
return train_tasks
# 2. 训练 MAML 模型
maml = MAML(
model=DefectClassifier(),
inner_lr=0.01,
meta_lr=1e-3,
inner_steps=3
)
for epoch in range(100):
for support_data, query_data in train_tasks:
maml.train_episode(support_data, query_data)
# 验证
if epoch % 10 == 0:
accuracy = evaluate_maml(maml, val_tasks)
print(f"Epoch {epoch}, Val Accuracy: {accuracy:.2f}")
# 3. 适应新缺陷类别
def adapt_to_new_defect(maml, new_defect_samples, lr=0.01, steps=10):
"""使用预训练的 MAML 模型快速适应新缺陷类别"""
adapted_model = deepcopy(maml.model)
optimizer = torch.optim.SGD(adapted_model.parameters(), lr=lr)
for _ in range(steps):
optimizer.zero_grad()
logits = adapted_model(new_defect_samples)
loss = F.cross_entropy(logits, new_defect_samples.labels)
loss.backward()
optimizer.step()
return adapted_model
实际效果:对于新的缺陷类别,用 10 个样本就能达到 85% 的检测准确率,比传统的 fine-tuning 快了差不多一个数量级。
与传统方法的对比
为了验证元学习的价值,做了几个对比实验:
传统 Fine-tuning
# 传统 fine-tuning 方法
def traditional_finetune(base_model, new_class_samples, epochs=50):
model = deepcopy(base_model)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
for epoch in range(epochs):
for batch in new_class_samples:
optimizer.zero_grad()
logits = model(batch)
loss = F.cross_entropy(logits, batch.labels)
loss.backward()
optimizer.step()
return model
结果:需要 50 个 epoch,准确率才到 75%,而且容易过拟合。
数据增强 + Fine-tuning
# 加上数据增强
augmented_transform = transforms.Compose([
transforms.RandomHorizontalFlip(),
transforms.RandomRotation(15),
transforms.ColorJitter(brightness=0.2, contrast=0.2),
transforms.Resize(84),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
结果:准确率提升到 80%,但仍不如 MAML 的 85%,而且训练时间更长。
MAML + 数据增强
# 在 MAML 的数据加载时加入增强
augmented_support_data = [augment(x) for x in support_x]
augmented_query_data = [augment(x) for x in query_x]
结果:准确率进一步提升到 88%,但训练时间也增加了。
结论:对于真正的 Few-shot 场景(样本 < 20),MAML 的优势最明显。如果样本量足够大(> 50),传统方法反而可能更划算。
最终的架构选择
综合考虑效果和成本,最终上线的架构是:
- 模型:FOMAML + 简单 CNN
- 数据增强:基本旋转和翻转
- 内层步数:3 步
- 内层学习率:0.01
- 外层学习率:1e-4
- 适应策略:支持集随机采样
为什么选 FOMAML 而不是 MAML?虽然 MAML 理论上更优,但在实际场景中,FOMAML 的效果差距不到 2%,但训练速度快了一倍,显存占用也少了一半。
踩坑总结
回顾整个折腾过程,几个最深的感受:
元学习不是万能的。它只适合真正的 Few-shot 场景,如果数据量够大,传统方法往往更直接有效。
超参数非常敏感。内层学习率、内层步数、外层学习率,每一个调不对都可能导致训练失败。
实现细节很关键。create_graph=True、梯度裁剪、显存管理,这些细节不注意就会踩大坑。
数据质量比算法更重要。一开始把大量时间花在调模型上,后来发现清理标注错误的数据反而收益更大。
不要追求完美的元学习。有时候一个简单的预训练 + fine-tuning,比搞个复杂的元学习架构更实用。
技术选择的边界
元学习像是一种"学会学习"的能力,这种能力在数据稀缺的场景下特别有价值。但它不是灵丹妙药,不能替代传统深度学习。
就像人类的快速学习能力一样,它建立在已有经验的基础上,然后迁移到新场景。如果完全没有相关经验,再强的学习能力也帮不上忙。
所以元学习的价值在于:当新任务与训练任务相似时,它能大幅降低数据需求。但如果新任务完全不同,它也得从头学起。
技术选择从来不是选最先进的,而是选最适合当前场景的。这次如果客户的新缺陷类型和基础缺陷差异很大,可能根本就不会考虑元学习,直接上人工标注了。
所谓经验,大抵就是这些踩坑爬坑的过程中形成的判断力。
版权声明: 本文首发于 指尖魔法屋-关于AI元学习的几点记录(https://blog.thinkmoon.cn/post/224-ai-meta-learning-from-model-to-learning-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。