简介面向信号处理与深度学习初学者的DOA示例包聚焦波达方向估计的Python实现思路。在窄带信号场景下资源将深度学习理念引入方向估计任务帮助读者理解神经网络与阵列信号处理结合的入门方式。压缩包共6个文件全部为.py脚本包体仅1KB代码非常轻量主要覆盖简单图形绘制、正弦余弦曲线生成、图像显示等可视化基础操作便于快速运行和调试。已有874人学习获得了一定关注虽然体量精简但适合作为学习路径的起点通过阅读并运行这些脚本可熟悉Python科学计算与数据可视化的基本流程进而为后续搭建完整的深度DOA估计模型打下基础。对于希望快速入门深度学习与DOA方向结合的读者这份资源提供了直观的可执行示例。1. PythonDOA的第一课六个脚本里藏着的信号处理习惯这套资源叫ch01_PythonDOA_深度学习估计_doapython解压出来是六个基础得不能再基础的 Python 脚本——hungry.py、man.py、sin_graph.py、simple_graph.py、img_show.py、sin_cos_graph.py。第一次打开的人大概率会愣一下说好的深度学习估计 DOA 呢其实这正是 PythonDOA 项目最常见的入门设计先确认你能熟练生成数组、画正弦波、显示图片、定义类再进入窄带信号建模和 CNN 训练。DOA 估计要回答的是“信号从哪个方向来”窄带信号到达接收阵列时会在阵元之间形成固定相位差深度学习要学的就是相位差与角度的映射。这套资源把第一章做成 Python 基础热身适合两类人写过 Python 但没碰过阵列信号处理的开发者以及想拿深度学习 DOA 当 baseline 复现的研究生。六个脚本的顺序本身就是一条被刻意铺好的学习路径。2. 把ch01六个脚本逐个跑通画图、numpy与类一个都不能少2.1 sin_graph.py 与 sin_cos_graph.py正弦波是窄带信号的入门长相先说为什么 DOA 项目要从画正弦波开始。窄带信号在数学上可以写成 s(t)A·cos(2πftφ)频率 f 和相位 φ 是两个最关键的参数。在阵列接收的场景里每个阵元收到的信号频率相同区别只在于相位 φ 不同——这个相位差就是 DOA 信息的载体。所以 sin_graph.py 练的不是画图本身而是让你习惯“用数组描述一段连续信号”的思路。把脚本里的核心逻辑拆出来大致是这一段import numpy as np import matplotlib.pyplot as plt fs 10000 # 采样率单位 Hz f 1000 # 信号频率单位 Hz t np.arange(0, 0.02, 1/fs) # 时间轴0 到 0.02 秒 s np.sin(2 * np.pi * f * t) # 窄带信号波形 plt.figure(figsize(8, 3)) plt.plot(t[:200], s[:200], linewidth1.2) plt.xlabel(time / s) plt.ylabel(amplitude) plt.grid(True) plt.show()逻辑上分四步先用np.arange生成时间轴再用np.sin生成波形然后用plt.plot画出来最后plt.show显示。fs是采样率f是载波频率t[:200]只取前 200 个点是为了避免整段波形在图上挤成一团。sin_cos_graph.py 在此基础上多画了一条余弦曲线。sin 和 cos 放在一起看的意义在于复信号 e^(jθ)cosθj·sinθ 的实部和虚部正好对应后续协方差矩阵的实部与虚部两个通道。你在 ch01 里把这个看成“正交分量可视化”到后面构造模型输入时就不会对R.real和R.imag感到陌生。2.2 simple_graph.py 与 img_show.py把数组变成能看的证据simple_graph.py 练的是多曲线对比画法。它在 DOA 管线里的对应场景是对比不同信噪比下的误差曲线、对比传统 MUSIC 与深度学习的预测误差、对比训练集和验证集的 loss。这类脚本的核心逻辑是同一个plt.plot函数被多次调用配合label和legend区分曲线。import numpy as np import matplotlib.pyplot as plt snr_list [0, 5, 10, 15, 20] rmse_list [8.3, 5.1, 2.8, 1.4, 0.6] plt.figure(figsize(6, 4)) plt.plot(snr_list, rmse_list, markero, labelCNN-DOA) plt.xlabel(SNR / dB) plt.ylabel(RMSE / degree) plt.legend() plt.grid(True) plt.show()这段代码对应的是“模型评估”环节。markero给数据点加圆点标记label用于图例grid(True)辅助读刻度。代码本身没有难度难的是把实验数据整理成两个等长的列表——这也是我建议从 ch01 就养成习惯的地方每个实验跑完立刻把结果存成 numpy 数组或 CSV不要靠手抄。img_show.py 则是把 numpy 数组当成图像显示。它在 DOA 项目里对应的场景非常具体协方差矩阵的热力图可视化。不同来波方向在协方差矩阵的幅度分布上会呈现不同的条纹结构模型学到的其实就是这种二维模式。import numpy as np import matplotlib.pyplot as plt R np.random.rand(64, 64) # 模拟一个协方差矩阵 plt.imshow(R, cmapviridis) plt.colorbar() plt.show()cmapviridis是颜色映射方案colorbar()显示右侧色条用于读数值。实际使用中你会把R换成真实计算的协方差矩阵R (X.conj().T X) / T然后观察它的实部、虚部和幅度分布——这一步往往能提前发现数据合成阶段的 bug比如阵元间距设置错误导致协方差出现异常条纹。2.3 hungry.py 与 man.py循环与类的真实用途hungry.py 和 man.py 是六个脚本里最容易被跳过的两个因为它们看起来既不涉及信号处理也不需要深度学习。但这两个脚本的“真实身份”分别是训练循环和数据集类的雏形。hungry.py 典型的结构是一个 while 循环比如“饿了就吃直到吃饱”对应到训练过程就是“loss 没降下来就继续跑直到收敛”hungry True count 0 while hungry: count 1 print(f第 {count} 轮仍在训练) if count 3: hungry False print(训练结束)这个while hungry的写法本质上是训练循环的骨架。实际模型训练时你会写成while epoch max_epochs循环体里是“取一个 batch、前向传播、计算 loss、反向传播、更新权重”。如果你在 ch01 阶段就把循环条件和终止条件的关系想清楚后面看训练代码就不会发怵。man.py 是类定义的练习class Man: def __init__(self, name, age): self.name name self.age age def intro(self): print(fname: {self.name}, age: {self.age}) m Man(doa, 1) m.intro()__init__是构造函数self指向实例本身intro是实例方法。这个东西在 DOA 项目里的对应物是自定义 Dataset 类——把“角度列表”和“信号数组”封装成一个类提供__len__和__getitem__接口。你后面用 TensorFlow 或 PyTorch 的 DataLoader本质上都是在和这种“类封装”打交道。所以 man.py 的练习目的不是学语法而是让你在写模型代码前先掌握“如何把数据和标签装进一个类里”。3. 窄带信号DOA最小模型从数学设定到CNN训练3.1 窄带信号、均匀线阵与接收模型进入 CNN 之前需要把 DOA 的数学建模说清楚。这里只讨论单源窄带信号场景这也是一个能做通的最小闭环。假设有一个均匀线阵ULAM 个阵元等间距排列间距为 d。一个远场窄带信号以角度 θ 入射到达相邻阵元时会产生波程差 d·sinθ对应的相位差是 2π·d·sinθ/λ。导向矢量steering vector把这种相位关系写成一列复数import numpy as np M 8 # 阵元数量 theta 30 # 来波方向单位度 d_ratio 0.5 # 阵元间距与波长比值通常取 0.5 def steering_vector(theta, M, d_ratio): angle np.deg2rad(theta) return np.exp(-1j * 2 * np.pi * d_ratio * np.arange(M) * np.sin(angle)) a steering_vector(theta, M, d_ratio) print(a.shape) # (8,)这里的np.arange(M)生成 0 到 M-1 的阵元索引d_ratio是 d/λ 的比值。为什么取 0.5因为阵元间距为半波长时在 [-90°, 90°] 范围内相位差与角度一一对应不会出现空间混叠。这个参数后续会直接影响模型能估计的角度范围。接收数据的完整表达式是 X a(θ)·s n其中 s 是窄带信号源n 是噪声。生成一批仿真快拍的代码如下def generate_snapshots(theta, M8, T512, snr_db15, d_ratio0.5): angle np.deg2rad(theta) steering np.exp(-1j * 2 * np.pi * d_ratio * np.arange(M) * np.sin(angle)) # 窄带信号源复高斯随机信号 s (np.random.randn(T) 1j * np.random.randn(T)) / np.sqrt(2) # 噪声与信号同维度的复高斯 n (np.random.randn(T, M) 1j * np.random.randn(T, M)) / np.sqrt(2) # 信号与噪声功率对齐后按 SNR 缩放噪声 x np.outer(s, steering.conj()) # (T, M) signal_power np.mean(np.abs(x)**2) noise_power np.mean(np.abs(n)**2) n n * np.sqrt(signal_power / noise_power / (10 ** (snr_db / 10))) return x n逻辑说明先用np.outer将信号源 s 与导向矢量外积得到 T 个快拍在 M 个阵元上的接收数据。随后计算信号功率和噪声功率按信噪比把噪声放大或缩小到目标水平。T是快拍数snr_db是信噪比d_ratio是阵元间距与波长比值。这个函数是整个数据合成管线的核心后续训练和测试都基于它。3.2 协方差矩阵特征CNN 吃进去的东西是什么拿到多快拍数据 X形状 T×M之后下一步是计算空间协方差矩阵。协方差矩阵的物理含义是阵元之间的相关性它把时间域的采样信息压缩成了一个 M×M 的矩阵。不同来波方向在这个矩阵上留下不同的模式——这正是 CNN 可以识别的特征。def build_covariance_feature(X): T X.shape[0] # 快拍数 R (X.conj().T X) / T # M×M 协方差矩阵 feature np.stack([R.real, R.imag], axis-1) # (M, M, 2) return feature关键点在X.conj().T X先取共轭转置再与自身做矩阵乘法除以快拍数得到样本协方差估计。np.stack(..., axis-1)把实部和虚部在最后一维堆叠形成双通道输入。对 8 阵元系统输入形状就是 (8, 8, 2)和图像分类里的 (H, W, C) 结构一致唯一的区别是这里没有空间平移不变性——不同位置代表不同阵元对所以这个场景里卷积核的感受野设计要比图像任务更谨慎。为什么要用协方差矩阵而不是原始波形因为原始波形是 T×M 的时序数据CNN 处理起来需要一维卷积或 RNN而且对相位信息的利用率低。协方差矩阵天然是二维的且直接编码了阵元间的相位关系。SubspaceNet 这类基于深度学习的 DOA 方法输入特征也是从协方差矩阵出发的。3.3 能跑起来的CNN回归骨架与训练参数下面给一个完整的最小可训练骨架。它做的事情是随机生成一个角度合成窄带接收数据计算协方差矩阵特征然后用 CNN 回归这个角度。import numpy as np import tensorflow as tf M 8 T 512 def generate_batch(batch_size, snr_db15): xs, ys [], [] for _ in range(batch_size): theta np.random.uniform(-60, 60) # 随机生成角度 X generate_snapshots(theta, MM, TT, snr_dbsnr_db) feature build_covariance_feature(X) # (8, 8, 2) xs.append(feature) ys.append(theta) return np.array(xs), np.array(ys) model tf.keras.Sequential([ tf.keras.layers.Input(shape(M, M, 2)), tf.keras.layers.Conv2D(32, 3, activationrelu, paddingsame), tf.keras.layers.Flatten(), tf.keras.layers.Dense(64, activationrelu), tf.keras.layers.Dense(1) # 输出角度 ]) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), lossmse ) for epoch in range(50): Xb, yb generate_batch(64) model.fit(Xb, yb, epochs1, batch_size32, verbose0) if epoch % 10 0: loss model.evaluate(Xb, yb, verbose0) print(fepoch {epoch}: mse {loss:.4f})参数说明M8是阵元数T512是快拍数batch_size64是每轮生成 64 个随机角度样本。网络结构是“单层 Conv2D Flatten Dense”回归头只有一个神经元输出是角度值。lossmse表示用均方误差衡量预测角度与真实角度的差距。单源单角度的场景下这个模型训练几十轮就能把误差压到 1° 以内。如果要做多源 DOA回归头的输出维度要改成 K信号源数量或者把角度离散成网格做分类。但无论哪种方案数据合成与特征构造的流程都是一样的。建议第一次复现时先把单源跑通再去扩展多源否则训练不收敛时很难定位是数据问题还是网络结构问题。4. 常见问题与避坑五个翻车现场和排查顺序4.1 Python环境与numpy版本现象pip install 之后 import tensorflow 报错或者运行X.conj().T X时提示 numpy 版本不兼容。原因最常见的是 numpy 被装到了 2.x 版本而某些 TensorFlow 版本只支持 numpy 1.x。这套资源依赖的深度学习栈如果基于 TensorFlow 2.10~2.15numpy 版本往往会被卡在 1.24 左右。解决先卸载再固定版本安装。我一般会建一个独立虚拟环境python -m venv doa_env source doa_env/bin/activate pip install numpy1.24.4 matplotlib tensorflow2.15.0装完后用python -c import numpy; print(numpy.__version__)验证版本再跑一遍 ch01 里的脚本确认环境无误。这个步骤看似无关紧要但跳过它直接进模型训练翻车概率非常高。4.2 协方差矩阵维度搞反现象模型输入层报错提示 shape 不匹配比如(8,)或(512, 8, 1)而不是(8, 8, 2)。原因X 的形状约定混了。generate_snapshots返回的形状是(T, M)即快拍数在前阵元数在后。计算协方差时写成X X.conj().T会得到(T, T)而不是(M, M)正确的写法是X.conj().T X。另外np.stack([R.real, R.imag], axis-1)不能省否则输入只有单通道。解决在特征构造函数里加一行断言assert R.shape (M, M), f协方差矩阵形状错误: {R.shape} assert feature.shape (M, M, 2), f输入特征形状错误: {feature.shape}这种防御性检查看起来多余但在反复调整阵元数或快拍数时能帮你省下大量排查时间。4.3 阵元间距与角度混叠现象模型在 ±60° 范围内训练但在某些角度上预测结果出现对称性偏差比如 40° 被预测成 -40°。原因d_ratio设置不当。当阵元间距大于半波长时相位差随角度的变化不再单调同一相位差可能对应两个不同角度这就是空间混叠。当d_ratio0.5时[-90°, 90°] 范围内不会混叠但如果你为了某种原因把间距调大混叠就会出现。解决固定d_ratio0.5同时把训练角度范围限制在 [-60°, 60°]。留出边界余量是必要的因为 CNN 在角度接近 90° 时相位差对角度变化不敏感训练误差天然会变大。如果必须覆盖更大范围考虑改变阵列构型而不是硬调网络。4.4 角度回归的周期性误差现象loss 已经降到很低但预测结果在 0° 附近出现明显跳变比如真实角度 359° 被预测成 1°。在 [-60°, 60°] 范围内对应的表现是边界处误差偏大。原因直接用 MSE 回归角度时网络不知道角度是周期变量。虽然单源窄带场景下角度范围有限但一旦扩展到全向范围0° 和 360° 是同一个方向MSE 却会把它们当成相差 360 的两个值导致模型在边界处无所适从。解决最常见的做法是让网络输出 sin(θ) 和 cos(θ) 两个值再用atan2还原角度。这样角度被编码成连续且周期友好的表示# 输出层改为 2 个神经元 # y_true 编码为 [sin(θ), cos(θ)] # 预测时 angle np.rad2deg(np.arctan2(pred_sin, pred_cos))这套编码在角度回归任务里几乎是标准做法。ch01 里的 sin_cos_graph.py 让你画 sin 和 cos 对比曲线本质上就是在为这种编码方式做铺垫。4.5 matplotlib中文乱码与后端显示现象plt.xlabel(时间/s)执行后图上的中文全部显示为方块。原因matplotlib 默认字体不包含中文字符。如果你在无图形界面的服务器上运行还可能遇到plt.show()不弹出窗口或报错的问题。解决在脚本头部加两行配置plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False如果服务器上没有 SimHei 字体可以把plt.show()换成plt.savefig(output.png)把图保存成文件再查看。这是深度学习项目里的常见操作训练脚本在后台跑产出图片按需保存不依赖图形界面。5. 一个改造技巧把ch01脚本变成DOA数据检查工具ch01 里的脚本不只能做入门练习稍微改造一下就能变成 DOA 实验里的数据检查工具。我拿到这套资源后做的第一件事就是基于 sin_cos_graph.py 和 img_show.py 写了一个“预测结果验证”脚本。训练完 3.3 节的 CNN 模型后在测试集上把预测角度和真实角度画成散点图# 用生成好的测试数据做预测 X_test, y_test generate_batch(200, snr_db10) y_pred model.predict(X_test)[:, 0] plt.figure(figsize(6, 4)) plt.scatter(y_test, y_pred, s12, alpha0.7, labelpredictions) plt.plot([-60, 60], [-60, 60], r--, linewidth1, labelideal) plt.xlabel(true angle / degree) plt.ylabel(predicted angle / degree) plt.legend() plt.grid(True) plt.show()如果散点均匀分布在红色虚线上说明模型在不同角度上表现均衡如果某段区域偏离红线说明该角度范围内数据量不足或特征区分度不够。这个画法的原型就是 sin_graph.py 的单曲线画图只是把 x 轴从时间换成了真实角度。另一个实用的改造是协方差矩阵可视化。把 img_show.py 的逻辑用在合成数据检查上每次生成数据集后先看一眼协方差矩阵长什么样X_sample generate_snapshots(20, M8, T512, snr_db15) R_sample (X_sample.conj().T X_sample) / X_sample.shape[0] plt.figure(figsize(5, 4)) plt.imshow(np.abs(R_sample), cmaphot) plt.colorbar() plt.title(|R| at theta20 deg) plt.show()不同角度会在这个热力图上呈现不同的条纹结构。如果两张图完全一样多半是数据合成出了问题比如角度没有参与导向矢量计算。从那以后我每次合成数据集都强制自己先跑一遍可视化脚本确认数据长什么样再进网络。这个习惯帮我挡掉了至少三次因为参数写错导致的无效训练。希望帮到你。本文还有配套的精品资源点击获取