461 lines
16 KiB
Markdown
461 lines
16 KiB
Markdown
# 02 — 模型训练与 ONNX 导出详解
|
||
|
||
## 一、建模思路
|
||
|
||
### 1.1 为什么用 1D-CNN 而不是 LSTM?
|
||
|
||
电力波形是时序信号,直觉上 LSTM/GRU 更"语义匹配"。但部署到 NPU 时:
|
||
|
||
| 维度 | 1D-CNN | LSTM/BiLSTM | Transformer |
|
||
|------|:---:|:---:|:---:|
|
||
| NPU 原生支持 | ✅ 完美 | ⚠️ 部分 | ⚠️ 部分(RKNN2.0+) |
|
||
| INT8 量化精度损失 | <0.5% | 3~10% | 2~5% |
|
||
| 推理速度 (相同参数量) | **最快** | 慢 5~10× | 慢 3~8× |
|
||
| 感受野 | 可调(kernel_size/dilation) | 全序列 | 全序列 |
|
||
| 时域特征提取能力 | 局部→全局 | 顺序依赖 | 自注意力 |
|
||
|
||
**结论**:1D-CNN 通过堆叠层数 + 空洞卷积(dilation) 可以获得足够大的感受野,同时在 NPU 上有最优的硬件加速。电力故障的特征(过零点突变、幅值骤变、谐波畸变)本质上都是**局部波形形态变化**,CNN 完全能捕获。
|
||
|
||
### 1.2 建议的网络结构
|
||
|
||
```
|
||
输入: [batch, 1, 128] (1通道电流/电压, 128个采样点, 约2个周波@1.6kHz)
|
||
|
||
┌─────────────────────────────────────────┐
|
||
│ Conv1D(1→16, k=7, s=1, p=same) │ 浅层: 大卷积核捕获局部突变
|
||
│ BatchNorm1D + ReLU │
|
||
│ MaxPool1D(k=2) │ → [batch, 16, 64]
|
||
├─────────────────────────────────────────┤
|
||
│ Conv1D(16→32, k=5, s=1, p=same) │ 中层: 提取复合特征
|
||
│ BatchNorm1D + ReLU │
|
||
│ MaxPool1D(k=2) │ → [batch, 32, 32]
|
||
├─────────────────────────────────────────┤
|
||
│ Conv1D(32→64, k=3, s=1, p=same) │ 深层: 细粒度特征
|
||
│ BatchNorm1D + ReLU │
|
||
│ AdaptiveAvgPool1d(1) │ → [batch, 64, 1]
|
||
├─────────────────────────────────────────┤
|
||
│ FC(64→num_classes) │ 分类头
|
||
│ 输出: [batch, num_classes] │
|
||
└─────────────────────────────────────────┘
|
||
|
||
总参数量: 约 25K,模型大小 < 100KB (FP32), < 30KB (INT8)
|
||
```
|
||
|
||
### 1.3 模型设计原则(面向 NPU 部署)
|
||
|
||
1. **尽量用 ReLU**,不要用 LeakyReLU/PReLU(NPU 支持不完善)
|
||
2. **避免动态 shape**,输入尺寸固定(如 `(1, 128)`)
|
||
3. **BatchNorm 不删**,RKNN 转换时会自动融合 BN → Conv(减少推理量)
|
||
4. **不用 Dropout**(推理时本来就不生效,还占计算图节点)
|
||
5. **AdaptiveAvgPool1d 可以用**,NPU 原生支持全局平均池化
|
||
|
||
---
|
||
|
||
## 二、数据准备
|
||
|
||
### 2.1 故障类型定义
|
||
|
||
电力系统常见的短路故障分类(以 5 分类为例):
|
||
|
||
| 标签 | 故障类型 | 英文 | 波形特征 |
|
||
|:---:|------|------|------|
|
||
| 0 | 正常 | Normal | 标准正弦波 |
|
||
| 1 | A相单相接地 | A-G Fault | A相电压骤降~0, 零序电压升高 |
|
||
| 2 | BC相间短路 | BC Fault | B/C相电流激增 5~20×, 电压跌落 |
|
||
| 3 | AB两相接地 | AB-G Fault | 两相电压跌落 + 零序分量 |
|
||
| 4 | 三相短路 | ABC Fault | 三相电流对称激增, 电压严重跌落 |
|
||
|
||
如果需要更细的分类,可以扩展到 10 分类(加入高阻接地、电弧故障、铁磁谐振等)。
|
||
|
||
### 2.2 数据预处理流程
|
||
|
||
```
|
||
COMTRADE/CSV 录波文件
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────┐
|
||
│ 1. 按通道提取 (A相电流 / A相电压 / 3I0) │
|
||
│ 每个通道独立训练一个模型 │
|
||
│ 或者多通道拼成 input_ch=3 │
|
||
├──────────────────────────────────────┤
|
||
│ 2. 滑动窗口切割 │
|
||
│ window = 128 点 │
|
||
│ stride = 64 点 (50% 重叠) │
|
||
│ 每个故障录波 → N 个样本 │
|
||
├──────────────────────────────────────┤
|
||
│ 3. 归一化 │
|
||
│ 方式A (推荐): Z-score │
|
||
│ x_norm = (x - μ) / σ │
|
||
│ μ,σ 从训练集统计, 写入配置文件 │
|
||
│ 方式B: Min-Max (仅用于特定场景) │
|
||
│ x_norm = (x - x_min)/(x_max - x_min)│
|
||
├──────────────────────────────────────┤
|
||
│ 4. 标签编码 │
|
||
│ one-hot [0,0,1,0,0] → class=2 │
|
||
├──────────────────────────────────────┤
|
||
│ 5. 数据集划分 (7:2:1) │
|
||
│ 训练集: 70% (实际训练) │
|
||
│ 验证集: 20% (调超参数) │
|
||
│ 测试集: 10% (仅最终评估) │
|
||
└──────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.3 数据增强(解决不平衡问题)
|
||
|
||
电力故障数据天然不均衡——正常运行时间远大于故障时间。
|
||
|
||
```python
|
||
# 对少数类做时间域增强
|
||
def augment_waveform(x):
|
||
# 1. 轻微时间偏移 (等效于改变故障起始角)
|
||
shift = np.random.randint(-8, 8)
|
||
x = np.roll(x, shift)
|
||
|
||
# 2. 幅度微扰 (±5%)
|
||
amp_noise = 1.0 + np.random.uniform(-0.05, 0.05)
|
||
x = x * amp_noise
|
||
|
||
# 3. 高斯噪声 (模拟传感器噪声)
|
||
noise = np.random.normal(0, 0.01, x.shape)
|
||
x = x + noise
|
||
|
||
return x
|
||
```
|
||
|
||
---
|
||
|
||
## 三、完整训练脚本
|
||
|
||
`code/train_1dcnn.py`:
|
||
|
||
```python
|
||
"""
|
||
电力波形故障识别 — 1D-CNN 训练脚本
|
||
目标: 训练一个轻量级 1D-CNN,导出 ONNX,然后转 RKNN 部署到 RK3568 NPU
|
||
"""
|
||
|
||
import numpy as np
|
||
import torch
|
||
import torch.nn as nn
|
||
import torch.optim as optim
|
||
from torch.utils.data import Dataset, DataLoader
|
||
from sklearn.metrics import classification_report, confusion_matrix
|
||
import os
|
||
|
||
# ============================================================
|
||
# 1. 模型定义
|
||
# ============================================================
|
||
|
||
class Fault1DCNN(nn.Module):
|
||
"""
|
||
轻量级 1D-CNN,面向 NPU 部署
|
||
参数量 ~25K,模型 FP32 大小 ~100KB
|
||
"""
|
||
def __init__(self, num_classes=5, input_channels=1, seq_len=128):
|
||
super().__init__()
|
||
|
||
# 第1卷积块: 大卷积核捕获局部突变 (过零点、尖峰)
|
||
self.block1 = nn.Sequential(
|
||
nn.Conv1d(input_channels, 16, kernel_size=7,
|
||
stride=1, padding='same', bias=False),
|
||
nn.BatchNorm1d(16),
|
||
nn.ReLU(inplace=True),
|
||
nn.MaxPool1d(kernel_size=2), # 128 → 64
|
||
)
|
||
|
||
# 第2卷积块: 中等卷积核提取复合特征
|
||
self.block2 = nn.Sequential(
|
||
nn.Conv1d(16, 32, kernel_size=5,
|
||
stride=1, padding='same', bias=False),
|
||
nn.BatchNorm1d(32),
|
||
nn.ReLU(inplace=True),
|
||
nn.MaxPool1d(kernel_size=2), # 64 → 32
|
||
)
|
||
|
||
# 第3卷积块: 小卷积核细粒度特征
|
||
self.block3 = nn.Sequential(
|
||
nn.Conv1d(32, 64, kernel_size=3,
|
||
stride=1, padding='same', bias=False),
|
||
nn.BatchNorm1d(64),
|
||
nn.ReLU(inplace=True),
|
||
)
|
||
|
||
# 全局池化 → 固定长度特征向量
|
||
self.gap = nn.AdaptiveAvgPool1d(1) # 32 → 1
|
||
|
||
# 分类头
|
||
self.classifier = nn.Linear(64, num_classes)
|
||
|
||
self._init_weights()
|
||
|
||
def _init_weights(self):
|
||
for m in self.modules():
|
||
if isinstance(m, nn.Conv1d):
|
||
nn.init.kaiming_normal_(m.weight, mode='fan_out')
|
||
elif isinstance(m, nn.BatchNorm1d):
|
||
nn.init.constant_(m.weight, 1)
|
||
nn.init.constant_(m.bias, 0)
|
||
elif isinstance(m, nn.Linear):
|
||
nn.init.normal_(m.weight, 0, 0.01)
|
||
nn.init.constant_(m.bias, 0)
|
||
|
||
def forward(self, x):
|
||
# x: (batch, 1, 128)
|
||
x = self.block1(x) # (batch, 16, 64)
|
||
x = self.block2(x) # (batch, 32, 32)
|
||
x = self.block3(x) # (batch, 64, 32)
|
||
x = self.gap(x) # (batch, 64, 1)
|
||
x = x.view(x.size(0), -1) # (batch, 64)
|
||
x = self.classifier(x) # (batch, num_classes)
|
||
return x
|
||
|
||
|
||
# ============================================================
|
||
# 2. 数据集
|
||
# ============================================================
|
||
|
||
class WaveformDataset(Dataset):
|
||
"""
|
||
从 numpy 文件加载波形数据集
|
||
预期数据格式: data.npy (N, 128) labels.npy (N,)
|
||
或自定义 loader 从 COMTRADE 文件加载
|
||
"""
|
||
def __init__(self, data_path, label_path, augment=False):
|
||
self.data = np.load(data_path).astype(np.float32)
|
||
self.labels = np.load(label_path).astype(np.int64)
|
||
self.augment = augment
|
||
|
||
# 确保是 (N, 1, 128) 格式
|
||
if self.data.ndim == 2:
|
||
self.data = self.data[:, np.newaxis, :]
|
||
|
||
def __len__(self):
|
||
return len(self.data)
|
||
|
||
def __getitem__(self, idx):
|
||
x = self.data[idx]
|
||
y = self.labels[idx]
|
||
|
||
if self.augment:
|
||
x = self._augment(x)
|
||
|
||
return torch.from_numpy(x), torch.tensor(y)
|
||
|
||
def _augment(self, x):
|
||
"""时间域数据增强 (对 numpy array 操作)"""
|
||
x = x.copy()
|
||
# 随机时移 ±8 点
|
||
shift = np.random.randint(-8, 9)
|
||
if shift != 0:
|
||
x = np.roll(x, shift, axis=-1)
|
||
# 幅度微扰 ±3%
|
||
x *= (1.0 + np.random.uniform(-0.03, 0.03))
|
||
# 加性噪声
|
||
x += np.random.normal(0, 1e-3, x.shape)
|
||
return x
|
||
|
||
|
||
# ============================================================
|
||
# 3. 训练
|
||
# ============================================================
|
||
|
||
def train_one_epoch(model, loader, criterion, optimizer, device):
|
||
model.train()
|
||
total_loss, correct, total = 0.0, 0, 0
|
||
for x, y in loader:
|
||
x, y = x.to(device), y.to(device)
|
||
optimizer.zero_grad()
|
||
logits = model(x)
|
||
loss = criterion(logits, y)
|
||
loss.backward()
|
||
optimizer.step()
|
||
total_loss += loss.item() * x.size(0)
|
||
correct += (logits.argmax(1) == y).sum().item()
|
||
total += x.size(0)
|
||
return total_loss / total, correct / total
|
||
|
||
|
||
def validate(model, loader, criterion, device):
|
||
model.eval()
|
||
total_loss, correct, total = 0.0, 0, 0
|
||
all_preds, all_labels = [], []
|
||
with torch.no_grad():
|
||
for x, y in loader:
|
||
x, y = x.to(device), y.to(device)
|
||
logits = model(x)
|
||
loss = criterion(logits, y)
|
||
total_loss += loss.item() * x.size(0)
|
||
preds = logits.argmax(1)
|
||
correct += (preds == y).sum().item()
|
||
total += x.size(0)
|
||
all_preds.extend(preds.cpu().numpy())
|
||
all_labels.extend(y.cpu().numpy())
|
||
return (total_loss / total, correct / total,
|
||
all_preds, all_labels)
|
||
|
||
|
||
def main():
|
||
# ---- 配置 ----
|
||
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||
BATCH_SIZE = 64
|
||
NUM_EPOCHS = 100
|
||
LR = 0.001
|
||
NUM_CLASSES = 5
|
||
SEQ_LEN = 128
|
||
INPUT_CH = 1
|
||
|
||
# ---- 数据加载 ----
|
||
train_ds = WaveformDataset('data/train_data.npy',
|
||
'data/train_labels.npy', augment=True)
|
||
val_ds = WaveformDataset('data/val_data.npy',
|
||
'data/val_labels.npy', augment=False)
|
||
train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE,
|
||
shuffle=True, num_workers=2)
|
||
val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE,
|
||
shuffle=False, num_workers=2)
|
||
|
||
print(f"训练集: {len(train_ds)}, 验证集: {len(val_ds)}")
|
||
|
||
# ---- 模型 ----
|
||
model = Fault1DCNN(num_classes=NUM_CLASSES,
|
||
input_channels=INPUT_CH, seq_len=SEQ_LEN)
|
||
model = model.to(DEVICE)
|
||
print(f"模型参数量: {sum(p.numel() for p in model.parameters()):,}")
|
||
|
||
# ---- 优化器 & 损失函数 ----
|
||
optimizer = optim.Adam(model.parameters(), lr=LR, weight_decay=1e-4)
|
||
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
|
||
optimizer, mode='max', factor=0.5, patience=10, verbose=True)
|
||
criterion = nn.CrossEntropyLoss()
|
||
|
||
# ---- 训练循环 ----
|
||
best_acc = 0.0
|
||
for epoch in range(1, NUM_EPOCHS + 1):
|
||
train_loss, train_acc = train_one_epoch(
|
||
model, train_loader, criterion, optimizer, DEVICE)
|
||
val_loss, val_acc, preds, labels = validate(
|
||
model, val_loader, criterion, DEVICE)
|
||
|
||
scheduler.step(val_acc)
|
||
|
||
if epoch % 10 == 0 or epoch == 1:
|
||
print(f"Epoch {epoch:3d} | "
|
||
f"Train Loss: {train_loss:.4f} Acc: {train_acc:.2%} | "
|
||
f"Val Loss: {val_loss:.4f} Acc: {val_acc:.2%}")
|
||
|
||
# 保存最佳模型
|
||
if val_acc > best_acc:
|
||
best_acc = val_acc
|
||
torch.save(model.state_dict(), 'checkpoints/best_model.pth')
|
||
|
||
print(f"\n最佳验证准确率: {best_acc:.2%}")
|
||
|
||
# ---- 最终评估 ----
|
||
print("\n" + "="*50)
|
||
print("最终验证集分类报告:")
|
||
print(classification_report(labels, preds,
|
||
target_names=['Normal','A-G','BC','AB-G','ABC']))
|
||
print("="*50)
|
||
|
||
# ---- 导出 ONNX ----
|
||
export_onnx(model, 'checkpoints/best_model.pth', SEQ_LEN, INPUT_CH, DEVICE)
|
||
|
||
|
||
# ============================================================
|
||
# 4. ONNX 导出 (关键步骤,影响后续 RKNN 转换成功率)
|
||
# ============================================================
|
||
|
||
def export_onnx(model, checkpoint_path, seq_len, input_ch, device):
|
||
"""导出 ONNX,注意算子兼容性"""
|
||
model.load_state_dict(torch.load(checkpoint_path, map_location=device))
|
||
model.eval()
|
||
|
||
dummy_input = torch.randn(1, input_ch, seq_len).to(device)
|
||
|
||
output_path = 'checkpoints/fault_1dcnn.onnx'
|
||
|
||
torch.onnx.export(
|
||
model,
|
||
dummy_input,
|
||
output_path,
|
||
input_names=['waveform'],
|
||
output_names=['logits'],
|
||
dynamic_axes=None, # ⚠️ 不要导出动态 shape!
|
||
opset_version=12, # RKNN 推荐 opset 11~12
|
||
do_constant_folding=True, # 常量折叠, 减少计算图节点
|
||
export_params=True,
|
||
verbose=False,
|
||
)
|
||
|
||
print(f"\nONNX 模型已导出: {output_path}")
|
||
|
||
# 验证 ONNX
|
||
import onnx
|
||
onnx_model = onnx.load(output_path)
|
||
onnx.checker.check_model(onnx_model)
|
||
print("ONNX 模型验证通过 ✓")
|
||
|
||
# 打印计算图算子统计(确认没有不支持的算子)
|
||
op_types = [n.op_type for n in onnx_model.graph.node]
|
||
from collections import Counter
|
||
op_count = Counter(op_types)
|
||
print(f"算子统计: {dict(op_count)}")
|
||
# 预期: {'Conv':3, 'Relu':3, 'MaxPool':2, 'GlobalAveragePool':1,
|
||
# 'Gemm':1, 'BatchNorm':3} (BN 后续会被融合)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
os.makedirs('checkpoints', exist_ok=True)
|
||
main()
|
||
```
|
||
|
||
### 3.2 训练建议
|
||
|
||
```bash
|
||
# 安装依赖
|
||
pip install torch numpy scikit-learn onnx onnxruntime
|
||
|
||
# 准备数据
|
||
# data/train_data.npy (N_train, 128) float32
|
||
# data/train_labels.npy (N_train,) int64
|
||
# data/val_data.npy (N_val, 128)
|
||
# data/val_labels.npy (N_val,)
|
||
|
||
# 训练
|
||
python train_1dcnn.py
|
||
|
||
# 验证 ONNX 推理结果与 PyTorch 一致
|
||
python -c "
|
||
import onnxruntime as ort
|
||
import numpy as np
|
||
import torch
|
||
|
||
# PyTorch 推理
|
||
model = ... # 加载训练好的模型
|
||
dummy = torch.randn(1,1,128)
|
||
pt_out = model(dummy).detach().numpy()
|
||
|
||
# ONNX 推理
|
||
sess = ort.InferenceSession('checkpoints/fault_1dcnn.onnx')
|
||
onnx_out = sess.run(None, {'waveform': dummy.numpy()})[0]
|
||
|
||
# 比较
|
||
print('最大误差:', np.max(np.abs(pt_out - onnx_out)))
|
||
# 期望: < 1e-5
|
||
"
|
||
```
|
||
|
||
---
|
||
|
||
## 四、训练要点小结
|
||
|
||
| 要点 | 做法 | 原因 |
|
||
|------|------|------|
|
||
| 网络选型 | 1D-CNN, 不要 LSTM/Transformer | NPU 的 CNN 算子最优 |
|
||
| 激活函数 | 只用 ReLU | NPU 原生支持,量化稳定 |
|
||
| 卷积核设计 | 浅层大核(7) → 深层小核(3) | 先捕获突变,再提取细节 |
|
||
| 输入固定 | shape=(1,1,128),不导出动态轴 | NPU 不支持动态 shape |
|
||
| ONNX opset | 11 或 12 | 太高可能有不兼容算子 |
|
||
| 检查 ONNX 算子 | export 后打印 op_type 列表 | 提前发现不支持算子 |
|
||
| 训练轮次 | 100~200 epoch, ReduceLROnPlateau | 小模型收敛快,不需太多轮 |
|