开始前,会这些就够了
- 理解小窗口内的逐项乘加
- 能读一维列表与位置编号
- 会运行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,观察输出是否只是换了位置。
| 输入 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 移动前 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 移动后 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
先挪输入,再计算:[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保存窗口中心、输出位置和实际值;当位移可被步长整除时,还保存应对应的输出值。报告另列能匹配的所有输出循环移动,避免把“没有指定对应”误报成相等。空输入、错误窗口长度、非法步长、长度不整除及未知边界都会拒绝。
查结果时先核对同一格用了哪些输入,再看位移是否对齐,最后才比较全局平均。这样可以把算子数值错、位置对应错、边界约定不同分开,不必用一个模糊的“结果变了”覆盖三个问题。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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的质量保证。
自己试一小步
你能为另一组窗口与边界建立自己的位移账本吗?
- 自行选择可被三整除的输入长度、一个非对称奇数窗口和至少两种非恒定输入,写清中心与核方向。
- 实现每格计算与步长三,枚举位移;分别检查可整除位移的对应和其他位移是否偶然匹配。
- 再定义一种非循环边界,设计边缘与内部输入,对比先移动再计算和先计算再移动。
- 独立记录逐位置输出、所有可匹配移动与全局平均,交第二份只读结果核验程序。
做到这些,就算完成
- 使用自己的输入、长度和窗口,不复制正文八位脉冲。
- 同时说明等变与不变,比较的输入输出位置单位一致。
- 区分有限案例、算子恒等推导与尚未执行的训练评测,逐项说明证据范围。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- PyTorch 2.9:Conv1d (新标签页)↗
互相关而非翻核卷积、stride、padding与circular
/zeros等边界选项。 - Dive into Deep Learning:From Fully Connected Layers to Convolutions (新标签页)↗
第7.1.2节从位置无关系数推导共享窗口,第7.1.3节区分互相关与翻核卷积。
- Making Convolutional Networks Shift-Invariant Again (新标签页)↗
第3.1节等变、不变、周期性位移和循环边界;第3.2节密集计算与下采样的区分。