想象你走进“智汇小馆”,主厨老张师傅是这条街上出了名的“人脸识别机”。他记性极好,凡是来过一次的客人,下次推门进来他都能立刻认出。可老张从不问客人姓名——他只看特征:发型、口音、点菜习惯、坐哪个位置。今天店里来了位新客,老张一边打量,一边翻开手边那本厚厚的“食客档案簿”:簿子上画着最近见过的每位客人的速写。他拿眼前这位的速写,和档案簿里的每一页逐一比对,最像的,就是熟客。这,就是对比学习的直觉。 MoCo(Momentum Contrast,动量对比学习)就是把这套“认人功夫”教给深度学习模型的一套心法。它有三样绝活:第一,用“对比”代替“标注”——不需要人告诉模型“这是猫”,只问“这两张图像不像”;第二,让一个“动量编码器”负责画速写,它更新极慢,确保档案簿里所有记录都用同一套画风;第三,把海量“档案速写”存进一个先进先出的队列,让模型每次比对都有成百上千个“陌生人”可参考。这篇文章,我们就跟着老张师傅的日常,把这套心法一层层拆开。

岗位关联

对AI从业者而言,MoCo是自监督学习的必修课:它用“动量编码器+负样本队列”的组合拳,让你在无标签数据上练出通用特征,大幅降低下游任务的标注成本,这是大数据时代降本增效的核心技能之一。

目录
  1. 可视化演示
  2. 对比学习基础
  3. 动量编码器机制
  4. 负样本队列维护
  5. InfoNCE损失
  6. 与SimCLR对比
  7. 与BYOL对比
  8. 效果与实现
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

本节配有交互动效(进入视口后自动加载,约 3KB)。

对比学习基础

每天下午三点,“智汇小馆”都会搬出一筐刚洗好的蔬菜。老张师傅的徒弟小刘问:“师父,这些菜没贴标签,您怎么教新来的帮工认菜?”老张笑了笑:“不用标签,拿两棵菜,问他像不像、是哪一种就行——认对了就夸,认错了就罚。”这正是对比学习的起点:数据没有人工标注,但我们可以自己制造“考题”——把同一张图片裁剪出两个不同视角,它们就是一对“正样本”(像两棵同品种的菜);再把这张图和图库里随便一张别的图凑一对,它们就是“负样本”(完全不同的菜)。模型的任务,是学会把正样本在特征空间里拉近,把负样本推开。

这个“制造考题”的过程,在自监督学习里叫“代理任务”。MoCo用的代理任务是“实例判别”:给定一张查询图(query),让它从一大堆候选图(key)里找出“唯一的那张同源图”。猫的两张不同角度照片得像,猫和狗的照片必须判然有别。模型在拼命踩这个跷跷板的过程中,被迫学会真正通用的视觉特征——这些特征后来可以直接迁移到分类、检测、分割等下游任务。

用一张图来概括对比学习的核心目标,就是让正样本的相似度永远高于任何负样本的相似度:

回到小馆里,老张师傅就是这样训练帮工的:他要求帮工对每一位熟客的评分,都必须高于对档案簿里所有陌生人的评分。条件越苛刻,帮工观察细节的本事就越强。对比学习的第一步,就是立下这条“正高负低”的铁律。

s(q,k+)>s(q,k−),∀k−∈Ns(q, k^{+}) > s(q, k^{-}), \quad \forall k^{-} \in \mathcal{N}
  1. 相似度函数这里的 \(s\) 是衡量两个特征向量像不像的“尺子”,不同任务可以用点积、余弦相似度或欧氏距离的负数。
    s(q,k)=q⋅k∥q∥∥k∥s(q, k) = \frac{q \cdot k}{\|q\| \|k\|}
  2. 正样本键\(k^{+}\) 是与查询样本 \(q\) 同源的另一个视角,比如同一张猫图的翻转图,它应当是高分对象。
    k+=augmentation(q原图), 同一张图的不同视角k^{+} = \text{augmentation}(q_{\text{原图}}), \text{ 同一张图的不同视角}
  3. 负样本键\(k^{-}\) 是随机挑选的其他样本,比如猫图配一张狗图,它应当得分低。
    k−∼N, 与 q 不同源的任意样本k^{-} \sim \mathcal{N}, \text{ 与 } q \text{ 不同源的任意样本}
  4. 全称量词\(\forall k^{-} \in \mathcal{N}\) 表示“对负样本集合中的每一个负样本”,这个不等式都必须成立,模型才算真正会认人了。
    ∀ 表示遍历所有负样本, 逐一压过它们\forall \text{ 表示遍历所有负样本, 逐一压过它们}

动量编码器机制

老张师傅有个怪癖:他每天收工后会尝一道新菜,但绝不因此立刻改掉自己的拿手菜配方。他常说:“一道菜没试够一个月,不算数。”徒弟们私下笑他固执,但正是这份固执,让档案簿里三百页速写都保持了同一种画风——如果今天跟这个师傅学画、明天跟那个师傅学画,档案簿早就乱了套。

MoCo里有两位画师:一位是“实习生小刘”(query编码器),学得飞快,每看一批新样本就大幅调整自己的画笔;另一位是“老张师傅”(key编码器),负责给档案簿画速写。问题是,老张不能完全不变——否则跟不上新菜品的风格。于是MoCo采用“动量更新”:老张的画笔参数,是“自己的老参数”和“小刘的新参数”按 99.9% 和 0.1% 的比例调和出来的。老参数占绝对主导,所以老张每一笔都只是微调,画风始终保持稳定。

数学上,这个调和过程用一个简洁的递推式表达。设 \(\theta_q\) 是query编码器的参数,\(\theta_k\) 是key编码器的参数,\(m\) 是动量系数:

如果 \(m\) 太接近1(比如0.9999),老张几乎听不进新意见,画风僵化;如果 \(m\) 太小(比如0.9),老张的画风跟着小刘剧烈抖动,档案簿前后页的画风就对不上了。0.999这个数字,是MoCo团队在ImageNet上反复试出来的黄金平衡点——老张既跟上了时代,又没有丢掉自己的笔触。

θk←m⋅θk+(1−m)⋅θq\theta_k \gets m \cdot \theta_k + (1 - m) \cdot \theta_q
  1. 两个参数主角\(\theta_k\) 是key编码器的参数,它“持有”档案簿的画风;\(\theta_q\) 是query编码器的参数,它负责快速吸收新知识。
    θk,θq∈Rd, 两个网络的权重向量\theta_k, \theta_q \in \mathbb{R}^{d}, \text{ 两个网络的权重向量}
  2. 动量系数\(m\) 是介于0和1之间的超参数,MoCo论文取0.999,意思是“每一次更新,老张只采纳0.1%的新意见”。
    m=0.999,1−m=0.001m = 0.999, \quad 1 - m = 0.001
  3. 加权求和新的 \(\theta_k\) 由旧的 \(\theta_k\) 占 99.9%、当前的 \(\theta_q\) 占 0.1% 相加而成。
    θk新=0.999×θk旧+0.001×θq\theta_k^{\text{新}} = 0.999 \times \theta_k^{\text{旧}} + 0.001 \times \theta_q
  4. 箭头语义\(\gets\) 表示“用右边的计算结果覆盖左边的变量”,这是每训练一步都会执行一次的赋值操作。
    每步: θk 平滑逼近 θq 但永不骤变\text{每步: } \theta_k \text{ 平滑逼近 } \theta_q \text{ 但永不骤变}

负样本队列维护

老张的档案簿是一本活页册,册子上固定保留最近五千位客人的速写。每当新客人进门,他就翻到最后一页,把新客人的速写贴上去;同时,最前面那页画得最久的速写被撕下来丢掉。簿子永远是“最近五千人”,不会越攒越多,也不会因为客人太少而没东西可比。这本活页册,就是MoCo里的“负样本队列”。

队列的每一个格子,都存着一位“客人”的特征向量——它是key编码器(老张)在某次前向计算时画出的速写。在训练中,模型拿到一个batch的查询图,会算出一批新的key特征,然后执行“先进先出”(FIFO)操作:从队头弹出最旧的batch个特征,把新算出的batch个特征压入队尾。队列长度始终保持不变,就像一个匀速滑动的传送带。关键在于,MoCo的队列容量可以做得非常大——论文里默认 65536,而batch通常只有 256。这意味着负样本数量不再受显存限制,模型每次对比都有几万个“陌生人”可供区分。

队列和动量编码器是天生一对。因为key编码器更新极慢,队列里几万个特征虽然在不同的历史时刻生成,但它们共享着几乎相同的“画风基准”。如果key编码器每天都在剧烈变化,队列里的老特征和新特征就变成了不同画风的画,拿它们当参照系必然一塌糊涂。所以MoCo的完整故事是:动量编码器负责让队列“旧而不乱”,队列负责让对比学习“多而不贵”。用集合的语言描述队列的更新:

夜深了,小刘看着师傅撕下最旧一页、贴上最新一页,若有所思:“师傅,您这档案簿永远满着,又永远在换血,难怪不管来多少客人都不会认错。”老张点点头:“记性好,不如方法好。该忘的忘,该记的记,才是真本事。”

Qt=(Qt−1∖{kold})∪{knew}\mathcal{Q}_t = \left( \mathcal{Q}_{t-1} \setminus \{k_{\text{old}}\} \right) \cup \{k_{\text{new}}\}
  1. 当前队列\(\mathcal{Q}_t\) 表示第 \(t\) 步训练时的队列特征集合,它是一个长度固定的字典。
    Qt={k1,k2,…,kN},N=65536\mathcal{Q}_t = \{k_1, k_2, \dots, k_{N}\}, \quad N = 65536
  2. 差集删除\(\mathcal{Q}_{t-1} \setminus \{k_{\text{old}}\}\) 表示从上一时刻的队列中,去掉一组最旧的样本特征。
    ∖ 是集合差, 去掉 kold 这批最早入队的负样本\setminus \text{ 是集合差, 去掉 } k_{\text{old}} \text{ 这批最早入队的负样本}
  3. 并集加入\(\cup \{k_{\text{new}}\}\) 表示把本batch新算出的key特征并进队列,它们是“刚进门的新客人速写”。
    ∪ 是集合并, 加入本批 batch 个新特征\cup \text{ 是集合并, 加入本批 } \text{batch} \text{ 个新特征}
  4. 容量守恒左边是更新后的队列,右边删掉 \(b\) 个、加上 \(b\) 个,一进一出,队列总长度始终不变。
    ∣Qt∣=∣Qt−1∣=N|\mathcal{Q}_t| = |\mathcal{Q}_{t-1}| = N

InfoNCE损失

现在到了最紧张的时刻:老张手里攥着新客人的速写,要在档案簿六百页里找出“最像的那一页”。他如何给每一页打分?又如何衡量自己认人的水平?打分的方法,是计算速写特征与每一页特征的点积——点积越大,说明两个向量方向越一致,人就越像。认人水平的衡量,则用一个叫 InfoNCE 的损失函数,它是对比学习的核心引擎。

InfoNCE的直觉是“揪出内鬼”:在正样本和一大堆负样本中,模型要用 softmax 的方式判断“谁是正样本”。如果正样本的得分在所有候选里脱颖而出,损失就低;如果模型瞎了眼把某个负样本当成了正样本,损失就高。公式里还有一个温度系数 \(\tau\),它控制打分分布的“脾气”:\(\tau\) 越小,softmax 越偏向“唯我独尊”,对错误零容忍;\(\tau\) 越大,softmax 越“温吞水”,错得再多也罚得轻。MoCo 常取 \(\tau = 0.07\),相当于一位要求严苛的主考官。

完整形式如下,其中 \(q\) 是查询特征,\(k_+\) 是正样本特征,\(k_i\) 是队列里的负样本特征:

老张每月底会看自己的“认人考核表”:认对的概率越高,损失分数越低,奖金越厚。InfoNCE就是那张考核表——它不关心老张对每个陌生人的具体评分,只关心“正确的人有没有排到最前面”。正是这张考核表,逼着模型把特征空间里的每个家族都安排得明明白白,同类扎堆、异类分家。

LInfoNCE=−log⁡exp⁡(q⋅k+/τ)exp⁡(q⋅k+/τ)+∑i=1Nexp⁡(q⋅ki/τ)\mathcal{L}_{\text{InfoNCE}} = -\log \frac{\exp(q \cdot k_{+} / \tau)}{\exp(q \cdot k_{+} / \tau) + \sum_{i=1}^{N} \exp(q \cdot k_i / \tau)}
  1. 查询与正键\(q\) 是查询样本的特征向量,\(k_{+}\) 是与它同源的正样本键,二者点积应尽量大。
    q⋅k+=∑j=1dqj⋅(k+)jq \cdot k_{+} = \sum_{j=1}^{d} q_j \cdot (k_{+})_j
  2. 温度缩放把点积结果除以 \(\tau\),相当于调整评分尺度的粗细,\(\tau=0.07\) 会把分数放大几十倍。
    q⋅k+τ,τ 越小分布越尖锐\frac{q \cdot k_{+}}{\tau}, \quad \tau \text{ 越小分布越尖锐}
  3. 分子指数化\(\exp(\cdot)\) 把正样本分数放大为非负数,便于和分母里的其他项比较所占比例。
    exp⁡(s+)=eq⋅k+/τ,s+ 是正样本得分\exp(s_{+}) = e^{q \cdot k_{+} / \tau}, \quad s_{+} \text{ 是正样本得分}
  4. 分母总和分母是“正样本得分 + 所有负样本得分”的总和,负样本数量 \(N\) 就是队列长度。
    ∑i=1Nexp⁡(q⋅ki/τ),N=65536\sum_{i=1}^{N} \exp(q \cdot k_i / \tau), \quad N = 65536
  5. 负对数姿态\(-\log(\cdot)\) 把“正样本概率”转化为损失,概率越接近1,损失越接近0。
    −log⁡(p+),p+→1⇒L→0-\log(p_{+}), \quad p_{+} \to 1 \Rightarrow \mathcal{L} \to 0

与SimCLR对比

隔壁街新开了一家“现代小馆”,主厨小刘跳槽过去后发明了新招:他不记档案簿,只认“当天到店的客人”。每天午市客人多,他就靠这批客人的相互对比练眼力;如果午市冷清、只有两三桌客人,他就彻底抓瞎——因为他的所有负样本都来自“当天这拨人”内部,店里人越少,参考对象就越少。这家店,就是SimCLR。

SimCLR的负样本完全依赖batch内互斥:假设batch大小是 \(N\),每张图生成两个增强视角,总共 \(2N\) 个样本,负样本数量是 \(2N-1\)。想让负样本多,就得把batch堆得巨大——SimCLR论文用了 4096 的batch,这在当时的显存条件下是烧钱行为。MoCo的做法则完全不同:负样本存进队列,batch 只需要 256,负样本却有 65536。前者是“今天客人必须够多”,后者是“过去很长时间的客人都算数”,二者在数学上都是InfoNCE的不同实例,但工程代价天差地别。

SimCLR的损失公式可以写成:

老张听了小刘的新店故事,连连摇头:“你把全部希望押在当天客流量上,不累吗?”MoCo的答案很明确:与其烧钱堆batch,不如建一座长效的档案库。队列与batch解耦,让显存压力骤降,也让“负样本超市”永远货架充盈——这是工程上的巧劲,更是思想上的破局。

LSimCLR=−log⁡exp⁡(sim(zi,zj)/τ)∑k=12N1[k≠i]exp⁡(sim(zi,zk)/τ)\mathcal{L}_{\text{SimCLR}} = -\log \frac{\exp(\text{sim}(z_i, z_j) / \tau)}{\sum_{k=1}^{2N} \mathbb{1}_{[k \neq i]} \exp(\text{sim}(z_i, z_k) / \tau)}
  1. 正样本对\(z_i\) 和 \(z_j\) 是同一张图的两次增强视角的特征,它们的相似度分数构成分子。
    sim(zi,zj)=zi⋅zj∥zi∥∥zj∥, 余弦相似度\text{sim}(z_i, z_j) = \frac{z_i \cdot z_j}{\|z_i\|\|z_j\|}, \text{ 余弦相似度}
  2. 分母遍历全batch分母求和范围是 \(1\) 到 \(2N\),即遍历当前batch的所有增强样本,一个不落。
    ∑k=12N, batch 内共 2N 个样本\sum_{k=1}^{2N}, \text{ batch 内共 } 2N \text{ 个样本}
  3. 排除自身\(\mathbb{1}_{[k \neq i]}\) 是指示函数,\(k = i\) 时不参与求和,防止模型把“自己”当成负样本。
    1[k≠i]=1 当且仅当 k≠i\mathbb{1}_{[k \neq i]} = 1 \text{ 当且仅当 } k \neq i
  4. 负样本来源分母中还隐含着“负样本=当前batch内其他样本”,当batch不够大时,负样本就贫瘠。
    负样本数=2N−1,N=batch size\text{负样本数} = 2N - 1, \quad N = \text{batch size}

与BYOL对比

又过了半年,小刘第三家店开张了,主打“师徒制”:他不再翻档案簿,而是让一个学徒网络整天盯着自己的判断标准学——师傅说“这是熟客”,学徒就记下“原来长这样”,完全不用拿陌生人来对比。这家店有个新名字:BYOL(Bootstrap Your Own Latent)。BYOL最激进的地方在于,它把负样本整个扔掉了。

MoCo坚持“必须拿正样本和负样本比一比,模型才知道边界在哪”;BYOL却认为,只要让“学生网络”不断追赶“教师网络”的输出,模型自己就能悟出好特征。BYOL的结构是:在线网络(student)对同一张图的两个增强视角分别预测,目标是逼近目标网络(teacher)的输出,而目标网络同样采用动量式更新——这一点和老张的“缓慢微调”异曲同工。但BYOL没有队列、没有负样本,它靠一个额外的“预测头”和“停止梯度”操作来防止模型崩溃。所谓崩溃,就是网络偷懒:把所有图片都映射成同一个特征,损失照样很低,等于什么都没学到。BYOL用工程诀窍避开了崩溃,MoCo则用显式对比天然免疫崩溃——这是两条截然不同的生存哲学。

BYOL的损失是两个特征向量之间的均方误差:

老张这样评价两个流派:“我翻档案簿,是跟陌生人比,清清楚楚地知道'不一样'长什么样;小蒋(BYOL的研究者)只跟师傅学,虽然看起来走捷径,但也悟出了一套防止偷懒的心法。”在自监督学习的食谱上,MoCo是色香味俱全的正统大餐,BYOL是剑走偏锋的分子料理——二者都端得上台面,只是对“什么是必要的对比”给出了不同的答案。

LBYOL=∥qˉθ(zθ)−zˉξ′∥22\mathcal{L}_{\text{BYOL}} = \left\| \bar{q}_\theta(z_\theta) - \bar{z}_{\xi'} \right\|_2^2
  1. 在线表示\(z_\theta\) 是学生网络从第一个视角提取的特征,\(\bar{q}_\theta\) 表示再经过一个预测头做一次非线性变换。
    qˉθ(zθ)=MLPθ(Encoderθ(x1))\bar{q}_\theta(z_\theta) = \text{MLP}_{\theta}(\text{Encoder}_{\theta}(x_1))
  2. 目标表示\(\bar{z}_{\xi'}\) 是教师网络从第二个视角提取的特征,并且这个特征的梯度被切断(stop-gradient)。
    zˉξ′=Encoderξ′(x2),不反传梯度\bar{z}_{\xi'} = \text{Encoder}_{\xi'}(x_2), \quad \text{不反传梯度}
  3. L2范数平方\(\left\| \cdot \right\|_2^2\) 把两个向量逐元素相减后求平方和,衡量它们的“距离”。
    ∥a−b∥22=∑j=1d(aj−bj)2\left\| a - b \right\|_2^2 = \sum_{j=1}^{d} (a_j - b_j)^2
  4. 无负样本的代价这个损失里只有“拉近”没有“推远”,因此模型必须依赖预测头和stop-gradient防止所有特征搅成一团。
    LBYOL 小 ⇒ 两个视角特征一致 \mathcal{L}_{\text{BYOL}} \text{ 小 } \Rightarrow \text{ 两个视角特征一致 }

效果与实现

老张的“智汇小馆”终于评上了星级。在计算机视觉的“米其林榜单”ImageNet上,MoCo的表现同样亮眼:仅用自监督预训练,不借助任何人工标签,MoCo v1在ImageNet线性分类协议上达到了60.6%的Top-1准确率,超越当时许多需要标签的方法。这个数字说明:模型从几百万张无标注图片里学到的通用特征,已经不输给拿着标签死记硬背的同行。

MoCo的训练配置里藏着几个关键数字。动量系数 \(m = 0.999\),温度系数 \(\tau = 0.07\),队列长度 \(K = 65536\),batch size = 256,优化器用SGD(初始学习率0.03,配合cosine学习率衰减),数据增强包括随机裁剪、颜色抖动、随机灰度化等。这些超参数像老张的调味配方:少了哪个,菜都不对味。特别要强调队列与batch的悬殊对比:

实现MoCo的代码其实不复杂:定义两个编码器、维护一个队列、计算InfoNCE损失、用动量公式更新key编码器。真正考验功力的,是对“一致性”的理解——队列里的老特征和新特征必须处于同一坐标系,这正是动量编码器存在的唯一理由。老张合上档案簿,笑着对徒弟说:“方法不在多,在于每一步都踩在点子上。稳定地积累,远比急功近利地堆砌更接近成功。”

Kqueue=65536≫BatchSize=256K_{\text{queue}} = 65536 \gg \text{BatchSize} = 256
  1. 队列容量\(K_{\text{queue}}\) 是负样本队列的最大长度,它决定模型每次对比能看到多少个“陌生人”。
    Kqueue=65536=216K_{\text{queue}} = 65536 = 2^{16}
  2. 训练批大小\(\text{BatchSize}\) 是每步训练用的样本数,它决定队列每步新增多少个特征。
    BatchSize=256=28\text{BatchSize} = 256 = 2^8
  3. 远大于符号\(\gg\) 表示左边的数量级远大于右边,队列容量是batch的256倍,负样本的“货源”极为充足。
    65536÷256=256 倍65536 \div 256 = 256 \text{ 倍}
  4. 工程意义正因为队列与batch解耦,研究者才能在有限显存下享受“超大规模负样本”的红利。
    显存开销∝BatchSize,而非∝Kqueue\text{显存开销} \propto \text{BatchSize}, \quad \text{而非} \quad \propto K_{\text{queue}}

AI 应用场景

电商推荐系统的视觉预训练

阶段:特征工程与预训练

解决的问题:电商平台上大量新上架的商品没有历史交互数据,传统推荐系统面对“冷启动”几乎无计可施;人工标注商品品类成本高、粒度粗。MoCo能在大规模无标签商品图上做自监督预训练,学出“不看销量也能懂商品”的视觉表征。

具体案例:某电商团队用千万张无标注商品主图训练MoCo,得到通用视觉编码器;下游只需少量标注商品图文数据,微调后即可支撑“以图搜图”和相似商品推荐,冷启动点击率提升约18%。

医学影像的低标注诊断

阶段:模型微调与迁移

解决的问题:医疗影像数据缺标注、隐私严、获取难,靠人工标注训练模型费时费力。MoCo先在大规模无标注影像(如胸部X光、CT切片)上预训练,再以少量标注数据微调,能有效缓解“标注稀缺”痛点。

具体案例:某医疗AI团队在100万张无标注眼底照片上做MoCo预训练,随后仅用5000张专家标注图微调用于糖尿病视网膜病变分级,准确率接近使用5万张标注图的全监督模型。

大规模视频行为理解

阶段:多模态表征学习

解决的问题:视频数据量巨大但语义稀疏,人工标注行为类别几乎不可能穷尽。MoCo可以对视频帧或短视频片段做实例判别预训练,让模型学到“哪些画面在讲同一件事”,从而为下游动作识别提供强初始化。

具体案例:某安防厂商利用海量监控录像(无标注)训练MoCo视频编码器,下游微调后用于异常行为检测,在公开数据集UCF101上取得超越传统光流方法的自监督精度。

Python 演示

python55 行
import numpy as np  def info_nce(query, pos_key, neg_keys, tau=0.1):    """InfoNCE: 最大化正样本概率, 最小化负样本概率"""    pos_sim = np.sum(query * pos_key, axis=-1) / tau    neg_sim = np.matmul(query, neg_keys.T) / tau    logits = np.concatenate([pos_sim[:, None], neg_sim], axis=-1)    logits -= np.max(logits, axis=-1, keepdims=True)    exp_logits = np.exp(logits)    probs = exp_logits / np.sum(exp_logits, axis=-1, keepdims=True)    return -np.mean(np.log(probs[:, 0] + 1e-9))  class MiniMoCo:    def __init__(self, dim=64, queue_size=128, momentum=0.999, tau=0.07):        self.dim = dim        self.queue_size = queue_size        self.momentum = momentum        self.tau = tau        self.query_encoder = np.random.randn(dim, dim) * 0.02        self.key_encoder = np.random.randn(dim, dim) * 0.02        self.queue = np.random.randn(queue_size, dim)        self.queue /= np.linalg.norm(self.queue, axis=-1, keepdims=True) + 1e-9     def _normalize(self, x):        return x / (np.linalg.norm(x, axis=-1, keepdims=True) + 1e-9)     def encode(self, x, encoder):        return self._normalize(x @ encoder.T)     def info_nce_loss(self, x_q, x_k):        q = self.encode(x_q, self.query_encoder)        k = self.encode(x_k, self.key_encoder)        return info_nce(q, k, self.queue, self.tau)     def step(self, x_q, x_k):        loss = self.info_nce_loss(x_q, x_k)        # 动量更新 key 编码器: theta_k = m * theta_k + (1-m) * theta_q        self.key_encoder = self.momentum * self.key_encoder + (1 - self.momentum) * self.query_encoder        # FIFO: 弹出最旧 batch 个特征, 尾部压入新 key 特征        b = len(x_k)        new_keys = self.encode(x_k, self.key_encoder)        self.queue = np.concatenate([self.queue[b:], new_keys], axis=0)        return loss  if __name__ == "__main__":    np.random.seed(42)    model = MiniMoCo(dim=64, queue_size=128)    for step in range(5):        x_q = np.random.randn(32, 64)        x_k = np.random.randn(32, 64)        loss = model.step(x_q, x_k)        print(f"step {step+1}: loss = {loss:.4f}, queue size = {model.queue.shape[0]}")
目的:完整演示 MoCo 的三大核心机制——InfoNCE 损失、动量编码器更新、FIFO 负样本队列,让你在几十行代码里看清“档案簿”是如何流转的。
  1. def info_nce(query, pos_key, neg_keys, tau=0.1):定义 InfoNCE 损失函数,把所有对比逻辑收敛到一个函数里。
  2. pos_sim = np.sum(query * pos_key, axis=-1) / tau计算正样本点积相似度并按温度缩放,等价于“这个熟客像不像”。
  3. neg_sim = np.matmul(query, neg_keys.T) / tau用矩阵乘法一次性算出 query 与队列所有负样本的相似度,高效且向量化。
  4. self.key_encoder = self.momentum * self.key_encoder + (1 - self.momentum) * self.query_encoder动量更新 key 编码器,让画风保持稳定。
  5. new_keys = self.encode(x_k, self.key_encoder)用当前 key 编码器给本批样本画速写,准备入队。
  6. self.queue = np.concatenate([self.queue[b:], new_keys], axis=0)FIFO 队列维护:丢掉最旧的 batch 个特征,把新的接到队尾。

常见误区

误区很多初学者以为,负样本队列既然在损失函数里出现了,那么队列里的特征也应该反向传播更新参数,于是尝试对队列里的每个特征求梯度。

正解MoCo 队列中的负样本特征来自 key 编码器的前向计算,它们只是“被读取的档案”,整个 InfoNCE 损失只对 query 编码器(和当前 batch 的正样本编码路径)反传梯度。key 编码器靠动量公式更新,它的梯度是彻底断开的,这就像老张翻档案簿时只是“看”,不会因为看了谁而改变自己的画风。

为何常见:因为 InfoNCE 公式里负样本确实参与了损失计算,初学者容易把“参与前向”和“参与反向”混为一谈。

误区新手常觉得“0.999 已经让 key 编码器很稳定了,那我设成 0.9999 或直接冻结不是更好?”

正解动量系数 \(m\) 过大会让 key 编码器更新太慢,追不上 query 编码器和数据分布的变化,队列里的特征会逐渐“过时”;过小则让 key 编码器抖动剧烈,队列前后页画风不一。0.999 是 MoCo 在 ImageNet 上验证的均衡点,不同数据集可能需要微调,但“越大越好”是错觉。

为何常见:人们直觉上认为“稳定”就是好事,却忽略了编码器也需要跟上数据分布的漂移,这是一对需要平衡的矛盾。

误区有同学认为“负样本越多越好,那把队列设成 100 万甚至无穷大,效果是不是更强?”

正解队列越长,负样本越丰富,但队列中最早的特征越是“陈年旧账”——尽管动量编码器缓解了漂移,它无法完全消除成本。特征空间随时间缓慢旋转,太旧的特征反而成为噪声。MoCo 论文选择 65536 是在“容量”和“新鲜度”之间权衡的结果,盲目增大不一定带来收益,还可能增加内存负担。

为何常见:只看收益不看代价,忽略了队列一致性这个隐性约束。

误区有人认为 MoCo 无非是把负样本从 batch 挪到了队列里,和 SimCLR 没有本质不同,谈不上思想创新。

正解队列只是表象,真正的思想创新是用动量编码器解决“队列一致性”问题——让历史的负样本特征仍然处于当前可比的坐标系中。这个“用缓慢更新换取新旧兼容”的思路,是 MoCo 区别于 SimCLR 的关键贡献,后续很多自监督方法都沿用了这一设计。

为何常见:初学者容易聚焦在显眼的“队列”上,而忽略了更隐蔽也更重要的“动量一致性”动机。

误区看到 BYOL 在某些榜单上分数更高,就认为它是 MoCo 的替代品,MoCo 只剩历史价值。

正解BYOL 去掉了负样本,但它引入了预测头和 stop-gradient 等防坍缩技巧,训练敏感性更高;MoCo 的显式对比框架更可解释、更稳定,且在迁移学习、多模态检索等领域仍被广泛用作基线。两者是“对比式”与“预测式”两条路线的代表,各有适用场景,不是简单的优劣关系。

为何常见:唯准确率论,忽略了算法的稳定性、可解释性和场景适配性。

面试题

Q1. MoCo 中的负样本主要来自哪里?

单选题 · easy

  • A.当前 batch 内其它样本
  • B.队列中的历史样本特征← 正确答案
  • C.手工精心标注的困难样本
  • D.随机生成的噪声向量

答案:B。MoCo 的负样本来自队列里存储的历史 key 特征,由动量编码器逐步生成并缓存。当前 batch 内的其它样本只是极小一部分负样本来源,而手工标注和随机噪声都不符合对比学习的负样本定义。

Q2. 动量编码器的参数更新方式是什么?

单选题 · easy

  • A.每步直接赋值 query 的参数
  • B.新旧参数按比例加权平均← 正确答案
  • C.按损失梯度反向传播更新
  • D.随机初始化后不再更新

答案:B。动量编码器用 \(\theta_k \gets m \theta_k + (1-m) \theta_q\) 更新,其中 \(m=0.999\) 让旧参数占绝对主导,key 编码器只做缓慢微调,而不是直接复制 query 参数,也不走梯度反传。

Q3. 为什么动量编码器能让队列中的特征保持一致?

单选题 · mid

  • A.特征全部存在 GPU 显存中
  • B.它更新缓慢让新旧特征同坐标系← 正确答案
  • C.它完全不使用任何数据增强
  • D.它每步都同步复制 query 参数

答案:B。队列里的特征是不同时刻由 key 编码器生成的,只有 key 编码器的参数变化足够缓慢,这些特征才可能位于同一特征空间。动量更新正是为了压制这种漂移,让新旧“档案”在同一套画风下可比。

Q4. MoCo 的负样本队列采用什么更新策略?

单选题 · mid

  • A.后进先出的栈式淘汰
  • B.先进先出的队列淘汰← 正确答案
  • C.随机抽取负样本进行替换
  • D.按损失大小淘汰负样本

答案:B。MoCo 使用 FIFO 先进先出:每步把最旧的 batch 个特征弹出队首,新算出的 key 特征压入队尾,队列长度恒定。这种策略简单高效,保证了负样本不断更新又不会突然清零。

Q5. 与 SimCLR 相比,MoCo 的核心优势是什么?

单选题 · mid

  • A.可以完全不需要数据增强
  • B.负样本与 batch 大小解耦← 正确答案
  • C.损失函数天然更加稳定
  • D.训练速度一定大幅提升

答案:B。SimCLR 的负样本数量取决于 batch 大小,不得不使用 4096 的大 batch;MoCo 把负样本存进队列,batch 可小至 256 而负样本仍达 65536,彻底解耦了二者。

Q6. 关于 InfoNCE 损失,下列说法哪些是正确的?

多选题 · mid

  • A.温度系数越小分布越尖锐← 正确答案
  • B.温度系数越小对比越宽松
  • C.分子是正样本的相似度← 正确答案
  • D.分母包含全部正负样本

答案:AC。InfoNCE 的分子是正样本相似度的指数,分母是正样本加所有负样本的指数和。温度系数 \(\tau\) 越小,指数缩放越大,softmax 分布越尖锐,对比要求越严苛,因此 B 说法相反。

Q7. BYOL 与 MoCo 最本质的差别是什么?

单选题 · mid

  • A.BYOL 不用卷积神经网络
  • B.BYOL 没有显式负样本← 正确答案
  • C.MoCo 没有任何损失函数
  • D.MoCo 只能处理图像分类

答案:B。BYOL 完全去掉了负样本,只靠学生网络逼近教师网络输出来学习表示,用预测头和 stop-gradient 防止特征坍塌。MoCo 则始终依赖显式的正负样本对比。

Q8. 关于 MoCo 训练机制,下列说法正确的是?

多选题 · hard

  • A.key 编码器不计算梯度回传← 正确答案
  • B.队列中的样本都来自同一批
  • C.最旧的样本会被弹出队列← 正确答案
  • D.query 参数也按动量方式更新

答案:AC。key 编码器只用动量公式更新,不参与反传;队列里的样本来自不同历史时刻,而非同一 batch;FIFO 策略每步弹旧入新;query 编码器走正常梯度更新而非动量更新。因此 A、C 正确。

Q9. 如果将动量系数 m 从 0.999 改为 0.5,最可能发生什么?

单选题 · hard

  • A.队列特征一致性变差且训练不稳← 正确答案
  • B.训练加速且效果明显提升
  • C.队列容量会自动翻倍增长
  • D.损失函数会被自动改变

答案:A。m=0.5 意味着 key 编码器每步有一半参数来自 query,画风剧烈抖动,队列里新旧特征不再同一坐标系,负样本信号噪声大,训练容易崩溃。这不改变损失函数或队列容量。

Q10. MoCo 预训练得到的编码器主要用在哪里?

单选题 · easy

  • A.仅用于图像分类任务
  • B.迁移到下游任务作初始化← 正确答案
  • C.替代优化器和损失函数
  • D.替代全部数据增强策略

答案:B。MoCo 的产物是一个通用视觉编码器,预训练完成后可以丢弃动量编码器和队列,把 query 编码器用作下游分类、检测、分割等任务的初始化权重,这是自监督预训练的标准用法。