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

16 KiB
Raw Blame History

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 数据增强(解决不平衡问题)

电力故障数据天然不均衡——正常运行时间远大于故障时间。

# 对少数类做时间域增强
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

"""
电力波形故障识别 — 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 训练建议

# 安装依赖
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 小模型收敛快,不需太多轮