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))