Lecture 03 学习讲义
正文
工具
按问题查找
区域、像素与实例类别与位置怎样成为可学习的区域目标?

LAB 10 · 从 RGB 图像训练小型检测器

主线阅读与优先实验

RGB图像、单特征层锚框检测器、分类/位置损失、NMS、独立检测评价

对应课堂(7 页)

可先阅读已有结果,再决定是否运行。重算时请核对各节记录的数据、参数、环境与日期。

从 P32–35 的检测流程 进入一项完整学习实验:48×48 RGB → 共享特征 → 类别与框偏移 → 解码/NMS → 保留图像评价。真值框只进入目标与评价,不进入模型输入。

先看 已有结果与失败图,挑一张解释哪里没检好,再回到输入协议、匹配和两个输出头。四维逻辑回归只是可选分类热身;主实验是 TinySSD 闭环的单特征层教学变体。完整复跑按格顺序执行,另写 lab10-results-rerun,不覆盖历史证据。

本册的产出是一条错误分析:它是漏检、错类、框偏还是重复?你会只改哪个条件来验证?遇到公式去 LAB 09 或 CLS 07;想研究轮廓而非包围框,可另选 LAB 11,不必为完成本册再训练第二项。

此段独立阅读
区域、像素与实例类别与位置怎样成为可学习的区域目标?

选读热身:四维分类的参数更新

四维向量 xx 经过 p=σ(xTw)p=\sigma(x^T w),交叉熵梯度是 XT(p−y)/NX^T(p-y)/N。这里确实有参数学习,但四个数不是图像,也没有框目标;准确率只能说明这个人为超平面能否被重获。预测先于运行:只把训练标签打乱,保留集仍按原超平面给真值,模型还能恢复原规则吗?随机标签不提供真实规律,但单次有限样本分数不必恰好等于 .5。这个对照检查的是监督关系,不涉及定位。

下格保留原数据、200 次更新与权重输出,补上独立保留样本及标签打乱条件;明确把原来的 “objectness accuracy” 改为四维分类热身的训练准确率。本次实跑:训练准确率 .953125,独立同规则保留集 .94921875,打乱训练标签后在同一保留集仅 .36328125;这个下降也不能变成检测能力的证据。

若已经熟悉 sigmoid 与交叉熵,可直接阅读“主实验的输入、目标和固定协议”;完整复跑仍按格顺序执行即可。

此段独立阅读
展开完整实现代码
import os, tempfile
from pathlib import Path
# Isolated ephemeral workspace supports detached runs with readonly project storage.
SPATIAL_RUN_DIR = Path(tempfile.mkdtemp(prefix='lecture03-'))
os.chdir(SPATIAL_RUN_DIR)
print('Ephemeral experiment directory:', SPATIAL_RUN_DIR)
此段独立阅读
展开完整实现代码
import numpy as np
rng=np.random.default_rng(7)
X=rng.normal(size=(64,4)); true=np.array([.8,-.4,.3,.2]); y=(X@true>0).astype(float);w=np.zeros(4)
for step in range(200):
    p=1/(1+np.exp(-X@w));w-=.2*(X.T@(p-y))/len(y)
print('four-dimensional logistic warm-up, training accuracy:',float(((X@w>0)==y).mean()))
print('learned weights:',w.round(3))
# A fresh held-out matrix answers a different question from training accuracy.
held=np.random.default_rng(107).normal(size=(256,4));held_y=held@true>0
print('same-rule held-out accuracy:',float(((held@w>0)==held_y).mean()))
shuffled=np.random.default_rng(17).permutation(y);ws=np.zeros(4)
for step in range(200):
    ps=1/(1+np.exp(-X@ws));ws-=.2*(X.T@(ps-shuffled))/len(shuffled)
print('only training labels shuffled, held-out true-rule accuracy:',float(((held@ws>0)==held_y).mean()))
此段独立阅读
区域、像素与实例类别与位置怎样成为可学习的区域目标?

主实验的输入、目标和固定协议

每张输入是 [3,48,48] 浮点 RGB 图像。目标字典包含 [M,4] 的归一化 xyxy 框及 [M] 类别,M∈{0,1,2,3}M\in\{0,1,2,3\}。红色矩形为 1,蓝色椭圆为 2,背景为 0。位置、尺寸、亮度和对象数变化;18% 的生成概率留给空图。空图要教模型输出“没有目标”,不是强迫每图选一个框。目标框只进入损失与评价,model(images) 的参数里没有坐标或标签。

768 张训练图、96 张验证图、192 张保留图使用不同原始场景种子 1101/3101/5901。训练固定 28 epoch、Adam 学习率 .002、batch 32;验证集仅给固定方案的读数,没有用保留集反复挑最好超参数。本次保留集实际有 296 个对象、33 张空图、108 张多对象图。合成类别同时与颜色、轮廓相关,所以它适合验证图像到位置的学习链,不能证明自然图像的形状泛化。

在同一个原始场景种子下将尺寸乘以 .5,背景、类别、颜色、中心、对象数不变,只重新光栅化尺寸及其框。它是可核验的尺度对照。下面源码完整可见;首格覆盖当前脚本,后续格顺序追加,因此请从上到下运行。

此段独立阅读
展开完整实现代码
import importlib.util, subprocess, sys
from pathlib import Path
# The only download, if needed, is open-source Python packages, not model weights.
if importlib.util.find_spec('torch') is None:
    subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'torch', '--index-url', 'https://download.pytorch.org/whl/cpu'])
missing=[p for p in ['numpy','matplotlib'] if importlib.util.find_spec(p) is None]
if missing: subprocess.check_call([sys.executable,'-m','pip','install',*missing])
import torch, numpy as np
print('Python',sys.version.split()[0], 'torch',torch.__version__, 'numpy',np.__version__, 'device: CPU')
此段独立阅读
区域、像素与实例类别与位置怎样成为可学习的区域目标?

读源码时跟踪三种集合

数据生成器保存真实图像与坐标;固定锚框是一组与图像真值无关的预测模板;最终预测框才来自网络。不要把这三者混为一谈。第一段还定义 IoU、按类别 NMS 与 AP。它们共用同一种半开区间 xyxy 几何,不使用整数像素的“面积加一”约定。

AP 对每个类别汇总全数据集预测、按分数递减排列;每个预测找当前图像同类真值的最大 IoU,达到阈值且该真值没用过才记 TP,否则记 FP。重复检测不会增加召回。代码把 precision 从右向左取包络,再对 recall 增量求和。这是 all-points AP,不是 COCO 的多个 IoU 阈值平均;我们分别报告 AP50、AP75。

此段独立阅读
展开完整实现代码
%%writefile lab10_detector.py
"""LAB10: train and evaluate an image-to-box anchor detector, CPU, no downloads.
Run: python lab10_detector.py --out lab10-results --epochs 28
Dependencies: torch numpy matplotlib. This script is self-contained.
"""
"""Shared transparent geometry and image construction for LAB10/11; no downloads."""
import json, hashlib, platform, sys, time
from pathlib import Path
from datetime import datetime, timezone
import numpy as np
import torch
from torch import nn
from torch.nn import functional as F
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle

def seed_all(seed=7):
    np.random.seed(seed); torch.manual_seed(seed); torch.set_num_threads(2)

def iou_matrix(a,b):
    if len(a)==0 or len(b)==0: return torch.zeros((len(a),len(b)))
    lo=torch.maximum(a[:,None,:2],b[None,:,:2]); hi=torch.minimum(a[:,None,2:],b[None,:,2:])
    inter=(hi-lo).clamp(min=0).prod(-1)
    area_a=(a[:,2:]-a[:,:2]).clamp(min=0).prod(-1)
    area_b=(b[:,2:]-b[:,:2]).clamp(min=0).prod(-1)
    return inter/(area_a[:,None]+area_b[None,:]-inter).clamp(min=1e-8)

def make_scenes(n,seed,size=48,scale=1.0):
    """Independent seed defines original sample, scale changes only object size.
    Boxes xyxy normalized; red rectangle=1, blue ellipse=2; zero to three objects.
    Images are rasterized from latent scene, labels are never a network input.
    """
    rng=np.random.default_rng(seed); xs=[]; ys=[]; masks=[]
    yy,xx=np.mgrid[:size,:size]
    for index in range(n):
        image=rng.uniform(.035,.095,(3,size,size)).astype('float32')
        num=int(rng.choice([0,1,2,3],p=[.18,.30,.36,.16]))
        boxes=[]; labels=[]; mask=np.zeros((size,size),np.int64)
        original=[]
        for obj in range(num):
            for attempt in range(60):
                w,h=rng.integers(9,19,size=2); x=rng.integers(2,size-w-2); y=rng.integers(2,size-h-2)
                box=np.array([x,y,x+w,y+h],np.float32)/size
                if not original or iou_matrix(torch.tensor(np.array(original)),torch.tensor(box[None])).max()<.08: break
            original.append(box); label=int(rng.integers(1,3)); color=rng.uniform(.7,.98)
            cx,cy=(box[0]+box[2])/2,(box[1]+box[3])/2
            sw,sh=(box[2]-box[0])*scale,(box[3]-box[1])*scale
            x1,y1,x2,y2=np.round(np.array([cx-sw/2,cy-sh/2,cx+sw/2,cy+sh/2])*size).astype(int)
            x2=max(x2,x1+2); y2=max(y2,y1+2)
            region=(xx>=x1)&(xx<x2)&(yy>=y1)&(yy<y2)
            if label==2: region &= ((xx+.5-(x1+x2)/2)/((x2-x1)/2))**2+((yy+.5-(y1+y2)/2)/((y2-y1)/2))**2<=1
            image[:,region]*=.2
            image[0 if label==1 else 2,region]=color
            image[1,region]=.15+color*.12
            mask[region]=label
            boxes.append([x1/size,y1/size,x2/size,y2/size]); labels.append(label)
        xs.append(image); masks.append(mask)
        ys.append({'boxes':torch.tensor(boxes,dtype=torch.float32).reshape(-1,4),'labels':torch.tensor(labels,dtype=torch.long),'sample_id':f'{seed}:{index}'})
    return torch.from_numpy(np.stack(xs)),ys,torch.from_numpy(np.stack(masks))

def nms(boxes,scores,threshold=.35):
    order=torch.argsort(scores,descending=True,stable=True); keep=[]
    while len(order):
        i=order[0]; keep.append(i.item()); order=order[1:]
        if len(order): order=order[iou_matrix(boxes[i:i+1],boxes[order])[0]<=threshold]
    return torch.tensor(keep,dtype=torch.long)

def average_precision(predictions,targets,iou_threshold=.5,score_threshold=0):
    """Per-class global score order, max-IoU GT then unused check; all-points AP."""
    result={}; all_tp=all_fp=all_gt=0
    for cls in [1,2]:
        gt={i:t['boxes'][t['labels']==cls] for i,t in enumerate(targets)}
        used={i:set() for i in gt}; total=sum(len(v) for v in gt.values())
        rows=[]
        for i,p in enumerate(predictions):
            for j in range(len(p['scores'])):
                if int(p['labels'][j])==cls and float(p['scores'][j])>=score_threshold:
                    rows.append((float(p['scores'][j]),i,j,p['boxes'][j]))
        rows.sort(key=lambda r:(-r[0],r[1],r[2])); tp=[]; fp=[]
        for score,i,j,b in rows:
            ok=False
            if len(gt[i]):
                overlaps=iou_matrix(b[None],gt[i])[0]; best=int(overlaps.argmax())
                ok=float(overlaps[best])>=iou_threshold and best not in used[i]
                if ok:used[i].add(best)
            tp.append(int(ok));fp.append(int(not ok))
        tp=np.cumsum(tp);fp=np.cumsum(fp)
        recall=tp/max(total,1); precision=tp/np.maximum(tp+fp,1)
        mr=np.r_[0,recall,1];mp=np.r_[0,precision,0]
        mp=np.maximum.accumulate(mp[::-1])[::-1];loc=np.where(mr[1:]!=mr[:-1])[0]
        ap=float(((mr[loc+1]-mr[loc])*mp[loc+1]).sum()) if total else None
        result[str(cls)]={'AP':ap,'GT':total,'TP':int(tp[-1]) if len(tp) else 0,'FP':int(fp[-1]) if len(fp) else 0}
        all_tp+=result[str(cls)]['TP'];all_fp+=result[str(cls)]['FP'];all_gt+=total
    return {'per_class':result,'mAP':float(np.mean([r['AP'] for r in result.values() if r['AP'] is not None])),
            'precision':all_tp/max(all_tp+all_fp,1),'recall':all_tp/max(all_gt,1),'iou_threshold':iou_threshold,'score_threshold':score_threshold}

def provenance(path,started):
    p=Path(path)
    return {'utc_started':started,'utc_completed':datetime.now(timezone.utc).isoformat(),'execution':'CPU, complete script',
            'python':sys.version,'torch':torch.__version__,'numpy':np.__version__,'platform':platform.platform(),'threads':torch.get_num_threads(),
            'code_sha256':hashlib.sha256(p.read_bytes()).hexdigest(),
            'device':'CPU', 'source_file':p.name}

def save_json(path,data):Path(path).write_text(json.dumps(data,indent=2,ensure_ascii=False))

def segmentation_metrics(pred,target):
    valid=target!=255; t=target[valid];p=pred[valid]
    cm=torch.bincount(t*3+p,minlength=9).reshape(3,3).double()
    intersection=cm.diag();union=cm.sum(0)+cm.sum(1)-intersection
    iou=intersection/union.clamp(min=1)
    return {'IoU_background':float(iou[0]),'IoU_red_rectangle':float(iou[1]),'IoU_blue_ellipse':float(iou[2]),
            'mIoU_3_classes':float(iou[union>0].mean()),'pixel_accuracy':float(intersection.sum()/cm.sum()),
            'valid_pixels':int(valid.sum()),'ignored_pixels':int((~valid).sum()),'confusion_matrix_true_rows':cm.int().tolist()}

import argparse

此段独立阅读
区域、像素与实例类别与位置怎样成为可学习的区域目标?

固定锚框怎样变成待学习的偏移

骨干把 48×4848\times48 图像映射为 12×1212\times12 特征;每格两个正方形锚框,边长 .23、.37,总共 288 个。这是 TinySSD 核心流程的单特征层教学变体,保留多尺寸锚框,没有照搬 D2L 的多层特征金字塔。

一个锚框的中心为 (ax,ay)(a_x,a_y)、宽高 (aw,ah)(a_w,a_h),目标框为 (bx,by,bw,bh)(b_x,b_y,b_w,b_h)。回归目标为 ((bx−ax)/aw,(by−ay)/ah,log⁡(bw/aw),log⁡(bh/ah))((b_x-a_x)/a_w,(b_y-a_y)/a_h,\log(b_w/a_w),\log(b_h/a_h))。解码使用逆变换,所以网络在每个位置学四个实数,而不是直接从真值读取位置。decode(anchors,encode(anchors,boxes)) 的往返检验在有效框上通过。

此段独立阅读
区域、像素与实例类别与位置怎样成为可学习的区域目标?

哪些锚框受哪一种监督

IoU≥.45 的锚框为正例,IoU<.25 为背景,中间区间为 ignore=-1。另用逐次全局最大 IoU 给每个真值保留一个不同的锚框;它可能低于 .45,目的是避免一个真值完全没有回归监督。这个训练强制匹配不能用于评价“凑足召回”。空图的所有锚框都是真背景。

分类损失取所有正例和最难负例,负例最多为正例数的 3 倍,每图至少 12 个;ignore 不参加。位置损失仅在正例上计算 Smooth L1,均值包含四个坐标。总损失 L=Lcls+3LboxL=L_{cls}+3L_{box}。因此空图仍有分类梯度,没有位置梯度;一个忽略锚框的分类梯度也应为零,已用针对性检查确认。

此段独立阅读
展开完整实现代码
%%writefile -a lab10_detector.py
def anchors_grid(grid=12,sizes=(.23,.37)):
    boxes=[]
    for y in range(grid):
        for x in range(grid):
            for s in sizes:boxes.append([(x+.5)/grid-s/2,(y+.5)/grid-s/2,(x+.5)/grid+s/2,(y+.5)/grid+s/2])
    return torch.tensor(boxes)

def encode(anchors,boxes):
    ac=(anchors[:,:2]+anchors[:,2:])/2;aw=anchors[:,2:]-anchors[:,:2]
    bc=(boxes[:,:2]+boxes[:,2:])/2;bw=boxes[:,2:]-boxes[:,:2]
    return torch.cat([(bc-ac)/aw,torch.log((bw/aw).clamp(min=1e-6))],1)

def decode(anchors,offset):
    ac=(anchors[:,:2]+anchors[:,2:])/2;aw=anchors[:,2:]-anchors[:,:2]
    bc=ac+offset[:,:2]*aw;bw=aw*torch.exp(offset[:,2:].clamp(-3,3))
    return torch.cat([bc-bw/2,bc+bw/2],1).clamp(0,1)

def match_targets(anchors,target):
    """-1 ignore, 0 background, 1/2 object. Each GT gets a distinct forced anchor."""
    classes=torch.zeros(len(anchors),dtype=torch.long);offsets=torch.zeros_like(anchors)
    if not len(target['boxes']): return classes,offsets
    overlap=iou_matrix(anchors,target['boxes']);best,best_gt=overlap.max(1)
    classes[(best>=.25)&(best<.45)]=-1
    positive=best>=.45
    # Greedy global maximum, invalidate its anchor and GT; guarantees all GT assigned.
    work=overlap.clone()
    for _ in range(len(target['boxes'])):
        flat=int(work.argmax()); a=flat//work.shape[1];g=flat%work.shape[1]
        positive[a]=True;best_gt[a]=g;work[a,:]=-1;work[:,g]=-1
    classes[positive]=target['labels'][best_gt[positive]]
    offsets[positive]=encode(anchors[positive],target['boxes'][best_gt[positive]])
    return classes,offsets

此段独立阅读
区域、像素与实例类别与位置怎样成为可学习的区域目标?

从共享特征到两个输出头

四个卷积层形成 45,806 参数的模型。分类 logits 为 [B,288,3],框偏移为 [B,288,4]。两个头共享空间特征,分类头的“有目标”分数和框头的“偏移多少”要同时学好;分类分数接近 1 并不保证坐标足够准确。

推理只接收 RGB,解码所有候选,对每一前景类别分别做 NMS,默认抑制 IoU>.35 的低分框。保存候选的分数下限为 .01,绘图与 precision/recall 的工作点为 .4。候选阈值、NMS 的 IoU 阈值、评价的 IoU 阈值是三个不同控制量。

此段独立阅读
展开完整实现代码
%%writefile -a lab10_detector.py
class TinyAnchorDetector(nn.Module):
    def __init__(self):
        super().__init__()
        self.backbone=nn.Sequential(nn.Conv2d(3,16,3,2,1),nn.ReLU(),nn.Conv2d(16,32,3,2,1),nn.ReLU(),
                                    nn.Conv2d(32,48,3,1,1),nn.ReLU(),nn.Conv2d(48,48,3,1,1),nn.ReLU())
        self.class_head=nn.Conv2d(48,2*3,3,padding=1);self.box_head=nn.Conv2d(48,2*4,3,padding=1)
    def forward(self,x):
        z=self.backbone(x);b=x.shape[0]
        return self.class_head(z).permute(0,2,3,1).reshape(b,-1,3),self.box_head(z).permute(0,2,3,1).reshape(b,-1,4)

def detection_loss(logits,offsets,classes,targets):
    ce=F.cross_entropy(logits.transpose(1,2),classes,ignore_index=-1,reduction='none')
    positive=classes>0;chosen=positive.clone()
    for i in range(len(classes)):
        negatives=torch.where(classes[i]==0)[0]; k=min(len(negatives),max(12,3*int(positive[i].sum())))
        chosen[i,negatives[torch.argsort(ce[i,negatives],descending=True)[:k]]]=True
    cls=ce[chosen].mean()
    loc=F.smooth_l1_loss(offsets[positive],targets[positive],reduction='mean') if positive.any() else offsets.sum()*0
    return cls+3*loc,cls,loc

@torch.no_grad()
def predict(model,images,anchors,score=.01,nms_iou=.35):
    model.eval();result=[]
    for batch in images.split(32):
        logits,offsets=model(batch); probs=logits.softmax(-1)
        for p,o in zip(probs,offsets):
            boxes=decode(anchors,o); all_b=[];all_s=[];all_l=[]
            for cls in [1,2]:
                indices=torch.where(p[:,cls]>=score)[0]
                keep=nms(boxes[indices],p[indices,cls],nms_iou);indices=indices[keep]
                all_b.append(boxes[indices]);all_s.append(p[indices,cls]);all_l.append(torch.full((len(indices),),cls))
            result.append({'boxes':torch.cat(all_b),'scores':torch.cat(all_s),'labels':torch.cat(all_l)})
    return result

此段独立阅读
展开完整实现代码
%%writefile -a lab10_detector.py
def report_predictions(pred,targets):
    empties=[i for i,t in enumerate(targets) if not len(t['boxes'])]
    multi=[i for i,t in enumerate(targets) if len(t['boxes'])>=2]
    return {'AP50':average_precision(pred,targets,.5),'AP75':average_precision(pred,targets,.75),
            'operating_point':average_precision(pred,targets,.5,.4),
            'empty_images':len(empties),'empty_false_positive_images_score04':sum(int((pred[i]['scores']>=.4).any()) for i in empties),
            'multi_object_images':len(multi),'multi_object_AP50':average_precision([pred[i] for i in multi],[targets[i] for i in multi],.5)}

def draw_predictions(images,targets,predictions,path,title,indices=None):
    if indices is None:indices=[0,1,2,3,4,5]
    fig,axes=plt.subplots(2,3,figsize=(11,7))
    for ax,i in zip(axes.flat,indices):
        ax.imshow(images[i].permute(1,2,0));ax.axis('off')
        for b,c in zip(targets[i]['boxes'],targets[i]['labels']):
            x,y,x2,y2=b.numpy()*48;ax.add_patch(Rectangle((x,y),x2-x,y2-y,fill=False,color='lime',lw=1.8))
        count=0
        for b,s,c in zip(predictions[i]['boxes'],predictions[i]['scores'],predictions[i]['labels']):
            if s<.4:continue
            x,y,x2,y2=b.numpy()*48;ax.add_patch(Rectangle((x,y),x2-x,y2-y,fill=False,color='yellow',ls='--',lw=1.1))
            ax.text(x,y,f'{int(c)}:{s:.2f}',color='yellow',fontsize=7);count+=1
        ax.set_title(f"sample {i}: GT={len(targets[i]['boxes'])}, pred={count}")
    fig.suptitle(title+' | green=GT; yellow=prediction score>=0.4');fig.tight_layout();fig.savefig(path,dpi=150);plt.close(fig)

def run(out='lab10-results',epochs=28):
    started=datetime.now(timezone.utc).isoformat();start=time.time();out=Path(out);out.mkdir(parents=True,exist_ok=True);seed_all(7)
    train,ttrain,_=make_scenes(768,1101); val,tval,_=make_scenes(96,3101);test,ttest,_=make_scenes(192,5901)
    small,tsmall,_=make_scenes(192,5901,scale=.5);anchors=anchors_grid()
    matched=[match_targets(anchors,t) for t in ttrain];classes=torch.stack([m[0] for m in matched]);targets=torch.stack([m[1] for m in matched])
    model=TinyAnchorDetector();random_pred=predict(model,test,anchors,score=.01)
    baseline=report_predictions(random_pred,ttest);opt=torch.optim.Adam(model.parameters(),lr=.002);history=[]
    for epoch in range(epochs):
        model.train(); losses=[]; order=torch.randperm(len(train))
        for ids in order.split(32):
            logits,offset=model(train[ids]);loss,cls,loc=detection_loss(logits,offset,classes[ids],targets[ids])
            opt.zero_grad();loss.backward();opt.step();losses.append([float(loss.detach()),float(cls.detach()),float(loc.detach())])
        item={'epoch':epoch+1,'loss':float(np.mean(losses,axis=0)[0]),'classification_loss':float(np.mean(losses,axis=0)[1]),'box_loss':float(np.mean(losses,axis=0)[2])};history.append(item)
        if (epoch+1)%7==0 or epoch==0:print(item,flush=True)
    pred=predict(model,test,anchors);small_pred=predict(model,small,anchors);val_pred=predict(model,val,anchors)
    loose_pred=predict(model,test,anchors,nms_iou=.65)
    results={'provenance':provenance(__file__,started),'config':{'seed':7,'train_count':768,'validation_count':96,'heldout_count':192,
             'split_seeds':[1101,3101,5901],'image_shape':[3,48,48],'num_anchors':len(anchors),'epochs':epochs,'batch_size':32,
             'optimizer':'Adam lr=.002','matching':'positive IoU>=.45 plus distinct forced GT anchors; ignore [.25,.45); background<.25',
             'loss':'hard-negative CE (3:1,min12) + 3*positive smoothL1','box_parameterization':'dx/dw,dy/dh,log width ratio,log height ratio',
             'AP':'per-class global score sort; maximum-IoU GT, one match per GT; precision-envelope all-points integral',
             'AP_candidate_score_floor':.01,'random_baseline_candidate_score_floor':.01,'NMS_IoU':.35,'display_score':.4,
             'model_parameters':sum(p.numel() for p in model.parameters()),'data_identity':'generated images; color/shape correlated; distinct original sample seed splits; no external data'},
             'target_counts':{'positive':int((classes>0).sum()),'ignore':int((classes==-1).sum()),'background':int((classes==0).sum())},
             'random_initialization':baseline,'validation':report_predictions(val_pred,tval),'heldout':report_predictions(pred,ttest),
             'condition_half_object_scale':report_predictions(small_pred,tsmall),'condition_NMS_065':report_predictions(loose_pred,ttest),
             'training_history':history,'seconds':time.time()-start}
    # Select actual worst examples by mean best GT overlap, plus an empty image.
    failures=[]
    for i,t in enumerate(ttest):
        p=pred[i];b=p['boxes'][p['scores']>=.4]
        quality=float(iou_matrix(t['boxes'],b).max(1).values.mean()) if len(t['boxes']) and len(b) else 0
        if len(t['boxes']):failures.append((quality,i))
    failures.sort();indices=[i for _,i in failures[:5]]+[next(i for i,t in enumerate(ttest) if not len(t['boxes']))]
    results['failure_examples']=[{'sample_index':i,'mean_best_box_IoU_unconditioned_on_class':q} for q,i in failures[:5]]
    torch.save({'state_dict':model.state_dict(),'anchors':anchors,'config':results['config']},out/'detector.pt')
    np.savez_compressed(out/'heldout-inputs-and-targets.npz',images=test.numpy(),half_scale_images=small.numpy(),
                        target_boxes=np.array([t['boxes'].numpy() for t in ttest],dtype=object),target_labels=np.array([t['labels'].numpy() for t in ttest],dtype=object))
    serialize=lambda ps:[{k:v.tolist() for k,v in p.items()} for p in ps]
    save_json(out/'predictions.json',{'heldout':serialize(pred),'half_scale':serialize(small_pred),'targets':serialize([{k:v for k,v in t.items() if k!='sample_id'} for t in ttest]),'half_scale_targets':serialize([{k:v for k,v in t.items() if k!='sample_id'} for t in tsmall])})
    draw_predictions(test,ttest,pred,out/'heldout-predictions.png','Heldout image-to-box predictions')
    draw_predictions(test,ttest,pred,out/'failure-predictions.png','Lowest geometric overlap examples',indices)
    draw_predictions(small,tsmall,small_pred,out/'half-scale-predictions.png','Same original scenes, object size x0.5')
    fig,ax=plt.subplots(figsize=(7,4));ax.plot([h['loss'] for h in history],label='total');ax.plot([h['classification_loss'] for h in history],label='classification');ax.plot([3*h['box_loss'] for h in history],label='3 x box');ax.set(xlabel='epoch (zero based)',ylabel='training loss');ax.legend();fig.tight_layout();fig.savefig(out/'training-curve.png',dpi=150);plt.close(fig)
    save_json(out/'run.json',results)
    print(json.dumps({'heldout_mAP50':results['heldout']['AP50']['mAP'],'half_scale_mAP50':results['condition_half_object_scale']['AP50']['mAP'],'seconds':results['seconds']},indent=2),flush=True)
    return results

if __name__=='__main__':
    p=argparse.ArgumentParser();p.add_argument('--out',default='lab10-results');p.add_argument('--epochs',type=int,default=28);args=p.parse_args();run(args.out,args.epochs)
此段独立阅读
区域、像素与实例类别与位置怎样成为可学习的区域目标?

实际训练与可复查的输出

下面在子进程运行完整脚本,既有随机初始化基准,也有训练后保留集、同图尺寸变化、同权重 NMS 阈值变化。脚本保存训练轨迹、权重 detector.pt、图像与目标 NPZ、逐预测 JSON、run.json 和四张图。图中绿实线是真值,黄虚线是 score≥.4 的预测;没有黄框的空图也是合法输出。

此段独立阅读
展开完整实现代码
import subprocess,sys,json
subprocess.run([sys.executable,'lab10_detector.py','--out','lab10-results-rerun','--epochs','28'],check=True)
R=json.loads(Path('lab10-results-rerun/run.json').read_text())
for setting in ['random_initialization','validation','heldout','condition_half_object_scale','condition_NMS_065']:
    r=R[setting]
    print(setting, 'mAP50=',round(r['AP50']['mAP'],4),'mAP75=',round(r['AP75']['mAP'],4),
          'P@.4=',round(r['operating_point']['precision'],4),'R@.4=',round(r['operating_point']['recall'],4))
print(R['provenance'])
from IPython.display import Image,display
for filename in ['heldout-predictions.png','failure-predictions.png','half-scale-predictions.png','training-curve.png']:
    display(Image(filename='lab10-results-rerun/'+filename))
此段独立阅读
区域、像素与实例类别与位置怎样成为可学习的区域目标?

已完成基准:分数高,哪里仍会错

本地 CPU 完整运行开始于 2026-09-22T16:28:18.578466+00:00,PyTorch 2.14.0+cpu;源码 SHA-256 为 6fe8ef2b4e80cc880f8df1f17333506aede144441d41672a6329b4bebe514f5e。28 epoch 训练总损失从 .80512 降到 .00589;以下是该脚本的保留集结果,而非训练准确率。

条件 mAP50 mAP75 precision@score .4 recall@score .4
随机初始化 .00746 .00003 .0000 .0000
训练后,默认 NMS .35 .97303 .70367 .9417 .9831
同场景对象尺寸 ×.5 .61490 .17987 .9548 .5000
同权重,NMS 改为 .65 .95866 .72143 .4193 1.0000

AP50 和 AP75 差约 .269,说明“找到附近的同类物体”和“框很精确”不是同一个成功标准。随机模型在 .4 工作点没有输出;它的低阈值候选偶然覆盖一些物体,所以低工作点的召回不代表已经学会检测。训练模型对 33 张空图在 .4 工作点均未报目标,这是本次合成背景范围内的观察。

保留集预测

最差几何重合示例 35、15、51、24、118 按“每个真值与预测框的最大 IoU 再平均”选出,这个选图指标不考虑类别,不能替代正式 AP。35 号图的两个红矩形相邻,部分预测框覆盖了两者之间的区域;类别正确、数量看似正确,面积仍分配错。15 号图中高分蓝框偏向红物体的区域,说明 confidence 不是定位保证。空图 12 作为参照保留。

真实失败图

缩小对象后,固定 .4 工作点的 precision 仍高而 recall 只有 .5:模型主要漏掉小物体,不能只报 precision 宣称鲁棒。放宽 NMS 到 .65 的意义也不同:同一批 logits 没变,但更多少删的重复框被留下;TP 增至全部 296,FP 从 18 增至 410,precision 因而显著下降。AP50 略降而 AP75 略升并不矛盾:更宽松的候选集合也可能保留一个定位更准但分数稍低的框。

相同场景缩小对象

下一步只改锚框最小尺寸或增加更细特征层,先写下预期会改善哪一种漏检,再重新训练并保留旧 run;不要同时改尺度、学习率、阈值后把变化都归因于锚框。若把图中的颜色交换而语义标签仍按形状给,当前训练协议并没有支持这类泛化结论。

此段独立阅读
区域、像素与实例类别与位置怎样成为可学习的区域目标?

来源与下一条问题

2026-09-22 实时核对 D2L 1.0.3:Anchor Boxes §14.4.3–14.4.4 用于锚框分配、偏移与 NMS;SSD §14.7.1–14.7.3用于共享骨干、分类/回归头与完整训练路径。这里独立实现单特征层、小合成场景、ignore 区间、hard-negative CE、Smooth L1 与全数据 AP;没有把 D2L 的香蕉数据或论文成绩说成本次结果。

分类给出“是什么”,检测再加“各在何处”。即使框完全正确,矩形框内仍包含椭圆四角的背景;LAB11 接着将每个像素列为分类位置,检查边界、细小对象和采样损失。

此段独立阅读