TENT
TTA希望利用测试阶段获得的目标域数据让模型对其进行适应,用适应后的模型预测。但测试阶段没有真实标签,那应该用什么损失函数更新模型?
1.Tent优化目标是最小化预测熵

当预测集中在某个类别时,熵较低。Tent优化目标是让模型对目标域数据的预测更加确定。
2.只更新BN的仿射参数

核心过程
python
# 1. 使用目标域数据预测
logits = model(target_x)
# 2. 计算预测熵
probs = torch.softmax(logits, dim=1)
entropy = -(
probs * torch.log(probs.clamp_min(1e-8))
).sum(dim=1).mean()
# 3. 计算梯度
entropy.backward()
# 4. 只更新 BN affine 参数
optimizer.step()
关于BN层
最常见的CNN结构
输入图像
│
▼
Conv2d(卷积层) 提取图像特征
│
▼
BatchNorm2d(BN 层)对中间特征进行归一化和仿射变换
│
▼
ReLU(激活函数)引入非线性
│
▼
Conv2d
│
▼
BatchNorm2d
│
▼
ReLU
│
▼
...
│
▼
全连接层
│
▼
分类结果
**神经网络每一层接收到的数据,其数值范围可能非常不同,而且会随着训练不断变化。如果中间特征的数值尺度变化很大,神经网络的训练可能变得困难。**引入归一化就可以把输入调整到相近的数值尺度,减轻由于输入尺度差异造成的优化困难。
BN层作用是对中间特征进行归一化和仿射变换,不会改变特征图的形状,而是改变特征值的分布。
简单代码
python
"""
TENT: Fully Test-Time Adaptation by Entropy Minimization
数据集:MNIST
模型:简单 CNN + BatchNorm
域偏移:Gaussian Noise
比较:
1. Source Model
2. BN Adapt
3. TENT
运行:
pip install torch torchvision matplotlib
python tent_mnist.py
"""
import copy
import random
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# ============================================================
# 1. 配置
# ============================================================
SEED = 42
BATCH_SIZE = 128
TRAIN_EPOCHS = 3
TRAIN_LR = 0.001
TENT_LR = 0.001
TENT_STEPS = 1
NOISE_STD = 0.8
DEVICE = torch.device(
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
def set_seed(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
set_seed(SEED)
print("Device:", DEVICE)
# ============================================================
# 2. 数据集
# ============================================================
# 源域:正常 MNIST
source_transform = transforms.ToTensor()
# 目标域:添加高斯噪声
class AddGaussianNoise:
def __init__(self, std=0.8):
self.std = std
def __call__(self, x):
noise = torch.randn_like(x) * self.std
x = x + noise
return torch.clamp(x, 0.0, 1.0)
# 给正常的 MNIST 图片添加高斯噪声,人为模拟 Distribution Shift(分布偏移)。
target_transform = transforms.Compose([# 把多个数据处理操作按照指定顺序组合起来,依次执行。
transforms.ToTensor(),
AddGaussianNoise(std=NOISE_STD)
])
train_dataset = datasets.MNIST(
root="./data",
train=True,
download=True,
transform=source_transform
)
clean_test_dataset = datasets.MNIST(
root="./data",
train=False,
download=True,
transform=source_transform
)
target_test_dataset = datasets.MNIST(
root="./data",
train=False,
download=True,
transform=target_transform
)
train_loader = DataLoader(
train_dataset,
batch_size=BATCH_SIZE,
shuffle=True
)
clean_test_loader = DataLoader(
clean_test_dataset,
batch_size=BATCH_SIZE,
shuffle=False
)
# 为了公平比较,预先固定目标域的噪声样本。
# 否则三种方法每次读取时会生成不同噪声。
fixed_target_images = []
fixed_target_labels = []
for x, y in DataLoader(
target_test_dataset,
batch_size=BATCH_SIZE,
shuffle=False
):
fixed_target_images.append(x)
fixed_target_labels.append(y)
fixed_target_images = torch.cat(fixed_target_images)
fixed_target_labels = torch.cat(fixed_target_labels)
target_loader = DataLoader(
torch.utils.data.TensorDataset(
fixed_target_images,
fixed_target_labels
),
batch_size=BATCH_SIZE,
shuffle=False
)
# ============================================================
# 3. CNN 模型
# ============================================================
class CNN(nn.Module):
def __init__(self):
super().__init__()
# 提取图片特征
self.features = nn.Sequential(
nn.Conv2d(1, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(2)
)
# 根据特征进行分类
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(64 * 7 * 7, 128),
nn.ReLU(),
nn.Linear(128, 10)
)
# 定义数据如何经过网络
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
# ============================================================
# 4. 源域监督训练
# ============================================================
# 训练源域模型:把初始的 CNN 模型放到干净的 MNIST 训练集上进行有监督训练
def train_source(model, loader):
# 将模型切换到训练模式
model.train()
# 创建Adam优化器
optimizer = torch.optim.Adam(
model.parameters(),
# 源域训练更新整个 CNN,TENT 只更新 BN 的仿射参数
lr=TRAIN_LR
)
for epoch in range(TRAIN_EPOCHS):
# 一个 Epoch 完整遍历一次训练集
total_loss = 0.0
correct = 0
total = 0
# 每执行一次循环,DataLoader 自动返回一个 Batch
for x, y in loader:
x = x.to(DEVICE) # 当前 Batch 的 128 张图片
y = y.to(DEVICE) # 这 128 张图片对应的标签
optimizer.zero_grad()
# 前向传播,logits存模型输出的原始分类分数
logits = model(x)
# 计算交叉熵损失
loss = F.cross_entropy(logits, y)
loss.backward()
# 更新参数
optimizer.step()
# 累加每个 Batch 的 Loss
total_loss += loss.item()
# 将 Logits 转换为预测类别
predictions = logits.argmax(dim=1)
# 统计正确数量
correct += (predictions == y).sum().item()
total += y.size(0)
accuracy = correct / total * 100
print(
f"Epoch {epoch + 1}/{TRAIN_EPOCHS} | "
f"Loss: {total_loss / len(loader):.4f} | "
f"Accuracy: {accuracy:.2f}%"
)
# ============================================================
# 5. 普通模型评估
# ============================================================
# 使用已经训练好的 CNN 对数据集进行预测,并计算分类准确率
@torch.no_grad() # 执行整个 evaluate() 函数时,不记录用于反向传播的计算图
def evaluate(model, loader):
model.eval()
# 遍历数据集并预测
correct = 0
total = 0
for x, y in loader:
x = x.to(DEVICE)
y = y.to(DEVICE)
logits = model(x)
predictions = logits.argmax(dim=1)
correct += (predictions == y).sum().item()
total += y.size(0)
return correct / total * 100
# ============================================================
# 6. 配置 BN Adapt
# ============================================================
def configure_bn_adapt(model):
# 其他层保持 eval,避免无关模块改变行为
model.eval()
for module in model.modules():
if isinstance(module, nn.BatchNorm2d):
# 只有 BN 层才会执行下面的操作,其他层不受影响。
# BN 使用当前测试 Batch 的统计量
module.train()
# 不维护 running statistics
# 不再跟踪和累计这些统计量
module.track_running_stats = False
# 把原来保存的均值和方差清空
module.running_mean = None
module.running_var = None
return model
# ============================================================
# 7. BN Adapt 评估
# ============================================================
@torch.no_grad()
def evaluate_bn_adapt(model, loader):
configure_bn_adapt(model)
correct = 0
total = 0
for x, y in loader:
x = x.to(DEVICE)
y = y.to(DEVICE)
logits = model(x)
predictions = logits.argmax(dim=1)
correct += (predictions == y).sum().item()
total += y.size(0)
return correct / total * 100
# ============================================================
# 8. 配置 TENT
# ============================================================
def configure_tent(model):
# 先将整个模型设置为 eval
model.eval()
# 冻结所有参数
for param in model.parameters():
param.requires_grad_(False)
tent_params = []
for module in model.modules():
if isinstance(module, nn.BatchNorm2d):
# BN 使用测试批次统计量
module.train()
module.track_running_stats = False
module.running_mean = None
module.running_var = None
# 只允许 BN affine 参数更新
module.weight.requires_grad_(True)
module.bias.requires_grad_(True)
tent_params.append(module.weight)
tent_params.append(module.bias)
return model, tent_params
# ============================================================
# 9. 预测熵
# ============================================================
def softmax_entropy(logits):
log_probs = F.log_softmax(logits, dim=1)
probs = F.softmax(logits, dim=1)
entropy = -(probs * log_probs).sum(dim=1)
return entropy
# ============================================================
# 10. TENT 单步更新
# ============================================================
def tent_step(model, x, optimizer):
optimizer.zero_grad()
# Step 1:前向传播
logits = model(x)
# Step 2:计算每个样本的预测熵
entropy = softmax_entropy(logits)
# Step 3:计算 Batch 平均熵
loss = entropy.mean()
# Step 4:反向传播
loss.backward()
# Step 5:更新 BN affine
optimizer.step()
return loss.item()
# ============================================================
# 11. TENT 测试
# ============================================================
def evaluate_tent(model, loader):
model, tent_params = configure_tent(model)
optimizer = torch.optim.SGD(
tent_params,
lr=TENT_LR
)
correct = 0
total = 0
entropy_history = []
for x, y in loader:
x = x.to(DEVICE)
y = y.to(DEVICE)
# 在当前 Batch 上进行无监督适应
for _ in range(TENT_STEPS):
entropy = tent_step(
model,
x,
optimizer
)
entropy_history.append(entropy)
# 使用更新后的参数重新预测当前 Batch
with torch.no_grad():
logits = model(x)
predictions = logits.argmax(dim=1)
correct += (predictions == y).sum().item()
total += y.size(0)
accuracy = correct / total * 100
return accuracy, entropy_history
# ============================================================
# 12. 主程序
# ============================================================
def main():
print("\n========== Source Training ==========")
model = CNN().to(DEVICE)
train_source(model, train_loader)
# 保存源模型
torch.save(
model.state_dict(),
"source_mnist.pth"
)
# --------------------------------------------------------
# Source Model
# --------------------------------------------------------
print("\n========== Source Evaluation ==========")
clean_acc = evaluate(
model,
clean_test_loader
)
source_target_acc = evaluate(
model,
target_loader
)
print(f"Clean Accuracy: {clean_acc:.2f}%")
print(f"Target Accuracy: {source_target_acc:.2f}%")
# --------------------------------------------------------
# BN Adapt
# --------------------------------------------------------
print("\n========== BN Adapt ==========")
bn_model = copy.deepcopy(model)
bn_acc = evaluate_bn_adapt(
bn_model,
target_loader
)
print(f"BN Adapt Accuracy: {bn_acc:.2f}%")
# --------------------------------------------------------
# TENT
# --------------------------------------------------------
print("\n========== TENT ==========")
tent_model = copy.deepcopy(model)
tent_acc, entropy_history = evaluate_tent(
tent_model,
target_loader
)
print(f"TENT Accuracy: {tent_acc:.2f}%")
# --------------------------------------------------------
# 结果汇总
# --------------------------------------------------------
print("\n========== Results ==========")
print(f"Clean Source: {clean_acc:.2f}%")
print(f"Corrupted Source: {source_target_acc:.2f}%")
print(f"BN Adapt: {bn_acc:.2f}%")
print(f"TENT: {tent_acc:.2f}%")
print(
f"TENT vs Source: "
f"{tent_acc - source_target_acc:+.2f} pp"
)
print(
f"TENT vs BN Adapt: "
f"{tent_acc - bn_acc:+.2f} pp"
)
if __name__ == "__main__":
main()