关于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/) 转载或引用必须申明原指尖魔法屋来源及源地址!