linux_learn/learn/NPU/02-模型训练与导出.md

461 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/PReLUNPU 支持不完善
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 | 小模型收敛快不需太多轮 |