# LAB 02 · 参数迁移:冻结、局部与全量微调 按问题继续 同源参数的冻结/局部/全量/scratch与BN模式辨析 对应课堂(1 页) 可先阅读已有结果,再决定是否运行。重算时请核对各节记录的数据、参数、环境与日期。 **学习路径:** [表示怎样迁移到新任务](/course/study/readout-transfer.html) **允许改变参数以后,已有权重是否比从头训练更有用?** 先读目标数字图和四臂参数范围:只训练新头、解冻后部、全部微调、随机起点。随后只按验证交叉熵选择停点和方案,最后读取保留评价。保存协议中scratch较好;用它检查“迁移必然获益”的直觉,而不是换一套口径把结果改好。 留下四行比较,写清起点、哪些参数更新、验证选择与保留结果。[P23](/course/pages/l3-r2-p23.html)区分冻结读出和微调,[解释这次负结果](/course/blocks/adaptation-scope-and-negative-result.html)说明能带走的判断。 输入是MNIST数字≥5,叶片图只是应用情境。重算从准备单元开始,保持源checkpoint不变;需要新结论时另留评价集。只想判断现成表示而不改参数,请用[LAB 01](https://deepnote.com/project/38755e4e-23f6-4f5f-8c68-8a2e4114b1c1/notebook/4dd8605ed9904820919ea9e13d9e7d7c)。 [此段独立阅读](/course/notebook-blocks/60d63838f97a43df9e3cf8ed118ce0da.html) 第三讲课堂停点:[复用表示的理由](/#/l3-r2-p23/0) → [三种更新范围](/#/l3-r2-p23/0)。 [CLS 01 · 学习信号、表示与迁移](/course/c02-signals.html) · [第三讲导读](/course/guide.html) ![白底叶片采集与温室复杂背景中的叶片观察](/assets/display/a28a701a042f8c6410b0b127.webp) 假设任务,效果待验证;AIGC情境插图。 **先从图上找复用的理由,也找改变的条件。** 同一个叶片识别任务,从白底固定拍摄走向温室:叶缘、叶脉和局部纹理仍可能有用;光照、尺度、背景和遮挡却发生变化。已有表示因此值得作为起点,但“值得试”之后,还要比较冻结、局部解冻和全部更新。 为让你现在就能动手,本页实际运行的是有真实标签的**数字是否 ≥5**:源任务为十类 MNIST,新任务为二分类。它练习相同的比较方法,结果属于数字任务。 本独立包携带原 L02-LAB 02 checkpoint、原报告和官方 MNIST 文件;无需访问其他项目的运行内存或文件。加载时校验 SHA-256。新任务 640 张图均来自源训练/源验证未用的官方训练索引,划分为 256 训练、128 验证、256 保留评价。缺少书写者分组,不能声称控制了书写者泄漏。用验证选择,最后查看保留集。 下面在 CPU 上比较三种迁移范围和一个从头训练对照,默认各 4 轮。耗时依环境而定;先读取项目中的数据和模型文件,无付费接口或新增作业义务。 [此段独立阅读](/course/notebook-blocks/67b8a90d213841e2a0334bd1cf4a5e9d.html) ### 准备:本实验的固定材料 [此段独立阅读](/course/notebook-blocks/ee8a87d747b242478019aba89d2e46d3.html) **先读保存结果,再决定是否重算。** 原教师曲线与输入图仍内嵌在本册,历史数值位于 `historical-v16/`。 在当前项目中使用 `lab09-transfer/`。下面的准备代码先检查该目录,若缺失则下载本讲原站的完整包并安全解压;已有文件不覆盖。包内含 `results/cnn-plain-state.pt`、`results/lab02-results.json` 和两份官方 MNIST 训练文件,无须先运行第二讲 notebook。 安装该目录 `requirements.txt` 后从本册顺序运行。模型、数据和源划分会逐项核对;训练四臂是学生可选操作,保存结果已经足以完成讨论。 [此段独立阅读](/course/notebook-blocks/248fcbe45bd74a248982c64b5c7877c5.html)展开完整实现代码 ``` # 当前项目中的独立材料:已存在则复用,缺失才从本讲原站准备。 from pathlib import Path import io, zipfile, urllib.request _base = Path.cwd() _folder = 'lab09-transfer' _sentinel = 'data/train-images-idx3-ubyte.gz' _choices = [_base/_folder, _base/'lecture03-labs'/_folder, _base] _ready = next((p for p in _choices if (p/_sentinel).is_file()), None) if _ready is None: _url = 'https://codingai-lec03.pages.dev/course/materials/' + _folder + '.zip' _payload = urllib.request.urlopen(_url, timeout=60).read() with zipfile.ZipFile(io.BytesIO(_payload)) as _archive: _names = [i.filename for i in _archive.infolist() if not i.is_dir()] _prefix = _folder + '/' _prefixed = all(n.startswith(_prefix) for n in _names) _destination = _base if _prefixed else _base/_folder for _item in _archive.infolist(): _target = (_destination/_item.filename).resolve() if not _target.is_relative_to(_destination.resolve()): raise ValueError('材料包中有越界路径,停止解压。') if _item.is_dir(): _target.mkdir(parents=True, exist_ok=True) elif not _target.exists(): _target.parent.mkdir(parents=True, exist_ok=True) _target.write_bytes(_archive.read(_item)) _ready = next((p for p in _choices if (p/_sentinel).is_file()), None) assert _ready is not None, '请将本讲完整材料包解压到当前项目的 ' + _folder + '/;保留文件结构。' LAB_ROOT = _ready print('材料目录已就绪:', LAB_ROOT) import copy, gzip, hashlib, io, json, os, time from pathlib import Path from collections import OrderedDict import numpy as np import matplotlib.pyplot as plt from matplotlib.patches import Rectangle import torch from torch import nn from torch.nn import functional as F torch.set_num_threads(2) torch.manual_seed(20260917) ROOT=LAB_ROOT if not (ROOT/'data').is_dir() and (ROOT/'lab09-transfer/data').is_dir():ROOT=ROOT/'lab09-transfer' assert (ROOT/'data').is_dir(), '请解压独立 lab09-transfer 包并保留目录结构。' OUT=ROOT/'runs/current';OUT.mkdir(parents=True,exist_ok=True) ``` [此段独立阅读](/course/notebook-blocks/186427ff91eb43f1841c3fb1342dc90a.html) **重算入口:**当前项目的 `lab09-transfer/` 是明确文件依赖。清空内存后,从本册准备代码顺序运行;不借用其他 notebook 的变量。 [此段独立阅读](/course/notebook-blocks/56ef8a09f7ad45788d3f8dba0f976821.html)展开完整实现代码 ``` MODEL_PATH=ROOT/'results/cnn-plain-state.pt' REPORT_PATH=ROOT/'results/lab02-results.json' DATA=ROOT/'data' files={ 'train-images-idx3-ubyte.gz':'440fcabf73cc546fa21475e81ea370265605f56be210a4024d2ca8f203523609', 'train-labels-idx1-ubyte.gz':'3552534a0a558bbed6aed32b30c495cca23d567ec52cac8be1a0730e8010255c'} missing=[str(p) for p in [MODEL_PATH,REPORT_PATH,*[DATA/n for n in files]] if not p.is_file()] if missing: raise RuntimeError('独立材料包不完整,请重新解压 data/ 与 results/:'+', '.join(missing)) report=json.loads(REPORT_PATH.read_text()) protocol=report['protocol'] assert protocol['split_seed']==20260908 and protocol['train_n']==12000, '本练习要求 LAB 02 默认源训练划分;改变划分后需要重新检查源/目标重叠。' raw={} for name,sha in files.items(): raw[name]=(DATA/name).read_bytes() assert hashlib.sha256(raw[name]).hexdigest()==sha, 'MNIST 文件不匹配:'+name images=np.frombuffer(gzip.decompress(raw['train-images-idx3-ubyte.gz']),dtype=np.uint8,offset=16).reshape(60000,1,28,28) digit_labels=np.frombuffer(gzip.decompress(raw['train-labels-idx1-ubyte.gz']),dtype=np.uint8,offset=8) partition=np.random.default_rng(20260908).permutation(60000) expected_partition_sha=hashlib.sha256(partition.tobytes()).hexdigest() expected_train_sha=hashlib.sha256(partition[:12000].tobytes()).hexdigest() expected_val_sha=hashlib.sha256(partition[50000:].tobytes()).hexdigest() assert protocol['train_ids_sha256']==expected_train_sha assert protocol['partition_sha256']==expected_partition_sha and protocol['validation_n']==10000 state_bytes=MODEL_PATH.read_bytes() model_sha256=hashlib.sha256(state_bytes).hexdigest() COURSE_MODEL_SHA256='ba8b738cc67d4ec3cd83d75a5dbe8a70ec548377335ba0abaaa7c23cd14eeba3' identity=report.get('checkpoint_provenance',{}).get('cnn-plain-state.pt') if identity is not None: assert identity['sha256']==model_sha256, '模型与 LAB 02 报告不是同一轮保存的文件;请重新运行 LAB 02 第4节和保存结果单元。' source=identity['source_protocol'] assert source['split_seed']==20260908 and source['train_n']==12000 and source['validation_n']==10000 assert source['partition_sha256']==expected_partition_sha assert source['train_ids_sha256']==expected_train_sha and source['validation_ids_sha256']==expected_val_sha source_files={item['file']:item['sha256'] for item in source['files']} assert all(source_files.get(name)==sha for name,sha in files.items()), '源训练数据身份与本练习不一致。' identity_basis='LAB02 training-time checkpoint hash and source protocol' elif model_sha256==COURSE_MODEL_SHA256: # 已追溯的课程旧模型兼容旧报告;此分支仅接受这一份确切文件。 identity_basis='known course checkpoint SHA-256 with verified default source split' else: raise RuntimeError('当前模型缺少与报告绑定的训练来源。请重新运行更新后的 LAB 02 第4节及保存结果单元;不要给旧文件补写哈希。') target_ids=partition[12000:12640] assert not set(target_ids)&set(partition[:12000]) assert not set(target_ids)&set(partition[50000:]) target_buffer=io.BytesIO() np.savez_compressed(target_buffer,images=images[target_ids],digit_labels=digit_labels[target_ids], official_train_ids=target_ids,split=np.array([0]*256+[1]*128+[2]*256,dtype=np.uint8)) provenance={'dataset':'MNIST official training set','source_task':'ten-class digit classification', 'source_checkpoint_sha256':model_sha256,'source_identity_basis':identity_basis,'source_origin':'bundled original LAB02 protected checkpoint; cross-project independent package', 'target_task':'digit >= 5 (binary)','source_split_seed':20260908, 'source_train_permutation_positions':[0,12000],'source_validation_permutation_positions':[50000,60000], 'target_permutation_positions':[12000,12640],'target_split_sizes':{'train':256,'validation':128,'heldout':256}, 'target_ids_sha256':hashlib.sha256(target_ids.tobytes()).hexdigest(), 'limitations':['single small digit task','source/target sample indices disjoint; writer groups unavailable','not a leaf experiment']} asset_bytes={'cnn-plain-state.pt':state_bytes,'target.npz':target_buffer.getvalue(), 'target-provenance.json':json.dumps(provenance).encode()} print('使用包内已核验模型;目标样本与默认源训练/验证索引不重叠。') ``` [此段独立阅读](/course/notebook-blocks/cb2b6dec25874e6a9654f27808e15529.html)展开完整实现代码 ``` source_state=torch.load(io.BytesIO(asset_bytes['cnn-plain-state.pt']),map_location='cpu',weights_only=True) def make_cnn(): return nn.Sequential(OrderedDict([ ('conv1',nn.Conv2d(1,8,3,padding=1)),('relu1',nn.ReLU()),('pool1',nn.MaxPool2d(2)), ('conv2',nn.Conv2d(8,16,3,padding=1)),('relu2',nn.ReLU()),('pool2',nn.MaxPool2d(2)), ('flatten',nn.Flatten()),('hidden',nn.Linear(784,32)),('relu3',nn.ReLU()),('logits',nn.Linear(32,10))])) source_model=make_cnn();source_model.load_state_dict(source_state);source_model.eval() assert sum(p.numel() for p in source_model.parameters())==26698 print('已读取独立包内的 26698 参数 CNN;PyTorch',torch.__version__) print('当前模型 SHA-256:',model_sha256) ``` [此段独立阅读](/course/notebook-blocks/97d6413de80249e1986f2ac8fa35a0db.html) ### 1. 先写预测,再确定协议 [此段独立阅读](/course/notebook-blocks/58bc52fbf8f147819e934fbe8e30546a.html) **标签变了,输入的笔画还在。** 下面是目标训练集的前 8 个例子;y=1 表示数字 ≥5,y=0 表示数字 <5。 ![真实目标训练样本及数字是否大于等于5的新标签](/assets/display/4b8038d5a4d30dbb2d163963.webp) 已保存的默认输入:官方 MNIST 训练图像,按本页固定索引取样,灰度除以 255,统一右移 2 像素、边界补零。图来自此前本地执行;不是叶片数据或生成数字。 源表示见过数字形态,却没用过这 640 个样本。已有表示是否有用、冻结是否足够、解冻越多是否越好,是三个不同问题。先选你预计最好的方案,并写一个可能推翻判断的结果。 `TRAIN_LIMIT` 控制训练量,`DX` 控制固定水平位移,`EPOCHS` 控制轮数;先运行默认值。三种迁移共用源检查点、新头初始化、批次顺序、优化器和预算。可更新参数与每步计算成本不同,不能称为等算力比较;从头训练检验起点的作用。改协议后就是新实验。 [此段独立阅读](/course/notebook-blocks/dedeadf5249d49bcb62e1ae35677dada.html)展开完整实现代码 ``` TRAIN_LIMIT=256 DX=2 EPOCHS=4 BATCH_SIZE=64 LEARNING_RATE=0.002 SEED=20260917 with np.load(io.BytesIO(asset_bytes['target.npz']),allow_pickle=False) as z: target={k:z[k].copy() for k in z.files} provenance=json.loads(asset_bytes['target-provenance.json']) assert 16<=TRAIN_LIMIT<=256 and -4<=DX<=4 and EPOCHS>=1 x=torch.tensor(target['images'],dtype=torch.float32)/255 def shift_x(a,dx): b=torch.zeros_like(a) if dx>=0:b[:,:,:,dx:]=a[:,:,:,:a.shape[-1]-dx] else:b[:,:,:,:dx]=a[:,:,:,-dx:] return b x=shift_x(x,DX) y=torch.tensor((target['digit_labels']>=5).astype(np.int64)) train_idx=np.flatnonzero(target['split']==0)[:TRAIN_LIMIT] val_idx=np.flatnonzero(target['split']==1) heldout_idx=np.flatnonzero(target['split']==2) train_x,train_y=x[train_idx],y[train_idx] val_x,val_y=x[val_idx],y[val_idx] assert len(set(target['official_train_ids']))==640 assert not set(train_idx)&set(val_idx) and not set(train_idx)&set(heldout_idx) print('训练/验证/保留:',len(train_idx),len(val_idx),len(heldout_idx),'固定横移',DX) print('训练标签计数',torch.bincount(train_y,minlength=2).tolist(),'验证标签计数',torch.bincount(val_y,minlength=2).tolist()) fig,axes=plt.subplots(1,8,figsize=(12,2)) for i,ax in enumerate(axes): ax.imshow(train_x[i,0],cmap='gray',vmin=0,vmax=1);ax.set_title(f'digit {target["digit_labels"][train_idx[i]]}: y={train_y[i].item()}');ax.axis('off') plt.show() ``` [此段独立阅读](/course/notebook-blocks/97b118d3eb0d429aaf8b3875bda3f348.html) ### 2. “冻住”到底冻住哪些参数? [此段独立阅读](/course/notebook-blocks/1c379ad583d74af7839f0841395946a8.html) **先沿一行读“从哪里开始”,再读“哪些部分会动”。** 四行都是同一小 CNN 的二分类版本。 ![head、late、all、scratch四种起点和可更新参数范围](/assets/display/de50ac0b5652b763013d0e09.webp) 结构示意,由本页模型定义与参数计数绘制。灰:读取源参数并冻结;绿:读取源参数并更新;黄:新分类头;蓝:随机起点并更新。图中 66/26354/26434 是可更新参数数,尚不是效果。 `head` 只更新新头;`late` 更新第二卷积层、32 维隐藏层与新头;`all` 更新全部层。`scratch` 不读预训练权重。**先核对:** late 与 all 只相差 80 个可更新参数,它们对应图中的哪一块?为什么“少解冻一层”不一定意味着参数大幅减少? 代码会实际核对可更新参数。这个模型没有 BatchNorm 或 Dropout;含这些层时,冻结参数还需单独决定模式与状态,见[第二讲 LAB05 · 经典架构与视觉表示](https://codingai-lec02.pages.dev/course/lab05.html)与[第二讲 LAB03 · 架构实验室](https://codingai-lec02.pages.dev/course/lab03.html)。 [此段独立阅读](/course/notebook-blocks/066b6fe08efe4c2fbd578d9b716eda96.html)展开完整实现代码 ``` MODES=['head','late','all','scratch'] def make_target_model(mode): assert mode in MODES torch.manual_seed(SEED) model=make_cnn() if mode!='scratch':model.load_state_dict(source_state) torch.manual_seed(SEED+1) model.logits=nn.Linear(32,2) for name,param in model.named_parameters(): param.requires_grad_(mode in ('all','scratch') or name.startswith('logits.') or (mode=='late' and name.startswith(('conv2.','hidden.')))) return model scope_table=[] for mode in MODES: model=make_target_model(mode) scope_table.append({'mode':mode,'trainable':sum(p.numel() for p in model.parameters() if p.requires_grad),'total':sum(p.numel() for p in model.parameters())}) print(json.dumps(scope_table,indent=2)) ``` [此段独立阅读](/course/notebook-blocks/79753dc070ca46108d4cc8ab56dbe083.html) ### 3. 运行比较:用验证集选每组的停点 [此段独立阅读](/course/notebook-blocks/0495306a95d840a5837582faa71e8a04.html) **先看一个真实的、可能推翻直觉的参考结果。** 每轮结束冻结当前模型计算训练与验证交叉熵,各组保留验证交叉熵最低的检查点。 ![四种训练范围的真实训练和验证损失,scratch在本协议中最低](/assets/display/47c2e01ecbe2f0f54b67b0cb.webp) 已保存本地运行:课件源检查点 `ba8b738c…14eeba3`,256 训练/128 验证,统一右移 2px,seed=20260917,Adam lr=.002,batch=64,各 4 轮。两图纵轴相同;右图空心圈是各组所选停点。最低验证 CE:head 1.906、late .877、all .849、scratch .611。本图读取已保存数值绘制;没有把它写成此次 Deepnote 云端输出。 **先回答:** 按预先写好的验证选择规则,这次应该选谁?scratch 获胜说明当前协议里预训练并未自动带来优势;它不能证明迁移普遍无效。短预算、相同学习率和这项特定二分类都限制结论。 下面会训练你的 4 个小模型,不覆盖既有源模型与课件响应。先保持默认协议核对,再进入最后的保留集评价;此时不能用保留结果回头挑更新范围。 [此段独立阅读](/course/notebook-blocks/d3888686b40d4fe0a93aac929dce690a.html)展开完整实现代码 ``` @torch.no_grad() def evaluate_binary(model,xx,yy): model.eval();scores=model(xx) return {'ce':float(F.cross_entropy(scores,yy)), 'accuracy':float((scores.argmax(1)==yy).float().mean())} torch.use_deterministic_algorithms(True) generator=torch.Generator().manual_seed(SEED+2) orders=[torch.randperm(len(train_y),generator=generator) for _ in range(EPOCHS)] models={};records={} for mode in MODES: model=make_target_model(mode) frozen_before={n:p.detach().clone() for n,p in model.named_parameters() if not p.requires_grad} optimizer=torch.optim.Adam([p for p in model.parameters() if p.requires_grad],lr=LEARNING_RATE) best_ce=float('inf');history=[];start=time.perf_counter() for epoch,order in enumerate(orders,1): model.train() for ids in order.split(BATCH_SIZE): optimizer.zero_grad();loss=F.cross_entropy(model(train_x[ids]),train_y[ids]);loss.backward();optimizer.step() tr=evaluate_binary(model,train_x,train_y);va=evaluate_binary(model,val_x,val_y) history.append({'epoch':epoch,'train_ce':tr['ce'],'val_ce':va['ce'],'val_accuracy':va['accuracy']}) if va['ce']