Files
2026-07-13 12:35:45 +08:00

560 lines
30 KiB
Python

import os
import platform
import sys
import time
import uuid
from datetime import timedelta
import numpy as np
import torch
import torch.distributed as dist
import torch.nn as nn
import yaml
from sklearn.metrics.pairwise import cosine_similarity
from torch.utils.data import DataLoader, BatchSampler, RandomSampler
from torch.utils.data.distributed import DistributedSampler
from torchinfo import summary
from tqdm import tqdm
from visualdl import LogWriter
from loguru import logger
from mvector.data_utils.collate_fn import collate_fn
from mvector.data_utils.featurizer import AudioFeaturizer
from mvector.data_utils.pk_sampler import PKSampler
from mvector.data_utils.reader import MVectorDataset
from mvector.loss import build_loss
from mvector.metric.metrics import compute_fnr_fpr, compute_eer, compute_dcf, accuracy
from mvector.models import build_model
from mvector.models.fc import SpeakerIdentification
from mvector.optimizer import build_optimizer, build_lr_scheduler
from mvector.optimizer.scheduler import MarginScheduler
from mvector.utils.checkpoint import save_checkpoint, load_pretrained, load_checkpoint
from mvector.utils.utils import dict_to_object, print_arguments, convert_string_based_on_type
class MVectorTrainer(object):
def __init__(self,
configs,
use_gpu=True,
data_augment_configs=None,
num_speakers=None,
overwrites=None,
log_level="info"):
"""声纹识别训练工具类
:param configs: 配置文件路径,或者模型名称,如果是模型名称则会使用默认的配置文件
:param use_gpu: 是否使用GPU训练模型
:param data_augment_configs: 数据增强配置字典或者其文件路径
:param num_speakers: 说话人数量,对应配置文件中的model_conf.classifier.num_speakers
:param overwrites: 覆盖配置文件中的参数,比如"train_conf.max_epoch=100",多个用逗号隔开
:param log_level: 打印的日志等级,可选值有:"debug", "info", "warning", "error"
"""
if use_gpu:
assert (torch.cuda.is_available()), 'GPU不可用'
self.device = torch.device("cuda")
else:
os.environ['CUDA_VISIBLE_DEVICES'] = '-1'
self.device = torch.device("cpu")
self.use_gpu = use_gpu
self.log_level = log_level.upper()
logger.remove()
logger.add(sink=sys.stdout, level=self.log_level)
# 读取配置文件
if isinstance(configs, str):
# 获取当前程序绝对路径
absolute_path = os.path.dirname(__file__)
# 获取默认配置文件路径
config_path = os.path.join(absolute_path, f"configs/{configs}.yml")
configs = config_path if os.path.exists(config_path) else configs
with open(configs, 'r', encoding='utf-8') as f:
configs = yaml.load(f.read(), Loader=yaml.FullLoader)
self.configs = dict_to_object(configs)
if num_speakers is not None:
self.configs.model_conf.classifier.num_speakers = num_speakers
# 覆盖配置文件中的参数
if overwrites:
overwrites = overwrites.split(",")
for overwrite in overwrites:
keys, value = overwrite.strip().split("=")
attrs = keys.split('.')
current_level = self.configs
for attr in attrs[:-1]:
current_level = getattr(current_level, attr)
before_value = getattr(current_level, attrs[-1])
setattr(current_level, attrs[-1], convert_string_based_on_type(before_value, value))
# 打印配置信息
print_arguments(configs=self.configs)
self.model = None
self.backbone = None
self.optimizer = None
self.scheduler = None
self.model_output_name = '1.output'
self.audio_featurizer = None
self.train_dataset = None
self.train_loader = None
self.enroll_dataset = None
self.enroll_loader = None
self.trials_dataset = None
self.trials_loader = None
self.margin_scheduler = None
self.amp_scaler = None
# 读取数据增强配置文件
if isinstance(data_augment_configs, str):
with open(data_augment_configs, 'r', encoding='utf-8') as f:
data_augment_configs = yaml.load(f.read(), Loader=yaml.FullLoader)
print_arguments(configs=data_augment_configs, title='数据增强配置')
self.data_augment_configs = dict_to_object(data_augment_configs)
if platform.system().lower() == 'windows':
self.configs.dataset_conf.dataLoader.num_workers = 0
logger.warning('Windows系统不支持多线程读取数据,已自动关闭!')
if self.configs.preprocess_conf.get('use_hf_model', False):
self.configs.dataset_conf.dataLoader.num_workers = 0
logger.warning('使用HuggingFace模型不支持多线程进行特征提取,已自动关闭!')
self.max_step, self.train_step = None, None
self.train_loss, self.train_acc = None, None
self.train_eta_sec = None
self.eval_eer, self.eval_min_dcf, self.eval_threshold = None, None, None
self.test_log_step, self.train_log_step = 0, 0
self.stop_train, self.stop_eval = False, False
def __setup_dataloader(self, is_train=False):
""" 获取数据加载器
:param is_train: 是否获取训练数据
"""
# 获取特征器
self.audio_featurizer = AudioFeaturizer(feature_method=self.configs.preprocess_conf.feature_method,
use_hf_model=self.configs.preprocess_conf.get('use_hf_model', False),
method_args=self.configs.preprocess_conf.get('method_args', {}))
dataset_args = self.configs.dataset_conf.get('dataset', {})
sampler_args = self.configs.dataset_conf.get('sampler', {})
data_loader_args = self.configs.dataset_conf.get('dataLoader', {})
if is_train:
self.train_dataset = MVectorDataset(data_list_path=self.configs.dataset_conf.train_list,
audio_featurizer=self.audio_featurizer,
aug_conf=self.data_augment_configs,
num_speakers=self.configs.model_conf.classifier.num_speakers,
mode='train',
**dataset_args)
train_sampler = RandomSampler(self.train_dataset)
# 使用TripletAngularMarginLoss必须使用PKSampler
use_loss = self.configs.loss_conf.get('loss', 'AAMLoss')
if self.configs.dataset_conf.get("is_use_pksampler", False) or use_loss == "TripletAngularMarginLoss":
# 设置支持多卡训练
if torch.cuda.device_count() > 1:
train_sampler = DistributedSampler(dataset=self.train_dataset, shuffle=True)
batch_sampler = PKSampler(sampler=train_sampler,
sample_per_id=self.configs.dataset_conf.get("sample_per_id", 4),
**sampler_args)
else:
# 设置支持多卡训练
if torch.cuda.device_count() > 1:
train_sampler = DistributedSampler(dataset=self.train_dataset, shuffle=True)
batch_sampler = BatchSampler(sampler=train_sampler, **sampler_args)
self.train_loader = DataLoader(dataset=self.train_dataset,
collate_fn=collate_fn,
batch_sampler=batch_sampler,
**data_loader_args)
dataset_args.max_duration = self.configs.dataset_conf.eval_conf.max_duration
# 获取评估的注册数据和检验数据
self.enroll_dataset = MVectorDataset(data_list_path=self.configs.dataset_conf.enroll_list,
audio_featurizer=self.audio_featurizer,
mode='eval',
**dataset_args)
self.enroll_loader = DataLoader(dataset=self.enroll_dataset,
collate_fn=collate_fn,
shuffle=False,
batch_size=self.configs.dataset_conf.eval_conf.batch_size,
**data_loader_args)
self.trials_dataset = MVectorDataset(data_list_path=self.configs.dataset_conf.trials_list,
audio_featurizer=self.audio_featurizer,
mode='eval',
**dataset_args)
self.trials_loader = DataLoader(dataset=self.trials_dataset,
collate_fn=collate_fn,
shuffle=False,
batch_size=self.configs.dataset_conf.eval_conf.batch_size,
**data_loader_args)
def extract_features(self, save_dir='dataset/features', max_duration=100):
""" 提取特征保存文件
:param save_dir: 保存路径
:param max_duration: 提取特征的最大时长,避免过长显存不足,单位秒
"""
self.audio_featurizer = AudioFeaturizer(feature_method=self.configs.preprocess_conf.feature_method,
use_hf_model=self.configs.preprocess_conf.get('use_hf_model', False),
method_args=self.configs.preprocess_conf.get('method_args', {}))
dataset_args = self.configs.dataset_conf.get('dataset', {})
dataset_args.max_duration = max_duration
data_loader_args = self.configs.dataset_conf.get('dataLoader', {})
data_loader_args.drop_last = False
for data_list in [self.configs.dataset_conf.train_list,
self.configs.dataset_conf.enroll_list,
self.configs.dataset_conf.trials_list]:
# 获取测试数据
dataset_args = self.configs.dataset_conf.get('dataset', {})
dataset_args.max_duration = max_duration
test_dataset = MVectorDataset(data_list_path=data_list,
audio_featurizer=self.audio_featurizer,
mode='extract_feature',
**dataset_args)
test_loader = DataLoader(dataset=test_dataset,
collate_fn=collate_fn,
shuffle=False,
**data_loader_args)
save_data_list = data_list.replace('.txt', '_features.txt')
with open(save_data_list, 'w', encoding='utf-8') as f:
for features, labels, input_lens in tqdm(test_loader):
for i in range(len(features)):
feature, label, input_len = features[i], labels[i], input_lens[i]
feature = feature.numpy()[:input_len]
label = int(label)
save_path = os.path.join(save_dir, str(label),
f'{str(uuid.uuid4())}.npy').replace('\\', '/')
os.makedirs(os.path.dirname(save_path), exist_ok=True)
np.save(save_path, feature)
f.write(f'{save_path}\t{label}\n')
logger.info(f'{data_list}列表中的数据已提取特征完成,新列表为:{save_data_list}')
def __setup_model(self, input_size, is_train=False):
""" 获取模型
:param input_size: 模型输入特征大小
:param is_train: 是否获取训练模型
"""
# 获取模型
self.backbone = build_model(input_size=input_size, configs=self.configs)
# 获取训练所需的函数
if is_train:
if self.configs.train_conf.enable_amp:
self.amp_scaler = torch.GradScaler("cuda", init_scale=1024)
# 获取分类器
num_class = self.configs.model_conf.classifier.num_speakers
# 语速扰动要增加分类数量
if self.data_augment_configs.speed.prob > 0:
if self.data_augment_configs.speed.speed_perturb_3_class:
self.configs.model_conf.classifier.num_speakers = num_class * 3
# 分类器
classifier = SpeakerIdentification(input_dim=self.backbone.embd_dim,
**self.configs.model_conf.classifier)
# 合并模型
self.model = nn.Sequential(self.backbone, classifier)
# print(self.model)
# 获取损失函数
self.loss = build_loss(configs=self.configs)
# 损失函数margin调度器
if self.configs.loss_conf.get('use_margin_scheduler', False):
margin_scheduler_args = dict(increase_start_epoch=int(self.configs.train_conf.max_epoch * 0.3),
fix_epoch=int(self.configs.train_conf.max_epoch * 0.7),
initial_margin=0.0,
final_margin=0.3)
margin_scheduler_args.update(self.configs.loss_conf.get('margin_scheduler_args', {}))
self.margin_scheduler = MarginScheduler(criterion=self.loss,
step_per_epoch=len(self.train_loader),
**margin_scheduler_args)
# 获取优化方法
self.optimizer = build_optimizer(params=self.model.parameters(), configs=self.configs)
# 学习率衰减函数
self.scheduler = build_lr_scheduler(optimizer=self.optimizer, step_per_epoch=len(self.train_loader),
configs=self.configs)
else:
# 不训练模型不包含分类器
self.model = nn.Sequential(self.backbone)
self.model.to(self.device)
self.model.to(self.device)
if self.log_level == "DEBUG" or self.log_level == "INFO":
# 打印模型信息,98是长度,这个取决于输入的音频长度
summary(self.model, (1, 98, input_size))
# 使用Pytorch2.0的编译器
if self.configs.train_conf.use_compile and torch.__version__ >= "2" and platform.system().lower() != 'windows':
self.model = torch.compile(self.model, mode="reduce-overhead")
def __train_epoch(self, epoch_id, save_model_path, local_rank, writer, nranks=0):
"""训练一个epoch
:param epoch_id: 当前epoch
:param local_rank: 当前显卡id
:param writer: VisualDL对象
:param nranks: 所使用显卡的数量
"""
train_times, accuracies, loss_sum = [], [], []
start = time.time()
use_loss = self.configs.loss_conf.get('use_loss', 'AAMLoss')
for batch_id, (features, label, input_len) in enumerate(self.train_loader):
if self.stop_train: break
if nranks > 1:
features = features.to(local_rank)
label = label.to(local_rank).long()
else:
features = features.to(self.device)
label = label.to(self.device).long()
# 执行模型计算,是否开启自动混合精度
with torch.autocast('cuda', enabled=self.configs.train_conf.enable_amp):
outputs = self.model(features)
logits = outputs['logits']
# 计算损失值
los = self.loss(outputs, label)
# 是否开启自动混合精度
if self.configs.train_conf.enable_amp:
# loss缩放,乘以系数loss_scaling
scaled = self.amp_scaler.scale(los)
scaled.backward()
else:
los.backward()
# 是否开启自动混合精度
if self.configs.train_conf.enable_amp:
self.amp_scaler.unscale_(self.optimizer)
self.amp_scaler.step(self.optimizer)
self.amp_scaler.update()
else:
self.optimizer.step()
self.optimizer.zero_grad()
# 计算准确率
if use_loss == 'SubCenterLoss':
loss_args = self.configs.loss_conf.get('loss_args', {})
cosine = torch.reshape(logits, (-1, logits.shape[1] // loss_args.K, loss_args.K))
logits, _ = torch.max(cosine, 2)
acc = accuracy(logits, label)
accuracies.append(acc)
loss_sum.append(los.data.cpu().numpy())
train_times.append((time.time() - start) * 1000)
self.train_step += 1
# 多卡训练只使用一个进程打印
if batch_id % self.configs.train_conf.log_interval == 0 and local_rank == 0:
# 计算每秒训练数据量
train_speed = self.configs.dataset_conf.sampler.batch_size / (
sum(train_times) / len(train_times) / 1000)
# 计算剩余时间
self.train_eta_sec = (sum(train_times) / len(train_times)) * (self.max_step - self.train_step) / 1000
eta_str = str(timedelta(seconds=int(self.train_eta_sec)))
self.train_loss = sum(loss_sum) / len(loss_sum)
self.train_acc = sum(accuracies) / len(accuracies)
logger.info(f'Train epoch: [{epoch_id}/{self.configs.train_conf.max_epoch}], '
f'batch: [{batch_id}/{len(self.train_loader)}], '
f'loss: {self.train_loss:.5f}, accuracy: {self.train_acc:.5f}, '
f'learning rate: {self.scheduler.get_last_lr()[0]:.8f}, '
f'speed: {train_speed:.2f} data/sec, eta: {eta_str}')
writer.add_scalar('Train/Loss', self.train_loss, self.train_log_step)
writer.add_scalar('Train/Accuracy', self.train_acc, self.train_log_step)
# 记录学习率
writer.add_scalar('Train/lr', self.scheduler.get_last_lr()[0], self.train_log_step)
if self.margin_scheduler:
writer.add_scalar('Train/margin', self.margin_scheduler.get_margin(), self.train_log_step)
self.train_log_step += 1
train_times, accuracies, loss_sum = [], [], []
# 固定步数也要保存一次模型
if batch_id % 10000 == 0 and batch_id != 0 and local_rank == 0:
save_checkpoint(configs=self.configs, model=self.model, optimizer=self.optimizer,
amp_scaler=self.amp_scaler, margin_scheduler=self.margin_scheduler,
save_model_path=save_model_path, epoch_id=epoch_id)
start = time.time()
self.scheduler.step()
if self.margin_scheduler:
self.margin_scheduler.step()
def train(self,
save_model_path='models/',
log_dir='log/',
max_epoch=None,
resume_model=None,
pretrained_model=None,
do_eval=True):
"""
训练模型
:param save_model_path: 模型保存的路径
:param log_dir: 保存VisualDL日志文件的路径
:param max_epoch: 最大训练轮数,对应配置文件中的train_conf.max_epoch
:param resume_model: 恢复训练,当为None则不使用预训练模型
:param pretrained_model: 预训练模型的路径,当为None则不使用预训练模型
:param do_eval: 训练时是否评估模型
"""
# 获取有多少张显卡训练
nranks = torch.cuda.device_count()
local_rank = 0
writer = None
if local_rank == 0:
# 日志记录器
writer = LogWriter(logdir=log_dir)
if nranks > 1 and self.use_gpu:
# 初始化NCCL环境
dist.init_process_group(backend='nccl')
local_rank = int(os.environ["LOCAL_RANK"])
# 获取数据
self.__setup_dataloader(is_train=True)
# 获取模型
self.__setup_model(input_size=self.audio_featurizer.feature_dim, is_train=True)
# 加载预训练模型
self.model = load_pretrained(model=self.model, pretrained_model=pretrained_model)
# 加载恢复模型
self.model, self.optimizer, self.amp_scaler, self.scheduler, self.margin_scheduler, last_epoch, best_eer = \
load_checkpoint(configs=self.configs, model=self.model, optimizer=self.optimizer,
amp_scaler=self.amp_scaler, scheduler=self.scheduler,
margin_scheduler=self.margin_scheduler, step_epoch=len(self.train_loader),
save_model_path=save_model_path, resume_model=resume_model)
# 支持多卡训练
if nranks > 1 and self.use_gpu:
self.model.to(local_rank)
self.model = torch.nn.parallel.DistributedDataParallel(self.model, device_ids=[local_rank])
logger.info(f'训练数据:{len(self.train_dataset)}')
self.train_loss, self.train_acc = None, None
self.test_log_step, self.train_log_step = 0, 0
self.eval_eer, self.eval_min_dcf, self.eval_threshold = None, None, None
if local_rank == 0:
writer.add_scalar('Train/lr', self.scheduler.get_last_lr()[0], last_epoch)
if max_epoch is not None:
self.configs.train_conf.max_epoch = max_epoch
# 最大步数
self.max_step = len(self.train_loader) * self.configs.train_conf.max_epoch
self.train_step = max(last_epoch, 0) * len(self.train_loader)
# 开始训练
for epoch_id in range(last_epoch, self.configs.train_conf.max_epoch):
if self.stop_train: break
epoch_id += 1
start_epoch = time.time()
# 训练一个epoch
self.__train_epoch(epoch_id=epoch_id, save_model_path=save_model_path, local_rank=local_rank,
writer=writer, nranks=nranks)
# 多卡训练只使用一个进程执行评估和保存模型
if local_rank == 0 and do_eval:
if self.stop_eval: continue
logger.info('=' * 70)
self.eval_eer, self.eval_min_dcf, self.eval_threshold = self.evaluate()
logger.info('Test epoch: {}, time/epoch: {}, threshold: {:.2f}, EER: {:.5f}, '
'MinDCF: {:.5f}'.format(epoch_id, str(timedelta(
seconds=(time.time() - start_epoch))), self.eval_threshold, self.eval_eer, self.eval_min_dcf))
logger.info('=' * 70)
writer.add_scalar('Test/threshold', self.eval_threshold, self.test_log_step)
writer.add_scalar('Test/min_dcf', self.eval_min_dcf, self.test_log_step)
writer.add_scalar('Test/eer', self.eval_eer, self.test_log_step)
self.test_log_step += 1
self.model.train()
# # 保存最优模型
if self.eval_eer <= best_eer:
best_eer = self.eval_eer
save_checkpoint(configs=self.configs, model=self.model, optimizer=self.optimizer,
amp_scaler=self.amp_scaler, margin_scheduler=self.margin_scheduler,
save_model_path=save_model_path, epoch_id=epoch_id, eer=self.eval_eer,
min_dcf=self.eval_min_dcf, threshold=self.eval_threshold, best_model=True)
if local_rank == 0:
# 保存模型
save_checkpoint(configs=self.configs, model=self.model, optimizer=self.optimizer,
amp_scaler=self.amp_scaler, margin_scheduler=self.margin_scheduler,
save_model_path=save_model_path, epoch_id=epoch_id, eer=self.eval_eer,
min_dcf=self.eval_min_dcf, threshold=self.eval_threshold)
def evaluate(self, resume_model=None, save_image_path=None):
"""
评估模型
:param resume_model: 所使用的模型
:param save_image_path: 保存图片的路径
:return: 评估结果
"""
if self.enroll_loader is None or self.trials_loader is None:
self.__setup_dataloader()
if self.model is None:
self.__setup_model(input_size=self.audio_featurizer.feature_dim)
if resume_model is not None:
if os.path.isdir(resume_model):
resume_model = os.path.join(resume_model, 'model.pth')
assert os.path.exists(resume_model), f"{resume_model} 模型不存在!"
self.model = load_pretrained(self.model, resume_model, use_gpu=self.use_gpu)
self.model.eval()
if isinstance(self.model, torch.nn.parallel.DistributedDataParallel):
eval_model = self.model.module if len(self.model.module) == 1 else self.model.module[0]
else:
eval_model = self.model if len(self.model) == 1 else self.model[0]
# 获取注册的声纹特征和标签
enroll_features, enroll_labels = None, None
with torch.no_grad():
for batch_id, (audio_features, label, input_len) in enumerate(
tqdm(self.enroll_loader, desc="注册音频声纹特征")):
if self.stop_eval: break
audio_features = audio_features.to(self.device)
label = label.to(self.device).long()
feature = eval_model(audio_features).data.cpu().numpy()
label = label.data.cpu().numpy().astype(np.int32)
# 存放特征
enroll_features = np.concatenate((enroll_features, feature)) if enroll_features is not None else feature
enroll_labels = np.concatenate((enroll_labels, label)) if enroll_labels is not None else label
# 获取检验的声纹特征和标签
trials_features, trials_labels = None, None
with torch.no_grad():
for batch_id, (audio_features, label, input_lens) in enumerate(
tqdm(self.trials_loader, desc="验证音频声纹特征")):
if self.stop_eval: break
audio_features = audio_features.to(self.device)
label = label.to(self.device).long()
feature = eval_model(audio_features).data.cpu().numpy()
label = label.data.cpu().numpy().astype(np.int32)
# 存放特征
trials_features = np.concatenate((trials_features, feature)) if trials_features is not None else feature
trials_labels = np.concatenate((trials_labels, label)) if trials_labels is not None else label
self.model.train()
logger.info('开始对比音频特征...')
all_score, all_labels = [], []
for i in tqdm(range(len(trials_features)), desc='特征对比'):
if self.stop_eval: break
trials_feature = np.expand_dims(trials_features[i], 0)
score = cosine_similarity(trials_feature, enroll_features).astype(np.float32).tolist()[0]
trials_label = np.expand_dims(trials_labels[i], 0).repeat(len(enroll_features), axis=0)
y_true = np.array(enroll_labels == trials_label).astype(np.int32).tolist()
all_score.extend(score)
all_labels.extend(y_true)
if self.stop_eval: return -1, -1, -1,
# 计算EER
all_score = np.array(all_score, dtype=np.float32)
all_labels = np.array(all_labels, dtype=np.int32)
fnr, fpr, thresholds = compute_fnr_fpr(all_score, all_labels)
eer, threshold = compute_eer(fnr, fpr, all_score)
min_dcf = compute_dcf(fnr, fpr)
eer, min_dcf, threshold = float(eer), float(min_dcf), float(threshold)
if save_image_path:
import matplotlib.pyplot as plt
index = np.where(np.array(thresholds) == threshold)[0][0]
plt.plot(thresholds, fnr, color='blue', linestyle='-', label='fnr')
plt.plot(thresholds, fpr, color='red', linestyle='-', label='fpr')
plt.plot(threshold, fpr[index], 'ro-')
plt.text(threshold, fpr[index], (round(threshold, 3), round(fpr[index], 5)), color='red')
plt.xlabel('threshold')
plt.title('fnr and fpr')
plt.grid(True) # 显示网格线
# 保存图像
os.makedirs(save_image_path, exist_ok=True)
plt.savefig(os.path.join(save_image_path, 'result.png'))
logger.info(f"结果图以保存在:{os.path.join(save_image_path, 'result.png')}")
return eer, min_dcf, threshold
def export(self, save_model_path='models/', resume_model='models/CAMPPlus_Fbank/best_model/'):
"""
导出预测模型
:param save_model_path: 模型保存的路径
:param resume_model: 准备转换的模型路径
:return:
"""
# 获取模型
self.__setup_model(input_size=self.audio_featurizer.feature_dim)
# 加载预训练模型
if os.path.isdir(resume_model):
resume_model = os.path.join(resume_model, 'model.pth')
assert os.path.exists(resume_model), f"{resume_model} 模型不存在!"
model_state_dict = torch.load(resume_model)
self.model.load_state_dict(model_state_dict)
logger.info('成功恢复模型参数和优化方法参数:{}'.format(resume_model))
self.model.eval()
# 获取静态模型
infer_model = torch.jit.script(self.model)
infer_model_path = os.path.join(save_model_path,
f'{self.configs.use_model}_{self.configs.preprocess_conf.feature_method}',
'inference.pt')
os.makedirs(os.path.dirname(infer_model_path), exist_ok=True)
torch.jit.save(infer_model, infer_model_path)
logger.info("预测模型已保存:{}".format(infer_model_path))