简介:本资源是面向医学影像分析初学者与Python开发者的一套LIDC-IDRI肺部CT数据配套说明资料,聚焦肺结节标注解析与DICOM图像处理入门实践。资源共1323个文件,主体为1319个XML格式的医师标注文件(含结节三维坐标、形态学描述等精细注释),辅以1个CSV元数据表、2个Excel临床统计表及1个TXT下载说明文档,整体压缩包仅6.44MB,轻量易获取。已有283人学习下载,适合需快速理解LIDC数据结构、开展结节检测预处理或构建基础特征工程的学习者。读者可直接获得标准化的标签解析路径参考、pydicom+PIL+numpy+pandas的典型读取与转换代码逻辑框架、XML与CSV字段映射关系梳理,以及针对TCIA平台数据组织方式的实操适配要点,为后续肺结节分割、分类建模打下坚实的数据认知基础。
1. LIDC相关数据说明和标签:不是“拿来就能训”的肺结节数据集,而是需要手动对齐、校验、清洗的多源标注黑匣子
你手头刚下完 LIDC-IDRI 的 10 个 ZIP 包,解压出 1000+ 个 DICOM 文件夹,又从 TCIA 下载了对应的 XML 标签文件——然后发现:XML 里写的“nodule”在图像上根本找不到;同一例 CT,三位放射科医生标注的结节位置差了 8mm;有的 XML 根本没写 malignancy 分数,有的写了却填的是字符串 "high" 而非数字 4。这不是数据质量问题,而是 LIDC 的设计本质:它是一套临床共识过程的原始记录,不是为深度学习准备的标注数据集。它适合做放射科医生决策建模、不确定性量化、多读片者一致性分析,但直接喂给 YOLO 或 nnUNet?大概率第一轮训练就 loss 飙升、mAP 归零。本文不讲“LIDC 是什么”,只聚焦一线实操:怎么把这堆带时间戳、带医生 ID、带模糊语义的 XML 和 DICOM 拆成可复现、可验证、可 debug 的 Python 数据流。适合正在做肺结节检测/分割/良恶性分类,且已卡在“数据加载失败”或“指标异常波动”阶段的开发者。
2. LIDC 数据结构解析:DICOM 与 XML 的三重错位关系必须先理清
LIDC-IDRI 的数据组织不是“一张图 + 一个 mask”这种理想结构,而是由三个独立维度交叉构成的网状结构。不厘清这个,后续所有 Python 脚本都是空中楼阁。
2.1 DICOM 层:Series UID 是唯一锚点,而非文件名或 Patient ID
LIDC 原始数据以 DICOM Series 为单位分发。每个 Series 对应一次 CT 扫描(如“LIDC-IDRI-0001”),其下包含多个 DICOM 文件(*.dcm)。关键点在于:
- Patient ID ≠ Study Instance UID ≠ Series Instance UID:TCIA 提供的下载包中,文件夹名是
LIDC-IDRI-XXXX,这只是方便人类识别的别名,真正跨模态对齐的唯一键是 DICOM Tag(0020,000E) Series Instance UID。 - 同一 Patient 可能有多个 Study(不同时间扫描),同一 Study 可能有多个 Series(如平扫+增强),而 LIDC 标注只针对特定 Series。
- XML 文件中
<imageAttributes>节点下的seriesInstanceUid字段,就是用来匹配 DICOM Series 的。不是文件夹名,不是 PatientID,必须用 pydicom 读取实际 DICOM 文件头提取。
import pydicom ds = pydicom.dcmread("LIDC-IDRI-0001/1.3.6.1.4.1.14519.5.2.1.6279.6001.100225287219297396187817577029/1.3.6.1.4.1.14519.5.2.1.6279.6001.179049373636411198791678797792/000001.dcm") print(ds.SeriesInstanceUID) # 输出类似 '1.3.6.1.4.1.14519.5.2.1.6279.6001.179049373636411198791678797792'提示:不要用
os.listdir()遍历文件夹名来猜 UID。真实项目中,某导师曾因硬编码LIDC-IDRI-0001对应某个 UID,导致在另一批数据上批量匹配失败,重跑预处理 36 小时。
2.2 XML 标签层:一个 Series 对应多个 XML,每个 XML 对应一位医生的一次独立标注
这是最反直觉的设计。LIDC 不提供“最终共识标注”,而是提供N 位医生对同一 Series 的 N 套独立标注(通常 N=4)。每套标注存于一个独立 XML 文件中,文件名形如1.3.6.1.4.1.14519.5.2.1.6279.6001.179049373636411198791678797792-1.xml,后缀-1表示第一位医生。
- 每个 XML 包含
<unblindedReadNodule>节点,每个节点代表该医生识别出的一个结节。 - 每个结节有
<roi>子节点,记录该医生在该 Series 的哪些 slice 上画了轮廓(注意:不是像素坐标,而是(x, y, z)在图像空间中的物理坐标,单位 mm)。 <characteristics>节点下有malignancy字段,值为 1~5 的整数(1=definitely benign, 5=definitely malignant),但存在大量缺失、空值、字符串值(如 "not assessed")。
2.3 三重错位:为什么你 load_data() 会报 KeyError
典型错位场景:
- 时间错位:XML 中
imageSOP_UID指向某张 DICOM 图像,但该 SOP Instance UID 在你本地 DICOM 文件中不存在(因传输丢失或压缩丢帧); - 空间错位:XML 中
zCoord是物理坐标(mm),需通过 DICOM 的ImagePositionPatient和PixelSpacing转换为 slice 索引,但不同设备 Z 方向 spacing 差异大,直接 round(zCoord / spacing_z) 会偏移 1~2 层; - 语义错位:同一物理结节,医生 A 标为
malignancy=4,医生 B 标为malignancy=2,医生 C 未标注——此时你若取均值3.0当作 GT,模型学到的是“平均模糊性”,而非“结节本质”。
因此,Python 加载逻辑必须显式处理这三重错位,不能依赖glob("*.xml")+cv2.imread()这种简单 pipeline。
3. 构建可复现的 Python 数据加载器:从 DICOM/XML 到 NumPy 张量的四步转换
我们不追求“一键生成 train/val/test”,而是构建一个可 debug、可单步验证、可输出中间状态的加载器。核心目标:给定一个 Series UID,返回(volume_array, nodule_masks, malignancy_scores)三元组,其中nodule_masks是(D, H, W)的 bool 数组,每个通道对应一个共识结节。
3.1 步骤一:DICOM 体数据重建 —— 用 pydicom + nibabel 保证 Z 轴顺序严格正确
LIDC 的 DICOM 文件不保证按 slice 位置排序。常见错误是sorted(os.listdir()),但文件名000001.dcm不代表 Z 坐标最小。必须读取ImagePositionPatient[2](即 Z 坐标)排序。
import pydicom import numpy as np from pathlib import Path def load_series_volume(series_path: Path) -> tuple[np.ndarray, dict]: """返回 (volume_array, metadata),volume_array shape=(D,H,W),Z轴已按物理位置升序排列""" dcm_files = list(series_path.glob("*.dcm")) if not dcm_files: raise ValueError(f"No DICOM files in {series_path}") # 关键:按 ImagePositionPatient[2](Z坐标)排序,而非文件名 dcm_objs = [] for f in dcm_files: try: ds = pydicom.dcmread(f, stop_before_pixels=True) # 跳过像素节省内存 z_pos = float(ds.ImagePositionPatient[2]) dcm_objs.append((z_pos, ds, f)) except Exception as e: print(f"Skip {f}: {e}") continue dcm_objs.sort(key=lambda x: x[0]) # 升序:Z小→Z大 # 读取像素并堆叠 slices = [] for _, ds, f in dcm_objs: ds_full = pydicom.dcmread(f) # 注意:LIDC 使用 RescaleIntercept/Slope,必须应用 pixel_array = ds_full.pixel_array.astype(np.float32) if 'RescaleIntercept' in ds_full and 'RescaleSlope' in ds_full: intercept = ds_full.RescaleIntercept slope = ds_full.RescaleSlope pixel_array = slope * pixel_array + intercept slices.append(pixel_array) volume = np.stack(slices, axis=0) # shape (D, H, W) # 元数据:用于后续坐标转换 metadata = { 'spacing': (float(ds_full.PixelSpacing[0]), float(ds_full.PixelSpacing[1]), abs(dcm_objs[1][0] - dcm_objs[0][0]) if len(dcm_objs) > 1 else 1.0), 'origin': tuple(ds_full.ImagePositionPatient), # (x,y,z) in mm 'direction': None, # LIDC 通常为标准方向,可设为 identity } return volume, metadata参数说明:
stop_before_pixels=True是血泪经验——1000+ 张 DICOM 全读内存会爆。abs(dcm_objs[1][0] - dcm_objs[0][0])计算 Z spacing 是 fallback,因部分设备不写SpacingBetweenSlices。RescaleIntercept/Slope必须应用,否则 HU 值全错(如空气应为 -1000,不校正可能为 0)。
3.2 步骤二:XML 解析与结节坐标物理→体素转换 —— 处理毫米级精度漂移
XML 中<imageZposition>是物理 Z 坐标(mm),需转为 volume 的第 d 层索引。公式:d_index = round((z_xml - origin_z) / spacing_z)
但问题在于:origin_z是第一张 slice 的 Z 坐标,而 XML 的z_xml可能略低于/高于实际 slice 范围(因插值或测量误差)。必须做边界检查 + 最近邻容错。
import xml.etree.ElementTree as ET def parse_nodules_from_xml(xml_path: Path, volume_shape: tuple, metadata: dict) -> list[dict]: """返回结节列表,每个 dict 包含 'mask_3d' (D,H,W bool), 'malignancy' (int or None), 'uid' (str)""" tree = ET.parse(xml_path) root = tree.getroot() nodules = [] for unblinded in root.findall(".//unblindedReadNodule"): nodule_id = unblinded.get("id", "unknown") # 获取 malignancy(处理缺失和字符串) chara = unblinded.find("characteristics") malignancy = None if chara is not None: mal_node = chara.find("malignancy") if mal_node is not None and mal_node.text: try: malignancy = int(mal_node.text.strip()) if malignancy < 1 or malignancy > 5: malignancy = None except ValueError: # "not assessed", "high", etc. malignancy = None # 解析所有 roi(一个结节可能跨多层) rois = unblinded.findall("roi") if not rois: continue # 初始化 3D mask mask_3d = np.zeros(volume_shape, dtype=bool) for roi in rois: image_uid = roi.find("imageSOP_UID") if image_uid is None or not image_uid.text: continue # 获取该 ROI 的 Z 坐标(物理 mm) z_pos = float(roi.find("imageZposition").text) # 转换为体素索引 d origin_z = metadata['origin'][2] spacing_z = metadata['spacing'][2] d_float = (z_pos - origin_z) / spacing_z d = int(round(d_float)) # 边界检查 + 容错:如果 d 超出范围,找最近的有效 slice if d < 0: d = 0 elif d >= volume_shape[0]: d = volume_shape[0] - 1 # 解析该层的 2D 轮廓(<edgeMap> 中的 x,y 坐标) edge_map = roi.find("edgeMap") if edge_map is None: continue coords = [] for point in edge_map.findall("point"): x = float(point.find("xCoord").text) y = float(point.find("yCoord").text) coords.append((x, y)) if len(coords) < 3: continue # 将物理坐标 (x,y) 转为体素坐标 (i,j),需用 ImagePositionPatient 和 PixelSpacing origin_x, origin_y = metadata['origin'][0], metadata['origin'][1] spacing_x, spacing_y = metadata['spacing'][0], metadata['spacing'][1] # 注意:DICOM 坐标系原点在左上角,numpy 是左上角,但 y 轴方向相反! # DICOM: (0,0) = top-left, y increases downward → numpy: row index increases downward → 一致 i_coords = [(int(round((y - origin_y) / spacing_y)), # row index int(round((x - origin_x) / spacing_x))) # col index for (x, y) in coords] # 用 OpenCV 填充多边形(比 matplotlib 更快更稳) import cv2 mask_2d = np.zeros(volume_shape[1:], dtype=np.uint8) if i_coords: pts = np.array(i_coords, dtype=np.int32).reshape((-1, 1, 2)) cv2.fillPoly(mask_2d, [pts], 1) mask_3d[d] |= mask_2d.astype(bool) nodules.append({ 'uid': nodule_id, 'mask_3d': mask_3d, 'malignancy': malignancy, 'z_physical': z_pos, # 保留原始 Z 用于 debug 'z_voxel': d, }) return nodules参数说明:
cv2.fillPoly是关键——不用skimage.draw.polygon,因其对非凸多边形填充不稳定;y - origin_y对应 row,x - origin_x对应 col,这是 DICOM 坐标系到 numpy 的标准映射;d = int(round(...))后强制 clamp 到[0, D),避免IndexError。
3.3 步骤三:多医生标注融合 —— 不是简单取并集,而是按共识规则生成 GT
LIDC 官方推荐两种共识方式:
- ≥3/4 医生标注:仅当 ≥3 位医生在同一物理位置(±5mm)标注了结节,才视为“共识结节”;
- ≥2/4 医生标注 + malignancy ≥3:更宽松,用于良恶性研究。
我们实现第一种(严格共识):
def fuse_nodules_across_xmls(nodule_lists: list[list[dict]], tolerance_mm: float = 5.0) -> list[dict]: """输入:[xml1_nodules, xml2_nodules, ...],输出共识结节列表""" if not nodule_lists: return [] # 扁平化所有结节,并打上医生 ID 标签 all_nodules = [] for xml_idx, nlist in enumerate(nodule_lists): for n in nlist: # 用中心点物理坐标代表结节位置 center_z = n['z_physical'] # 近似中心:取 mask_3d 的质心(简化版,实际可用连通域重心) nz = np.where(n['mask_3d']) if len(nz[0]) == 0: continue center_x_phys = (np.min(nz[2]) + np.max(nz[2])) / 2 * metadata['spacing'][0] + metadata['origin'][0] center_y_phys = (np.min(nz[1]) + np.max(nz[1])) / 2 * metadata['spacing'][1] + metadata['origin'][1] all_nodules.append({ 'center': (center_x_phys, center_y_phys, center_z), 'xml_idx': xml_idx, 'data': n, }) # 聚类:DBSCAN 按物理距离聚类(eps = tolerance_mm) from sklearn.cluster import DBSCAN if len(all_nodules) == 0: return [] coords = np.array([n['center'] for n in all_nodules]) clustering = DBSCAN(eps=tolerance_mm, min_samples=3).fit(coords) # min_samples=3 → ≥3医生 consensus_nodules = [] for label in set(clustering.labels_): if label == -1: # noise continue cluster = [all_nodules[i] for i in range(len(all_nodules)) if clustering.labels_[i] == label] # 取 cluster 中心作为共识中心 center_phys = np.mean([n['center'] for n in cluster], axis=0) # 合并所有医生的 mask(取并集) fused_mask = np.any([n['data']['mask_3d'] for n in cluster], axis=0) # malignancy:取所有非 None 的 malignancy 的中位数(鲁棒) mal_scores = [n['data']['malignancy'] for n in cluster if n['data']['malignancy'] is not None] malignancy = int(np.median(mal_scores)) if mal_scores else None consensus_nodules.append({ 'mask_3d': fused_mask, 'malignancy': malignancy, 'center_phys': center_phys, 'n_doctors': len(cluster), }) return consensus_nodules注意:这里用
DBSCAN而非阈值匹配,因结节可能呈线性分布(如血管旁结节),欧氏距离聚类更合理;min_samples=3硬编码对应“≥3/4”,若需适配其他协议可参数化;np.median比np.mean对异常值更鲁棒(如一位医生误标 malignancy=1)。
4. 避坑:LIDC Python 处理中五个高频翻车点与血泪修复方案
LIDC 的坑不在代码难写,而在现象隐蔽、原因分散、debug 成本极高。以下是某跨平台系统开发中真实踩过的 5 个坑,按发生频率排序:
4.1 现象:cv2.fillPoly填出来的 mask 全黑,或形状扭曲
原因:DICOM 的ImageOrientationPatient非标准(如 [1,0,0,0,1,0] 以外的值),导致(x,y)坐标系旋转,而你的转换公式假设了标准方向。
解决:在parse_nodules_from_xml中,先检查ImageOrientationPatient:
if 'ImageOrientationPatient' in ds_full: iop = ds_full.ImageOrientationPatient if not np.allclose(iop, [1,0,0,0,1,0]): # 非标准方向 raise NotImplementedError("Non-standard ImageOrientationPatient not handled. Use ITK or SimpleITK for full orientation support.")实际项目中,某公司因忽略此检查,在 3% 的病例上 mask 错位 2cm,模型在测试集上 recall 低 15 个点。
4.2 现象:malignancy字段读出来是None,但 XML 明明写了<malignancy>4</malignancy>
原因:XML 命名空间(namespace)未声明。LIDC XML 顶部有xmlns="http://www.nih.gov",导致root.find("characteristics")返回None。
解决:解析时显式传入 namespace 字典:
NS = {'ns': 'http://www.nih.gov'} chara = root.find(".//ns:characteristics", NS) # 注意前缀 ns:4.3 现象:load_series_volume生成的 volume Z 轴顺序颠倒,导致结节 mask 在错误 slice
原因:ImagePositionPatient[2]可能是递减序列(如从脚到头扫描),而sort(key=lambda x: x[0])默认升序,应改为:
# 改为按 Z 坐标绝对值排序?不,应判断扫描方向 z_positions = [x[0] for x in dcm_objs] if z_positions[0] > z_positions[-1]: # 递减 dcm_objs.sort(key=lambda x: x[0], reverse=True) # 使 volume[0] 为头侧 else: dcm_objs.sort(key=lambda x: x[0]) # volume[0] 为脚侧4.4 现象:同一 Series 的多个 XML,解析出的结节数量差异极大(1 vs 12)
原因:部分 XML 中<unblindedReadNodule>节点被包裹在<nonNodule>或<excludedNodule>父节点下,被findall(".//unblindedReadNodule")漏掉。
解决:改用更宽泛的查找:
# 查找所有可能的结节节点 nodules = [] for tag in ['unblindedReadNodule', 'nonNodule', 'excludedNodule']: nodules.extend(root.findall(f".//{tag}")) # 再过滤:只有 unblindedReadNodule 才含 malignancy 和 roi4.5 现象:fuse_nodules_across_xmls聚类后,共识结节数为 0,但肉眼可见明显结节
原因:tolerance_mm=5.0过小。LIDC 医生间 Z 方向标注差异常达 8~10mm(因 slice thickness 2~3mm,定位误差叠加)。
解决:将tolerance_mm提高到8.0,并在日志中打印每个 cluster 的直径:
for label in set(clustering.labels_): if label == -1: continue cluster_coords = coords[clustering.labels_ == label] diameter = np.max(pairwise_distances(cluster_coords)) # mm print(f"Cluster {label}: {len(cluster_coords)} docs, diameter {diameter:.1f}mm")5. 验证与调试:用三类可视化+数值指标建立可信数据流水线
写完加载器不等于数据就对了。LIDC 的复杂性要求你建立一套可重复、可截图、可进 regression test的验证机制。我一般在每次重构数据 pipeline 后,强制运行以下三步。
5.1 第一层验证:单例 DICOM/XML 可视化 —— 确保坐标转换无像素级偏差
对任意一个 Series,生成三张图:
- 图 A:原始 DICOM 某 slice(如中心 slice)+ 叠加该 slice 上所有医生的 2D 轮廓(不同颜色);
- 图 B:共识 mask 的最大密度投影(MIP)+ 标注中心点(红叉);
- 图 C:
malignancy分布直方图(4 位医生的原始值 + 共识中位数)。
import matplotlib.pyplot as plt def visualize_single_case(series_uid: str, volume: np.ndarray, nodule_lists: list, consensus: list, save_dir: Path): # 取中心 slice mid_d = volume.shape[0] // 2 fig, axes = plt.subplots(1, 3, figsize=(15, 5)) # 图A:原始slice + 多医生轮廓 axes[0].imshow(volume[mid_d], cmap='gray') colors = ['red', 'blue', 'green', 'yellow'] for xml_idx, nlist in enumerate(nodule_lists): for n in nlist: if mid_d == n['z_voxel'] and n['mask_3d'][mid_d].any(): # 绘制轮廓(简化:取边缘像素) contours = measure.find_contours(n['mask_3d'][mid_d], 0.5) for contour in contours: axes[0].plot(contour[:, 1], contour[:, 0], color=colors[xml_idx], linewidth=1) axes[0].set_title(f"Slice {mid_d}: Multi-reader contours") # 图B:共识 mask MIP mip = np.max(np.stack([c['mask_3d'] for c in consensus], axis=0), axis=0) if consensus else np.zeros_like(volume[0]) axes[1].imshow(mip, cmap='hot', alpha=0.7) axes[1].set_title("Consensus mask MIP") # 图C:malignancy 分布 all_mal = [] for nlist in nodule_lists: for n in nlist: if n['malignancy'] is not None: all_mal.append(n['malignancy']) if all_mal: axes[2].hist(all_mal, bins=np.arange(1, 7)-0.5, rwidth=0.8, alpha=0.7, label='Per-reader') if consensus: cons_mal = [c['malignancy'] for c in consensus if c['malignancy'] is not None] if cons_mal: axes[2].axvline(np.median(cons_mal), color='red', linestyle='--', label='Consensus median') axes[2].set_xlabel('Malignancy score') axes[2].set_ylabel('Count') axes[2].legend() axes[2].set_title("Malignancy distribution") plt.tight_layout() plt.savefig(save_dir / f"{series_uid}_debug.png", dpi=150, bbox_inches='tight') plt.close()关键检查点:图 A 中,同一颜色的轮廓是否闭合?不同颜色轮廓是否在相同区域重叠?图 B 的 MIP 是否覆盖图 A 中所有高亮区域?图 C 的红色虚线是否落在直方图主峰内?任一否,立即停机查坐标转换。
5.2 第二层验证:批量统计报表 —— 用 Pandas 生成可审计的数据质量报告
对全部 1010 例 LIDC 系列,运行以下脚本生成data_quality_report.csv:
| Series UID | Total Slices | Avg Slice Thickness (mm) | Nodules per Series (raw) | Nodules per Series (consensus) | % Consensus Rate | Min Malignancy | Max Malignancy |
|---|---|---|---|---|---|---|---|
| 1.3.6... | 124 | 2.5 | [4,3,5,2] | 1 | 25% | 2 | 4 |
import pandas as pd def generate_quality_report(series_list: list[str], base_path: Path) -> pd.DataFrame: records = [] for series_uid in series_list: try: vol, meta = load_series_volume(base_path / series_uid) xml_paths = list((base_path / "xmls").glob(f"{series_uid}-*.xml")) nodule_lists = [parse_nodules_from_xml(p, vol.shape, meta) for p in xml_paths] consensus = fuse_nodules_across_xmls(nodule_lists) # 原始结节数(每位医生) raw_counts = [len(nlist) for nlist in nodule_lists] # 共识结节数 cons_count = len(consensus) # 共识率 = cons_count / max(raw_counts) if raw_counts else 0 consensus_rate = cons_count / max(raw_counts) if raw_counts else 0 # malignancy 统计 all_mal = [] for nlist in nodule_lists: for n in nlist: if n['malignancy'] is not None: all_mal.append(n['malignancy']) records.append({ 'Series UID': series_uid, 'Total Slices': vol.shape[0], 'Avg Slice Thickness (mm)': meta['spacing'][2], 'Nodules per Series (raw)': raw_counts, 'Nodules per Series (consensus)': cons_count, '% Consensus Rate': round(consensus_rate * 100, 1), 'Min Malignancy': min(all_mal) if all_mal else None, 'Max Malignancy': max(all_mal) if all_mal else None, }) except Exception as e: records.append({'Series UID': series_uid, 'Error': str(e)}) return pd.DataFrame(records) # 保存报表 report = generate_quality_report(all_series_uids, Path("LIDC_data")) report.to_csv("data_quality_report.csv", index=False)这份报表是交付给合作方的“数据身份证”。当对方质疑“你们的结节检出率偏低”,你可以直接指出:“报告中第 327 行显示该系列共识率仅 12%,属低共识案例,已从训练集剔除”。
5.3 第三层验证:模型级回归测试 —— 用固定随机种子跑 mini-batch,确保结果可复现
最后一步,也是最容易被跳过的一步:用你刚写的加载器,跑一个 2-batch 的 mini-training,固定所有随机种子,保存 loss 曲线和第一个 batch 的预测 mask。
import torch import random def run_regression_test(): # 固定所有种子 torch.manual_seed(42) np.random.seed(42) random.seed(42) # 构建 dataloader(使用你的加载器) dataset = LIDCDataset(series_list[:10], xml_dir="xmls/", transform=ToTensorVolumetric()) loader = DataLoader(dataset, batch_size=2, shuffle=False, num_workers=0) # num_workers=0 避免多进程随机性 model = Simple3DUnet() # 任意轻量模型 criterion = DiceLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) losses = [] for epoch in range(2): for batch in loader: x, y = batch['image'], batch['mask'] # shape (B,1,D,H,W) pred = model(x) loss = criterion(pred, y) loss.backward() optimizer.step() optimizer.zero_grad() losses.append(loss.item()) # 保存 loss 和首个预测 mask np.save("regression_losses.npy", np.array(losses)) torch.save(model.state_dict(), "regression_model.pth") # 保存第一个 batch 的预测 first_pred = pred[0].detach().cpu().numpy() # (1,D,H,W) np.save("regression_first_pred.npy", first_pred) print(f"Regression test done. Final loss: {losses[-1]:.4f}") return losses[-1] # 每次修改加载器后,必须运行此函数,并比对 regression_losses.npy 是否变化 final_loss = run_regression_test() assert abs(final_loss - 0.4217) < 1e-3, "Data pipeline changed! Check coordinate conversion."从那以后我每次重构
parse_nodules_from_xml,都强制走一遍run_regression_test(),并 git commit 附带regression_losses.npy。团队新人入职第一天,任务就是跑通这个 test,否则不准碰模型代码。它不是性能测试,而是数据行为的指纹——只要 fingerprint 不变,你就知道坐标没漂、mask 没漏、共识没崩。
希望帮到你。
本文还有配套的精品资源,点击获取