qinghuan
← 回到知识探索

AI 直觉 / 动手实验

同一个卷积窗口,输入挪一格,输出为什么不只跟着挪?

用八个位置的整数输入,分别核对每格计算、隔格取值、循环边界与零填充,再看全局平均能保住什么。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 输出跟着移动,与输出完全不变是两件事
  4. 固定三个乘数,先把八个位置首尾接起来
  5. 每个位置都计算时,循环移动确实能对得上
  6. 每次跳两格,移动一格就换了取样位置
  7. 把所有输出平均,能消去位置,但补不回漏看的响应
  8. 改为边界外补零,即使步长一也要重新检查
  9. 运行四十八组位移,逐个位置查看实际数值
  10. 把原理来源与真实网络表现分开
  11. 记住这些要点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
  • 理解小窗口内的逐项乘加
  • 能读一维列表与位置编号
  • 会运行Python标准库脚本

同一组窗口权重在每个位置反复使用,似乎意味着输入向右挪一格,输出也只会向右挪一格。但如果窗口每次跳两格,或者碰到边缘时补零,原先的直觉就需要重新核对。共享规则没有替你决定采样位置和边界。

我们不用照片、训练模型或误差阈值,只用八个整数和三个固定乘数。把“先挪输入再计算”与“先计算再挪输出”并排记录,就能找到哪些变化只是位置移动,哪些变化已经改变了数值。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • shift_correlation.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B shift_correlation.py

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。有限一维整数互相关的位移、步长和边界对照;不运行框架、不训练网络、不实施抗混叠过滤,也不评估真实分类表现。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先挪输入再计算,能和先计算再挪输出对上吗?

窗口固定为“左邻 × 1 + 自己 × 2 + 右邻 × 3”,权重不翻转。先用默认的一格位移,再把步长改成 2,观察输出是否只是换了位置。

位置编号与输入值
输入01234567
移动前10000000
移动后01000000

先挪输入,再计算:[3, 2, 1, 0, 0, 0, 0, 0]

先计算,再挪输出:[3, 2, 1, 0, 0, 0, 0, 0]

本次对得上:输出对应向右移动 1 格。

输入未移动时的原输出

[2, 1, 0, 0, 0, 0, 0, 3]

平均值:0.75

输入移动后的实际输出

[3, 2, 1, 0, 0, 0, 0, 0]

平均值:0.75

页面按正文相同的整数规则计算,额外允许查看三种输入在补零边界下的各个位移。它没有运行 PyTorch 或训练网络。循环边界与补零边界是不同设定;步长 2 的偶数位移也不能脱离边界条件直接下结论。这里的平均覆盖全部实际输出位置。

输出跟着移动,与输出完全不变是两件事

设F表示整个计算过程,S表示把列表向右移。若先移动输入再计算,等于先计算再移动输出,即F(Sx)=S(Fx),叫平移等变。普通中文就是响应会跟着位置走;它允许输出列表改变,只要求变化方式能与输入移动对应。

若移动输入后,输出本身完全相同,即F(Sx)=F(x),才叫平移不变。给每个位置画响应图,往往希望响应跟着物体移动;最后只判断有没有某物,可能更希望判断保持稳定。需要哪一种性质,应先由输出的用途决定。

这两个式子还隐含输入和输出位置怎样对应。步长为二时,输出相邻一格对应输入相邻两格,不能把两边的“一格”当作同样的距离。我们先保持相同长度看清第一种关系,再单独加入隔格取值。

固定三个乘数,先把八个位置首尾接起来

位置编号为零到七,主输入是[1,0,0,0,0,0,0,0],只有最左边的一格非零。每个中心t都取左邻、自己、右邻,分别乘一、二、三再相加:y[t]=x[t−1]+2x[t]+3x[t+1]。权重顺序始终不翻转。

严格说,这个运算是互相关;深度学习框架常把它放在名为卷积的层里。PyTorch Conv1d文档也明确采用互相关。本例单通道、没有偏置或激活函数,不能把这条算式当作所有卷积网络的完整前向。

第一组采用人为的循环边界:位置七的右邻是零,位置零的左邻是七;右移时从右侧出去的数也绕回左侧。因此没有内容因移动而消失。这里是为了单独检查步长,并非假定真实照片左右边缘自然相接。

每个位置都计算时,循环移动确实能对得上

步长为一的原输出是[2,1,0,0,0,0,0,3]。把输入右移一格,非零项来到位置一,输出变成[3,2,1,0,0,0,0,0]。这恰好是原输出循环右移一格,而不是与原输出逐格相同。

原因可以直接看下标。移动后的输入在位置t取到原来的t−d,因此窗口读取的三个下标都统一减去d;原输出在t−d位置也读取同样三项,并使用相同乘数。循环取模让这个关系在首尾处仍成立。

脚本把位移从零到七全部检查,并加入逐格递增和常量两个输入。它还枚举所有二进制八位输入,共二百五十六种,对步长一完成二千零四十八次恒等检查。穷举能核验这个有限实现;任意输入下的解释仍来自相同下标与乘数的对应。

每次跳两格,移动一格就换了取样位置

现在只计算中心为零、二、四、六的位置,得到四个输出。等价地,可以先算出完整八格结果,再取偶数位置。主输入得到[2,0,0,0];输入右移一格后却是[3,1,0,0],不再只是把原来的二移到另一格。

这时尝试四种输出循环移动,都无法把[2,0,0,0]变成[3,1,0,0]。两个列表连数值组成也不同,所以失败不是忘记选一个正确的整数移动距离。隔格取值只看见其中一组位置,输入移动会改变它究竟看见哪组响应。

输入右移两格则不同,输出变成[0,2,0,0],正好右移一个输出位置。一般在本题循环长度可被步长整除的约定下,输入位移d若是步长s的整数倍,就对应输出移动d/s。脚本对步长二的四个偶数位移做了一千零二十四次二进制输入检查。

奇数位移没有上述保证,不代表每个输入都一定失败。常量输入每格都是二,三项乘加处处为十二,移动后仍相同。一个特殊输入恰好对上,只证明这一行结果,不能代替对整个算子的性质判断。

把所有输出平均,能消去位置,但补不回漏看的响应

循环移动只是重排位置,不改变八个数的总和,所以主例的完整步长一输出的全局平均始终为6/8,也就是3/4。这里先得到一个会跟着移动的响应图,再把它汇总成不随循环位移变化的数;两个阶段承担不同作用。

若先隔格取值再平均,主例原输出均值为2/4,即1/2;输入挪一格后均值为4/4,即一。后面虽然都叫全局平均,它接收的数已经不同,不能恢复此前漏掉的信息。报告里的mean列按实际输出长度计算,不偷偷沿用八作分母。

因此不能只看模型末尾有平均层,就宣布整个模型对平移不变。必须检查送入平均层的特征是否只是位置重排,以及平均覆盖哪些位置、是否有不同权重。本文也没有把全局平均与在小窗口里做平均池化混为一谈。

改为边界外补零,即使步长一也要重新检查

第二组不再首尾相接:窗口读到范围外时返回零,列表右移时左侧补零、右侧超出的值丢弃。原主输入的步长一输出为[2,1,0,0,0,0,0,0],先把这份输出右移得到[0,2,1,0,0,0,0,0]。

但先移动输入再计算,结果是[3,2,1,0,0,0,0,0],最左边多了三。非零输入本身仍在画面内;差异来自有限输出窗口的边缘:原本落在输出范围外的响应,在输入移动后进入了保留范围。此时两条流程没有读取同一组有效信息。

把非零输入放到位置三,再只右移一格,两个流程又能相等,因为相关窗口远离边缘。程序保存边缘反例和内部对照,而不是把补零写成“必然不等”。循环边界下的证明有自己的前提,换成裁剪、反射或重复边界都应按实际规则重算。

运行四十八组位移,逐个位置查看实际数值

完整程序生成shift-inputs.json、shift-report.json和shift-outputs.csv。三种输入、两种步长、八种位移,共四十八组循环案例和二百八十八个输出位置;另有两组补零对照及三千零七十二次有限穷举检查。数据与窗口都是手写整数,没有随机种子。

CSV保存窗口中心、输出位置和实际值;当位移可被步长整除时,还保存应对应的输出值。报告另列能匹配的所有输出循环移动,避免把“没有指定对应”误报成相等。空输入、错误窗口长度、非法步长、长度不整除及未知边界都会拒绝。

查结果时先核对同一格用了哪些输入,再看位移是否对齐,最后才比较全局平均。这样可以把算子数值错、位置对应错、边界约定不同分开,不必用一个模糊的“结果变了”覆盖三个问题。

展开收起代码 · 小实验python · 112 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Exact finite 1-D cross-correlation: circular shift, stride and zero boundary."""
import csv
import json
from fractions import Fraction as F
from itertools import product
from pathlib import Path

ROOT = Path(__file__).resolve().parent
KERNEL = [1, 2, 3]  # Left, center, right: no kernel reversal.
INPUTS = {'impulse': [1, 0, 0, 0, 0, 0, 0, 0],
          'ramp': list(range(8)), 'constant': [2] * 8}


def shift(values, distance, boundary):
    if not values or type(distance) is not int or boundary not in ('circular', 'zeros'):
        raise ValueError('nonempty values, integer shift and known boundary required')
    n = len(values)
    return [values[(i-distance) % n] if boundary == 'circular' else
            values[i-distance] if 0 <= i-distance < n else 0 for i in range(n)]


def correlate(values, kernel, stride, boundary):
    if not values or len(kernel) != 3 or type(stride) is not int or stride not in (1, 2):
        raise ValueError('nonempty input, three weights and stride 1/2 required')
    if len(values) % stride or boundary not in ('circular', 'zeros'):
        raise ValueError('length must be divisible by stride; boundary must be known')
    result = []
    for center in range(0, len(values), stride):
        total = 0
        for j, weight in enumerate(kernel):
            index = center+j-1
            value = values[index % len(values)] if boundary == 'circular' else values[index] if 0 <= index < len(values) else 0
            total += weight*value
        result.append(total)
    return result


def main():
    rows, summaries = [], []
    for name, values in INPUTS.items():
        for stride in (1, 2):
            base = correlate(values, KERNEL, stride, 'circular')
            for distance in range(len(values)):
                moved = shift(values, distance, 'circular')
                actual = correlate(moved, KERNEL, stride, 'circular')
                rotations = [k for k in range(len(base)) if shift(base, k, 'circular') == actual]
                expected_shift = distance//stride if distance % stride == 0 else None
                expected = shift(base, expected_shift, 'circular') if expected_shift is not None else None
                if expected is not None:
                    assert actual == expected
                mean = F(sum(actual), len(actual))
                if stride == 1:
                    assert mean == F(sum(base), len(base))
                record = {'input': name, 'stride': stride, 'input_shift': distance,
                          'base_output': base, 'shifted_input': moved, 'actual_output': actual,
                          'expected_output_shift': expected_shift, 'expected_output': expected,
                          'matching_output_rotations': rotations, 'mean': str(mean)}
                summaries.append(record)
                for i, value in enumerate(actual):
                    rows.append({'input': name, 'stride': stride, 'input_shift': distance,
                                 'output_index': i, 'center': i*stride, 'actual': value,
                                 'expected_if_divisible': '' if expected is None else expected[i]})
    impulse = [s for s in summaries if s['input'] == 'impulse' and s['stride'] == 2]
    assert impulse[0]['actual_output'] == [2, 0, 0, 0]
    assert impulse[1]['actual_output'] == [3, 1, 0, 0]
    assert all(s['matching_output_rotations'] == [] for s in impulse if s['input_shift'] % 2)
    assert [s['mean'] for s in impulse] == ['1/2', '1']*4
    # Exhaust all 256 binary length-eight inputs; no random testing or seed.
    exhaustive = {'input_vectors': 0, 'stride1_shift_checks': 0, 'stride2_even_shift_checks': 0}
    for values in product((0, 1), repeat=8):
        exhaustive['input_vectors'] += 1
        for stride in (1, 2):
            base = correlate(values, KERNEL, stride, 'circular')
            for distance in range(0, 8, stride):
                assert correlate(shift(values, distance, 'circular'), KERNEL, stride, 'circular') == shift(base, distance//stride, 'circular')
                key = 'stride1_shift_checks' if stride == 1 else 'stride2_even_shift_checks'
                exhaustive[key] += 1
    boundaries = []
    for name, values in [('edge_impulse', INPUTS['impulse']), ('interior_impulse', [0,0,0,1,0,0,0,0])]:
        base = correlate(values, KERNEL, 1, 'zeros')
        moved = shift(values, 1, 'zeros')
        actual = correlate(moved, KERNEL, 1, 'zeros')
        expected = shift(base, 1, 'zeros')
        mismatch = [i for i,(a,b) in enumerate(zip(actual,expected)) if a != b]
        boundaries.append({'case':name,'input':values,'base_output':base,'shifted_input':moved,
                           'actual_output':actual,'shift_of_base_output':expected,'mismatch_positions':mismatch})
    assert boundaries[0]['mismatch_positions'] == [0] and boundaries[1]['mismatch_positions'] == []
    rejected = []
    for name,args in [('empty',([],KERNEL,1,'circular')),('even_kernel',([1,2],[1,2],1,'circular')),
                      ('stride_zero',([1,2],KERNEL,0,'circular')),('odd_length_stride2',([1,2,3],KERNEL,2,'circular')),
                      ('unknown_boundary',([1,2],KERNEL,1,'unknown'))]:
        try:
            correlate(*args)
        except ValueError:
            rejected.append(name)
        else:
            raise AssertionError(name)
    inputs={'kernel':KERNEL,'kernel_direction':'left center right; cross-correlation, no reversal',
            'inputs':INPUTS,'strides':[1,2],'circular_shifts':list(range(8)),
            'zero_boundary_cases':{'edge_impulse':INPUTS['impulse'],'interior_impulse':[0,0,0,1,0,0,0,0]},
            'zero_boundary_shift':1,'centers':'0, stride, 2*stride, ... < length; symmetric one-position padding'}
    report={'scope':'Exact finite integer operators; no learned CNN, framework execution, anti-alias filter or accuracy experiment',
            'circular_cases':summaries,'output_rows':len(rows),'boundary_cases':boundaries,'exhaustive':exhaustive,'rejected_inputs':rejected}
    (ROOT/'shift-inputs.json').write_text(json.dumps(inputs,indent=2)+'\n',encoding='utf-8')
    (ROOT/'shift-report.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
    with (ROOT/'shift-outputs.csv').open('w',encoding='utf-8',newline='') as handle:
        writer=csv.DictWriter(handle,fieldnames=list(rows[0]));writer.writeheader();writer.writerows(rows)
    print(f'PASS shift_correlation: {len(summaries)} circular cases, {len(rows)} output rows, 2 boundary cases, 3072 exhaustive identities')


if __name__ == '__main__':
    main()

怎样运行:Python 3.10+标准库;保存为shift_correlation.py,在空目录运行 python shift_correlation.py。不要加-O,验收使用assert。 2026-10-16 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。有限一维整数互相关的位移、步长和边界对照;不运行框架、不训练网络、不实施抗混叠过滤,也不评估真实分类表现。

运行后对照结果
PASS shift_correlation: 48 circular cases, 288 output rows, 2 boundary cases, 3072 exhaustive identities

把原理来源与真实网络表现分开

《动手学深度学习》从位置无关的系数推导共享窗口;PyTorch固定版本文档列出互相关、步长与边界选项。Zhang的原论文第3.1节明确区分等变、不变和循环边界,第3.2节把密集计算与下采样分开。这些来源帮助固定本文要检验的规则。

论文还研究在下采样前过滤信号以减轻位移影响。本文没有加入过滤器、训练网络或复现其图像实验,也不从一个整数反例推断某个真实分类器会错多少。要比较实际方案,仍需固定图像移动与边界处理,沿网络逐层核对,再检查任务结果。

这份实验能够支持的是:共享乘数、处理边界、选择输出位置和最终汇总,是四项需要分别说明的决定。把输入移动和输出移动都写成明确规则,才能判断自己需要的性质是否由当前实现保证。

记住这些要点

  • 输入移动后输出跟着移动,称为等变;输出自身相同,称为不变。
  • 循环边界、步长一与共享乘数共同支撑本例的精确对应。
  • 隔格取值后,输入移动两格才自然对应输出移动一格。
  • 全局平均不能自动修复先前下采样或边界造成的差异。

别把例子用过头

  • 把互相关写成已经翻转了核的数学卷积。
  • 把输入一格与降采样后的输出一格当成同样距离。
  • 用常量等特殊输入的成功,证明所有输入都有同样性质。
  • 把循环边界下的算子恒等式推广为任意真实CNN的质量保证。

自己试一小步

你能为另一组窗口与边界建立自己的位移账本吗?

  1. 自行选择可被三整除的输入长度、一个非对称奇数窗口和至少两种非恒定输入,写清中心与核方向。
  2. 实现每格计算与步长三,枚举位移;分别检查可整除位移的对应和其他位移是否偶然匹配。
  3. 再定义一种非循环边界,设计边缘与内部输入,对比先移动再计算和先计算再移动。
  4. 独立记录逐位置输出、所有可匹配移动与全局平均,交第二份只读结果核验程序。

做到这些,就算完成

  • 使用自己的输入、长度和窗口,不复制正文八位脉冲。
  • 同时说明等变与不变,比较的输入输出位置单位一致。
  • 区分有限案例、算子恒等推导与尚未执行的训练评测,逐项说明证据范围。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。