案例 11:EuroSAT 上的 CNN 架构、残差与效率比较#
学习目标与记号#
本案例使用 27,000 张 Sentinel-2 红、绿、蓝(red, green, blue,RGB)图像,比较三种经典结构思想:VGG/AlexNet 风格的卷积堆叠、MobileNet 的深度可分离卷积以及 ResNet 的残差连接。主要讨论以下内容:
理解不同网络模块中的张量维度及其参数来源;
验证残差分支和直连分支在相加前必须具有相同维度;
区分 BatchNorm 的训练/评估行为与 Dropout;
在相同数据、随机种子和训练预算下比较准确率、参数量和推断耗时;
识别 EuroSAT 随机图像划分可能带来的空间泄漏;
明确图像分类与 YOLO 目标检测的任务差异。
记输入图像的维度为 \((B,C,H,W)\),其中 \(B\)、\(C\)、\(H\) 和 \(W\) 分别表示批量大小、通道数、图像高度和图像宽度。残差块的输入记为 \(\boldsymbol{x}\),残差分支学习的修正量记为 \(\mathcal{F}(\boldsymbol{x})\)。快速模式使用固定的小样本和很少的训练轮数,适合在中央处理器(central processing unit,CPU)上检查程序;完整模式建议使用图形处理器(graphics processing unit,GPU)。
数据来源、许可和划分约束#
数据集:EuroSAT RGB,共有 10 个土地利用或土地覆盖类别,包含 27,000 张 \(64\times64\) 的 JPEG 图像;
说明页:https://zenodo.org/records/7711810;
匿名 HTTPS 下载地址:https://zenodo.org/records/7711810/files/EuroSAT_RGB.zip?download=1;
许可:数据集标注为 MIT;底层 Sentinel 影像还应遵守 Copernicus Sentinel 数据条款。
公开 RGB 数据包没有可以稳定标识拍摄地点的分组字段,因此本案例只能进行分层随机划分。同一地点附近的影像可能同时进入训练集和测试集,使测试成绩显得过于乐观。本案例会明确记录这一限制;如果要研究模型对新地区的表现,应使用包含坐标信息的数据版本,并按照地理区域划分训练集、验证集和测试集。
from __future__ import annotations
import io
import os
import random
import time
import urllib.request
import zipfile
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from PIL import Image
from sklearn.metrics import accuracy_score, f1_score
from sklearn.model_selection import train_test_split
SEED = 20260812
FAST_MODE = os.getenv("FAST_MODE", "1") != "0"
random.seed(SEED)
np.random.seed(SEED)
try:
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
except ImportError as exc:
raise ImportError("本案例需要 PyTorch 和 Pillow。") from exc
torch.manual_seed(SEED)
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
CACHE_ROOT = Path(os.getenv("AI_COURSE_DATA_DIR", Path.home() / ".cache" / "ai-course"))
CACHE_DIR = CACHE_ROOT / "eurosat_rgb"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
print({"快速模式": FAST_MODE, "计算设备": str(DEVICE), "缓存目录": str(CACHE_DIR)})
{'快速模式': True, '计算设备': 'cpu', '缓存目录': '/private/tmp/ai-course-case-data/eurosat_rgb'}
URL = "https://zenodo.org/records/7711810/files/EuroSAT_RGB.zip?download=1"
ZIP_PATH = CACHE_DIR / "EuroSAT_RGB.zip"
if not ZIP_PATH.exists():
print("首次运行将下载约 95 MB 的公开压缩包……")
urllib.request.urlretrieve(URL, ZIP_PATH)
assert ZIP_PATH.stat().st_size > 90_000_000
with zipfile.ZipFile(ZIP_PATH) as zf:
image_names = sorted(
n for n in zf.namelist()
if n.lower().endswith((".jpg", ".jpeg", ".png")) and not n.startswith("__MACOSX")
)
assert len(image_names) == 27_000, f"预期 27000 张图,实际 {len(image_names)}"
labels_text = np.array([Path(n).parent.name for n in image_names])
classes = sorted(np.unique(labels_text).tolist())
class_labels = {
"AnnualCrop": "一年生作物", "Forest": "森林", "HerbaceousVegetation": "草本植被",
"Highway": "公路", "Industrial": "工业区", "Pasture": "牧场",
"PermanentCrop": "多年生作物", "Residential": "住宅区", "River": "河流",
"SeaLake": "海域或湖泊",
}
class_to_id = {name: i for i, name in enumerate(classes)}
labels = np.array([class_to_id[x] for x in labels_text], dtype=np.int64)
print({"图像数量": len(image_names), "类别": [class_labels[c] for c in classes]})
{'图像数量': 27000, '类别': ['一年生作物', '森林', '草本植被', '公路', '工业区', '牧场', '多年生作物', '住宅区', '河流', '海域或湖泊']}
counts = pd.Series(labels_text).value_counts().sort_index()
counts_display = counts.rename(index=class_labels).rename("图像数量")
counts_display.index.name = "类别"
display(counts_display.to_frame())
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
with zipfile.ZipFile(ZIP_PATH) as zf:
for cls, ax in zip(classes, axes.flat):
name = image_names[np.flatnonzero(labels_text == cls)[0]]
with zf.open(name) as handle:
image = Image.open(handle).convert("RGB")
arr = np.asarray(image)
assert arr.shape == (64, 64, 3)
ax.imshow(arr)
ax.set_title(class_labels[cls])
ax.axis("off")
plt.tight_layout()
| 图像数量 | |
|---|---|
| 类别 | |
| 一年生作物 | 3000 |
| 森林 | 3000 |
| 草本植被 | 3000 |
| 公路 | 2500 |
| 工业区 | 2500 |
| 牧场 | 2000 |
| 多年生作物 | 2500 |
| 住宅区 | 3000 |
| 河流 | 2500 |
| 海域或湖泊 | 3000 |
all_idx = np.arange(len(image_names))
train_idx, temp_idx = train_test_split(
all_idx, test_size=0.30, random_state=SEED, stratify=labels
)
valid_idx, test_idx = train_test_split(
temp_idx, test_size=0.50, random_state=SEED, stratify=labels[temp_idx]
)
def stratified_subsample(indices, size, seed):
if size >= len(indices):
return indices
chosen, _ = train_test_split(
indices, train_size=size, random_state=seed, stratify=labels[indices]
)
return np.sort(chosen)
if FAST_MODE:
train_idx = stratified_subsample(train_idx, 2_000, SEED)
valid_idx = stratified_subsample(valid_idx, 500, SEED + 1)
test_idx = stratified_subsample(test_idx, 500, SEED + 2)
assert set(train_idx).isdisjoint(valid_idx)
assert set(train_idx).isdisjoint(test_idx)
assert set(valid_idx).isdisjoint(test_idx)
print({"训练集": len(train_idx), "验证集": len(valid_idx), "测试集": len(test_idx)})
print("重要:当前公开包无坐标分组,随机划分不能排除相邻影像泄漏。")
{'训练集': 2000, '验证集': 500, '测试集': 500}
重要:当前公开包无坐标分组,随机划分不能排除相邻影像泄漏。
# 训练集统计量只由训练图像计算。为减少重复解压,Dataset 在初始化时加载所选小集合。
class EuroSATDataset(Dataset):
def __init__(self, indices, mean=None, std=None):
self.indices = np.asarray(indices)
arrays = []
with zipfile.ZipFile(ZIP_PATH) as zf:
for idx in self.indices:
with zf.open(image_names[idx]) as handle:
img = Image.open(handle).convert("RGB")
arrays.append(np.asarray(img, dtype=np.float32) / 255.0)
self.x = np.stack(arrays)
self.y = labels[self.indices]
if mean is None:
mean = self.x.mean(axis=(0, 1, 2))
std = self.x.std(axis=(0, 1, 2))
self.mean = np.asarray(mean, np.float32)
self.std = np.maximum(np.asarray(std, np.float32), 1e-6)
self.x = (self.x - self.mean) / self.std
def __len__(self):
return len(self.y)
def __getitem__(self, idx):
x = torch.from_numpy(self.x[idx]).permute(2, 0, 1).contiguous()
return x, torch.tensor(self.y[idx], dtype=torch.long)
train_ds = EuroSATDataset(train_idx)
valid_ds = EuroSATDataset(valid_idx, train_ds.mean, train_ds.std)
test_ds = EuroSATDataset(test_idx, train_ds.mean, train_ds.std)
assert train_ds[0][0].shape == (3, 64, 64)
generator = torch.Generator().manual_seed(SEED)
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, generator=generator)
valid_loader = DataLoader(valid_ds, batch_size=128, shuffle=False)
test_loader = DataLoader(test_ds, batch_size=128, shuffle=False)
print("训练集 RGB 均值/标准差:", train_ds.mean, train_ds.std)
训练集 RGB 均值/标准差: [0.34494996 0.38066077 0.40503475] [0.20166357 0.13692604 0.11535666]
# 非神经网络式的颜色基线:每张图只保留 RGB 均值和标准差,再用最近类别中心分类。
train_stats = np.concatenate(
[train_ds.x.mean(axis=(1,2)), train_ds.x.std(axis=(1,2))], axis=1
)
test_stats = np.concatenate(
[test_ds.x.mean(axis=(1,2)), test_ds.x.std(axis=(1,2))], axis=1
)
centroids = np.stack([train_stats[train_ds.y == c].mean(axis=0) for c in range(len(classes))])
distance = ((test_stats[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2)
baseline_pred = distance.argmin(axis=1)
baseline_result = {
"accuracy": accuracy_score(test_ds.y, baseline_pred),
"macro_f1": f1_score(test_ds.y, baseline_pred, average="macro"),
}
print("RGB 全局统计最近中心基线:", {"准确率": baseline_result["accuracy"], "宏平均 F1": baseline_result["macro_f1"]})
RGB 全局统计最近中心基线: {'准确率': 0.38, '宏平均 F1': 0.35757917927974214}
三种架构设计#
VGG/AlexNet 风格:连续使用普通卷积增加通道数,再用池化降低空间分辨率;结构容易理解,但参数量和计算量通常较大;
MobileNet 风格:深度卷积分别对每个输入通道进行空间卷积,随后使用 \(1\times1\) 的逐点卷积重新组合通道。参数量由普通卷积的 \(C_{\mathrm{in}}C_{\mathrm{out}}K^2\) 降为 \(C_{\mathrm{in}}K^2+C_{\mathrm{in}}C_{\mathrm{out}}\);
ResNet 风格:残差分支学习 \(\mathcal{F}(\boldsymbol{x})\),块输出为 \(\mathcal{F}(\boldsymbol{x})+\boldsymbol{x}\)。若通道数或空间分辨率发生改变,直连分支也必须进行相应变换,使两个分支具有相同维度。
批量归一化在训练时使用当前批次统计量并更新运行统计量,在评估时使用已经保存的运行统计量;Dropout 在评估时关闭随机屏蔽。因此,进行验证或测试前必须调用 model.eval()。
class ConvBlock(nn.Sequential):
def __init__(self, c_in, c_out, stride=1):
super().__init__(
nn.Conv2d(c_in, c_out, 3, stride=stride, padding=1, bias=False),
nn.BatchNorm2d(c_out), nn.ReLU()
)
class VGGSmall(nn.Module):
def __init__(self, n_classes=10):
super().__init__()
self.features = nn.Sequential(
ConvBlock(3, 32), ConvBlock(32, 32), nn.MaxPool2d(2),
ConvBlock(32, 64), ConvBlock(64, 64), nn.MaxPool2d(2),
ConvBlock(64, 128), nn.AdaptiveAvgPool2d(1),
)
self.head = nn.Sequential(nn.Flatten(), nn.Dropout(0.2), nn.Linear(128, n_classes))
def forward(self, x):
return self.head(self.features(x))
class DepthwiseSeparable(nn.Sequential):
def __init__(self, c_in, c_out, stride=1):
super().__init__(
nn.Conv2d(c_in, c_in, 3, stride=stride, padding=1, groups=c_in, bias=False),
nn.BatchNorm2d(c_in), nn.ReLU(),
nn.Conv2d(c_in, c_out, 1, bias=False),
nn.BatchNorm2d(c_out), nn.ReLU(),
)
class MobileSmall(nn.Module):
def __init__(self, n_classes=10):
super().__init__()
self.features = nn.Sequential(
ConvBlock(3, 24, 2), DepthwiseSeparable(24, 48, 2),
DepthwiseSeparable(48, 96, 2), DepthwiseSeparable(96, 128, 2),
nn.AdaptiveAvgPool2d(1),
)
self.head = nn.Sequential(nn.Flatten(), nn.Linear(128, n_classes))
def forward(self, x):
return self.head(self.features(x))
class ResidualBlock(nn.Module):
def __init__(self, c_in, c_out, stride=1):
super().__init__()
self.residual = nn.Sequential(
ConvBlock(c_in, c_out, stride),
nn.Conv2d(c_out, c_out, 3, padding=1, bias=False),
nn.BatchNorm2d(c_out),
)
self.skip = (
nn.Identity() if c_in == c_out and stride == 1
else nn.Sequential(
nn.Conv2d(c_in, c_out, 1, stride=stride, bias=False),
nn.BatchNorm2d(c_out),
)
)
self.activation = nn.ReLU()
def forward(self, x):
residual, skip = self.residual(x), self.skip(x)
assert residual.shape == skip.shape
return self.activation(residual + skip)
class ResNetSmall(nn.Module):
def __init__(self, n_classes=10):
super().__init__()
self.features = nn.Sequential(
ConvBlock(3, 32), ResidualBlock(32, 32),
ResidualBlock(32, 64, 2), ResidualBlock(64, 128, 2),
nn.AdaptiveAvgPool2d(1),
)
self.head = nn.Sequential(nn.Flatten(), nn.Linear(128, n_classes))
def forward(self, x):
return self.head(self.features(x))
factories = {"vgg_small": VGGSmall, "mobile_small": MobileSmall, "resnet_small": ResNetSmall}
model_labels = {"vgg_small": "小型 VGG", "mobile_small": "小型 MobileNet", "resnet_small": "小型 ResNet"}
probe = torch.zeros(2, 3, 64, 64)
for name, factory in factories.items():
model = factory()
assert model(probe).shape == (2, 10)
print(model_labels[name], "参数量:", sum(p.numel() for p in model.parameters()))
小型 VGG 参数量: 141034
小型 MobileNet 参数量: 22426
小型 ResNet 参数量: 308650
def fit_model(factory, epochs):
torch.manual_seed(SEED)
model = factory().to(DEVICE)
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-4)
history = []
for epoch in range(epochs):
model.train()
losses = []
for xb, yb in train_loader:
xb, yb = xb.to(DEVICE), yb.to(DEVICE)
optimizer.zero_grad()
logits = model(xb)
loss = nn.functional.cross_entropy(logits, yb)
assert torch.isfinite(loss)
loss.backward()
optimizer.step()
losses.append(loss.item())
model.eval()
correct = total = 0
with torch.no_grad():
for xb, yb in valid_loader:
pred = model(xb.to(DEVICE)).argmax(1).cpu()
correct += int((pred == yb).sum())
total += len(yb)
history.append((float(np.mean(losses)), correct / total))
return model, np.asarray(history)
epochs = 2 if FAST_MODE else 12
models, histories = {}, {}
for name, factory in factories.items():
models[name], histories[name] = fit_model(factory, epochs)
print(model_labels[name], "最后一轮训练损失/验证准确率:", histories[name][-1])
小型 VGG 最后一轮训练损失/验证准确率: [1.11316532 0.64 ]
小型 MobileNet 最后一轮训练损失/验证准确率: [1.1470372 0.66 ]
小型 ResNet 最后一轮训练损失/验证准确率: [1.09893958 0.618 ]
def evaluate_and_time(model):
model.eval()
ys, preds = [], []
start = time.perf_counter()
with torch.no_grad():
for xb, yb in test_loader:
logits = model(xb.to(DEVICE))
ys.append(yb.numpy())
preds.append(logits.argmax(1).cpu().numpy())
if DEVICE.type == "cuda":
torch.cuda.synchronize()
elapsed = time.perf_counter() - start
y_true, y_pred = np.concatenate(ys), np.concatenate(preds)
return {
"accuracy": accuracy_score(y_true, y_pred),
"macro_f1": f1_score(y_true, y_pred, average="macro"),
"parameters": sum(p.numel() for p in model.parameters()),
"ms_per_image": 1000 * elapsed / len(y_true),
}
rows = [{"model": "rgb_centroid", **baseline_result, "parameters": 60, "ms_per_image": np.nan}]
for name, model in models.items():
rows.append({"model": name, **evaluate_and_time(model)})
results = pd.DataFrame(rows).set_index("model")
result_labels = {"rgb_centroid": "RGB 统计最近中心", **model_labels}
results_display = results.rename(
index=result_labels,
columns={"accuracy": "准确率", "macro_f1": "宏平均 F1", "parameters": "参数量",
"ms_per_image": "每张图像推断毫秒数"},
).sort_values("准确率", ascending=False)
results_display.index.name = "模型"
display(results_display)
assert np.isfinite(results[["accuracy", "macro_f1", "parameters"]].to_numpy()).all()
print("计时包含数据装载与 Python 开销,只能在同一机器、同一批量设置内相对比较。")
| 准确率 | 宏平均 F1 | 参数量 | 每张图像推断毫秒数 | |
|---|---|---|---|---|
| 模型 | ||||
| 小型 MobileNet | 0.656 | 0.641629 | 22426 | 0.658918 |
| 小型 ResNet | 0.606 | 0.576888 | 308650 | 2.814772 |
| 小型 VGG | 0.600 | 0.592783 | 141034 | 1.310420 |
| RGB 统计最近中心 | 0.380 | 0.357579 | 60 | NaN |
计时包含数据装载与 Python 开销,只能在同一机器、同一批量设置内相对比较。
# 残差恒等性质的局部核验:把残差支路参数置零,输入输出应经最终 ReLU 对齐。
block = ResidualBlock(16, 16).eval()
with torch.no_grad():
for parameter in block.residual.parameters():
parameter.zero_()
x = torch.randn(3, 16, 8, 8)
out = block(x)
assert torch.allclose(out, torch.relu(x), atol=1e-6)
print("残差支路为零时,块输出等于 ReLU(x):核验通过。")
残差支路为零时,块输出等于 ReLU(x):核验通过。
逐步理解本案例#
1. EuroSAT 为什么适合比较卷积结构#
EuroSAT 是由卫星影像裁切得到的土地利用和土地覆盖分类数据。图像具有三个颜色通道,输入维度为 \((批量,3,64,64)\)。类别与纹理、颜色和空间布局有关,因此适合比较不同卷积结构。不过,相邻地理区域的图像块可能非常相似;随机划分图像时,同一地区附近的图像可能分别进入训练集与测试集,从而高估模型在新地区上的表现。在没有完整地点标识的情况下,本案例只把分层随机划分结果作为教学示例,不声称模型具有跨地区推广能力。
读取 ZIP 压缩包时,程序只处理预期的图像文件,并根据文件夹名称获得类别。像素归一化所需的均值和标准差只能根据训练集计算。随机翻转等数据增强只用于训练图像,验证集和测试集使用固定的数据处理方法,避免评价结果因随机增强而变化。
2. 怎样公平比较不同网络结构#
比较 VGG/AlexNet 风格的卷积堆叠、深度可分离卷积和残差网络时,应保持数据划分、输入尺寸、数据增强、优化器、训练轮数和停止规则相同。若某个模型使用了远多于其他模型的训练轮数,准确率差异就不能只归因于网络结构。还应同时报告参数量、固定批量的预测时间和峰值内存;准确率略高但计算与存储代价大幅增加的模型,不一定适合实际使用。
快速模式只使用少量图像和训练轮数,用于检查数据读取、张量维度和后向传播是否正常,不能据此对网络结构进行可靠排名。较完整的实验应使用多个随机种子重复训练,并报告均值、标准差和实际运行时间。
3. 三类核心模块分别做什么#
普通卷积同时组合空间位置和输入通道,参数量约为 \(K^2C_{\mathrm{in}}C_{\mathrm{out}}\)。深度可分离卷积先对每个输入通道分别进行空间卷积,再使用 \(1\times1\) 卷积组合通道,参数量约为 \(K^2C_{\mathrm{in}}+C_{\mathrm{in}}C_{\mathrm{out}}\)。它通常能减少计算量,但真实运行速度还取决于硬件和框架对相应运算的实现。
残差块学习 \(\mathcal{F}(\boldsymbol{x})\),输出为 \(\mathcal{F}(\boldsymbol{x})+\boldsymbol{x}\)。恒等直连分支为梯度提供更直接的传播路径,使较深网络通常更容易训练。当空间尺寸或通道数改变时,需要使用适当步幅或 \(1\times1\) 卷积调整直连分支,使两个分支的维度一致后才能相加。BN 在训练时使用当前批次并更新运行统计量,在推断时使用保存的运行统计量;若忘记切换到评估模式,结果会依赖当前批次的样本组成。Dropout 也只在训练模式下随机屏蔽激活。
4. 怎样理解参数量、速度与错误类型#
参数量可以反映模型参数文件的大致规模,但不能直接表示计算量;浮点运算次数也不能完全代表实际预测时间。计时前应先进行预运行,使用相同批量重复测量;若使用 GPU,还需要在计时点等待设备完成计算。内存不仅用于保存参数,还要保存中间激活,因此训练所需内存通常明显大于预测阶段。
混淆矩阵可以帮助发现河流与高速公路、牧场与草本植被等纹理相近类别之间的持续误判。查看置信度较高的错误样本,有助于发现观测标签噪声、图像裁切问题或模型对背景的过度依赖。如果模型主要依赖颜色,那么季节、传感器或地区变化都可能降低模型表现,因此报告中应明确说明这类数据分布变化的风险。
5. AlexNet、VGG、MobileNet 与 ResNet 的联系#
AlexNet 风格强调较大模型容量、ReLU、Dropout 和数据增强;VGG 使用重复的小卷积构成规则模块;MobileNet 使用深度可分离卷积降低计算代价;ResNet 使用残差连接帮助训练更深的网络。本案例实现的是适配 \(64\times64\) 图像和教学运行时间的缩小版本,不能把实验结果当作原论文完整模型的复现。比较时应区分网络的核心结构思想与原始完整模型。
6. 为什么不把 YOLO 放入同一比较表#
YOLO 用于目标检测,需要边界框观测标签、位置损失、类别损失以及交并比和平均精度等评价指标。EuroSAT 只为整幅图像提供一个类别,因此只能进行图像分类。分类网络和目标检测模型解决的问题不同,不能放在同一张准确率表中比较。本案例只说明它们可能共享卷积特征提取部分,并把目标检测留给具有边界框标注的数据与评价方法。
7. 怎样书写实验结论#
应先说明测试条件,再报告准确率、宏平均 F1、参数量、预测时间和内存,最后说明随机空间划分可能造成的信息泄漏以及数据许可范围。一次随机划分中很小的指标优势不能证明某种网络结构普遍更好。网页只发布静态结果,不发布约 95 MiB 的数据包或训练得到的模型参数;数据通过匿名下载地址写入独立缓存。图题、坐标轴、表头与运行提示均使用中文。
本案例小结与局限#
数据缺少坐标信息,因此本案例无法按照地理区域进行理想的数据划分;随机划分得到的测试分数不能表示模型对新地区的表现;
RGB 图像只使用 Sentinel-2 的三个光学波段,不能代表完整的 13 波段多光谱信息;
快速模式中的少量训练不足以公平确定最佳网络结构,它主要用于检查程序实现、张量维度和比较过程;
参数量不等于实际预测速度。硬件、批量大小、内存读取方式和运算实现都会影响所需时间,因此报告本地计时结果时必须说明运行环境;
图像分类只为整幅图像预测一个类别。YOLO 需要目标框和目标级观测标签,不能直接使用 EuroSAT 当前的整图类别标签训练;
卫星影像会随季节、传感器和地区变化。实际应用还需要评价模型在新地区的表现、概率校准和预测不确定性。
综合练习#
根据公式计算一个 (3\to32)、(3\times3) 普通卷积和对应深度可分离卷积的参数量,并与程序值核对。
固定训练预算,分别移除 ResNet 的 BatchNorm 或把直连支路删除;报告梯度范数和验证曲线。
加入训练时随机水平/垂直翻转,只用训练集增强;讨论哪些增强可能改变土地类别语义。
找到带坐标的 EuroSAT 元数据,设计按地理网格分组的训练/验证/测试划分,并比较随机划分的乐观程度。
用
torch.profiler或等价工具分别报告参数量、乘加量、峰值内存和固定批量延迟,不把单一指标称为“效率”。