import torch
import pandas as pd
import torch.nn as nn
from sklearn.model_selection import KFold
from torch.utils.data import Dataset, DataLoader
from transformers import AutoTokenizer, AutoConfig, AutoModel, AdamW, get_cosine_schedule_with_warmup

# 本文仅适合解读,其中部分参数并没有设置


# 读取训练数据,并且过滤异常数据,重新构建索引
train_df = pd.read_csv(TRAIN_DATA_PATH)
train_df.drop(train_df[(train_df.target==0) & (train_df.standard_error==0)].index, inplace=True)
train_df.reset_index(drop=True, inplace=True)

# 获取预训练的tokenizer
tokenizer = AutoTokenizer.from_pretrained('roberta-base')

class LitDataset(Dataset):
    def __init__(self, df, inference_only=False):
        super().__init__()
        
        self.df = df
        self.inference_only = inference_only
        self.text = df.excerpt.tolist()

        # 如果不是推理的话,则训练数据本身含有target指标,进行加载
        if no self.inference_only:
            self.target = torch.tensor(df.target.values, dtype=torch.float32)

        """
        Transformers的五种编码方法
        token_ids = tokenizer.encode(text)
        token_ids = tokenizer.encode_plus(text)
        token_ids = tokenizer.batch_encode_plus([text])
        token_ids = tokenizer(text)
        token_ids = tokenizer.convert_tokens_to_ids(tokenizer.tokenize(text))

        分词时通常生成两个文件,merges.txt和vocab.json文件,merges.txt用于把文本转换为单词(分词),vocab.json为从单词到单词ID的映射文件(映射)。
        transformer模型的嵌入层读取单词ID,将单词ID映射到该单词的密集向量表示(Embedding)。

            1. 注意掩码 attention_mask:维度与单词ID张量相同,仅包含0和1(用于截断和填充)
            2. 段ID token_type_ids:单词ID张量中包含多个句子或部分时,标记所属关系。如Q&A中,通过段ID区分疑问和上下文(用于区分)
        
        # 从单词到ID
        通过tokenizer.tokenize将文本字符串转换为单词列表,再通过tokenizer.convert_tokens_to_ids将单词列表转换为可读单词ID列表。
        由于没有有用的参数可供使用(如自动填充/截断、添加特殊标记等)所以无法创建attention_mask和token_type_ids
        # 编码
        encode和encode_plus都对单个字符串执行从单词到ID的映射。
        encode仅输出单词ID张量,encode_plus输出包含单词ID张量和附加张量的字典。
        # bath
        batch_encode_plus对大量文本进行分词,可以构建所需的所有张量(单词ID、注意掩码和段ID)
        # 分词器
        tokenizer(text)通过输入数据类型决定使用encode_plus和batch_encode_plus

        tokenizer.batch_encoded_plus的参数
            1. batch_text_or_text_pairs: 要编码的序列批次或序列对
            2. add_special_tokens:(True) 是否使用与模型相关的特殊标记对序列进行编码
            3. padding:(False) 激活和控制填充
                - True / 'longest': 填充到批处理中最长的序列
                - 'max_length': 填充到参数指定的最大长度或模型最大可接受输入长度
                - False / 'do_not_pad': 无填充,输出具有不同长度序列的批次
            4. truncation:(False) 激活和控制阶段
                - True / 'longest_first': 截断为参数指定的最大长度,max_length如果未提供该参数,则截断为模型最大可接受长度。对序列逐一处理。
                - 'only_first': 截断为参数指定的最大长度。但只截断一对序列的第一个序列。
                - 'only_second': 截断为参数指定的最大长度。但只截断一对序列的第二个序列。
                - False / 'do_not_truncate': 无截断,可以输出序列长度大于模型最大允许输入大小的序列
            5. max_length:(None) 使用的最大长度,控制截断/填充参数。
            7. return_tensors:(python整数列表) 返回的类型
                - 'tf': 返回TensorFlow tf.constant对象
                - 'pt': 返回Pytorch torch.Tensor对象
                - 'np': 返回Numpy np.ndarray对象
            8. retrun_token_type_ids: 是否返回段ID
            9. return_attention_mask: 是否返回注意掩码
        tokenizer.batch_encoded_plus的返回
            1. input_ids: 单词ID列表
            2. token_type_ids: 段ID
            3. attention_mask: 注意掩码
        """
        self.encoded = tokenizer.batch_encoded_plus(self.text,
            padding='max_length', max_length=248, truncation=True, return_attention_mask=True)

    def __len__(self):
        return len(self.df)

    # __getitem__(self, key)被称为魔法方法,返回所给键对应的值。即提供通过下标来索引的能力。
    def __getitem__(self, index):
        # 由于tokenizer.batch_encoded_plus中未设置return_tensor='pt'所以默认返回的是
        input_ids = torch.tensor(self.encoded['input_ids'][index])
        attention_mask = torch.tensor(self.encoded['attention_mask'][index])

        # 如果仅推理,则只返回单词ID和注意掩码,如果是训练则把y_true=target也返回
        if self.inference_only:
            return (input_ids, attention_mask)
        else:
            target = self.target[index]
            return (input_ids, attention_mask, target)


class LitModel(nn.Module):
    def __init__(self):
        super().__init__()
        """
        AutoConfig通过from_pretrained实例化为库的配置类
        AutoConfig.from_pretrained的参数:
            1. pretrained_model_name_or_path: 托管在huggingface.co上的预训练模型ID或本地配置路径(save_pretrained的'config.json'的上一级)
            2. cache_dir: 预训练模型配置的下载目录路径(一共下载三个文件,包含.json和.lock文件)。不推荐使用,编码不对,使用默认缓存地址就行
        如果想下载config.json则使用config.save_pretrained(url)来向url中保存config.json文件,此时可以用作pretrained_model_name_or_path加载
        config实际为一个参数字典,通过.update来更新字段里的参数
        """
        config = AutoConfig.from_pretrained(ROBERTA_PATH)
        config.update({ "output_hidden_states": True, "hidden_dropout_prob": 0.0, "layer_norm_eps": 1e-7 })
        """
        AutoModel通过from_pretrained()或from_config()实例化为基本模型类
        AutoModel.from_config的参数:
            1. config: 根据配置类选择要实例化的模型,从配置文件加载模型不会加载模型权重。不推荐用。
        AutoModel.from_pretrained的参数:
            1. pretrained_model_name_or_path: 托管在huggingface.co上的预训练模型ID或本地配置路径
            2. config: 模型使用的配置,这里可以使用自己更改的配置来替换自动加载的配置
            3. cache_dir: 缓存下载,不推荐使用,编码不对
        如果想下载model.bin和config.json则使用model.save_pretrained(url)来向url中保存模型和配置,可被pretrained_model_name_or_path使用
        """
        self.roberta = AutoModel.from_pretrained(ROBERTA_PATH, config=config)
        self.attention = nn.Sequential( nn.Linear(768, 512), nn.Tanh(), nn.Linear(512, 1), nn.Softmax(dim=1) )
        self.regressor = nn.Sequential( nn.Linear(768, 1) )

    def forward(self, input_ids, attention_mask):
        # 一共有13层hidden_states,1个嵌入层,12个Roberta层,从最后一个roberta层获取隐藏状态(batch_size, max_length, 768)
        roberta_output = self.roberta(input_ids=input_ids, attention_mask=attention_mask)
        last_layer_hidden_states = roberta_output.hidden_states[-1]
        # 为了将所有单元格的隐藏状态浓缩为一个上下文向量,使用注意力机制计算所有单元格隐藏状态的加权平均值(batch_size, max_length, 1)
        # 每个词向量的权重是一致的,这样符合正常的思维逻辑
        weights = self.attention(last_layer_hidden_states)
        context_vector = torch.sum(weights * last_layer_hidden_states, dim=1)
        # 将上下文向量浓缩为预测分数(batch_size, 1)
        return self.regressor(context_vector)


def create_optimizer(model):
    named_parameters = list(model.named_parameters())
    roberta_parameters = named_parameters[:197]
    attention_parameters = named_parameters[199: 203]
    regressor_parameters = named_parameters[203:]
    attention_group = [params for (name, params) in attention_parameters]
    regressor_group = [params for (name, params) in regressor_parameters]
    parameters = []
    parameters.append({"params": attention_group})
    parameters.append({"params": regressor_group})
    # 通过指定layer_num的方式来将layer层分组设置初始学习率
    for layer_num, (name, params) in enumerate(roberta_parameters):
        # 将不需要设置权重衰减的层weight_decay设置为0.0
        weight_decay = 0.0 if "biass" in name else 0.01
        lr = 2e-5
        if layer_num >= 69:
            lr = 5e-5
        if layer_num >= 133:
            lr = 1e-4
        parameters.append({"params": params, "weight_decay": weight_decay, "lr": lr})
    return AdamW(parameters)


eval_schedule = [(0.50, 16), (0.49, 8), (0.48, 4), (0.47, 2), (-1., 1)]

def train(model, model_path, train_loader, val_loader, optimizer, scheduler=None, num_epochs=3):
    # 初始化用于统计训练效果的值
    best_val_rmse = None
    best_epoch = 0
    step = 0
    last_eval_step = 0
    eval_period = eval_schedule[0][1]
    start = time.time()
    # train需要对epoch进行循环
    for epoch in range(num_epochs):
        val_rmse = None
        # 遍历所有batch_size
        for batch_num, (input_ids, attention_mask, target) in enumerate(train_loader):
            input_ids = input_ids.to('cuda:0')
            attention_mask = attention_mask.to('cuda:0')
            target = target.to('cuda:0')
            # 把梯度置零,即每个batch_size内都需要初始化optimizer值
            optimizer.zero_grad()
            # 训练时写model.train()是为了保证归一化层是使用batch_size数据的均值和方差,dropout则随机选择一部分网络来训练更新参数
            # 验证时写model.eval()则可使得归一化层使用全部数据的均值和方差,并且不开启dropout而是使用所有网络连接
            model.train()
            """
            RobertaModel中forward()的参数:
                1. input_ids: 词汇表中输入序列标记的索引
                2. attention_mask: 避免对填充标记索引执行注意掩码
                3. token_type_ids: 分段标记索引以区分输入的第一和第二部分
                4. position_ids: 每个输入序列标记在位置嵌入中的位置索引
            RobertaModel中forward()的返回:
                1. last_hidden_state(batch_size, sequence_length, hidden_size): 模型最后一层输出的隐藏状态序列
                2. pooler_output(batch_size, hidden_size): 序列的第一个分类标记的最后一层隐藏状态
                3. hidden_states: 模型每层输出的隐藏状态加上可选的初始化嵌入输出
            """
            pred = model(input_ids, attention_mask)
            mse = nn.MSELoss(reduction='mean')(pred.flatten(), target)
            # 标量输出进行反向传导,计算梯度
            mse.backward()
            # 根据batch_size的梯度更新参数
            optimizer.step()

            # 如果使用scheduler的话,则需要更新scheduler
            if scheduler:
                scheduler.step()
            # 如果step每次运行满eval_period,则记录该eval_period阶段内的耗时
            if step >= last_eval_step + eval_period:
                elapsed_seconds = time.time() - start
                num_steps = step - last_eval_step
                last_eval_step = step
                # 每个eval_period阶段运行完都会计算一次val_rmse,如果val_rmse降低至eval_schedule设定的阈值之下,则eval_period将以更小值迭代
                # 这里的目的是,采用更加密集的验证速度,防止错过最佳的参数配置。即rmse越小,则验证所需的轮数越少
                val_rmse = math.sqrt(eval_mse(model, val_loader))
                for rmse, period in eval_schedule:
                    if val_rmse >= rmse:
                        eval_period = period
                        break
                # 如果暂时是最佳模型参数的话,则保存模型
                if not best_val_rmse or val_rmse < best_val_rmse:
                    best_val_rmse = val_rmse
                    best_epoch = epoch
                    # 只保留了模型训练好的参数权重
                    torch.save(model.state_dict(), model_path)
                # 重新设置start时间以便计算从训练到再次验证所需的时间(随着验证所需轮数减少,耗时应当越快)
                start = time.time()
            # 完成一步后使用下一个batch的数据进行参数更新
            step += 1
    # 返回最佳的验证集rmse
    return best_val_rmse


# 验证集的mse求解,经过训练过程解读,验证部分理解更为简单
def eval_mse(model, data_loader):
    # model.eval()使得batch_size归一化时采用全局平均值和全局标准差,并且关闭dropout机制
    model.eval()
    mse_sum = 0
    with torch.no_grad():
        for batch_num, (input_ids, attention_mask, target) in enumerate(data_loader):
            input_ids = input_ids.to(device)
            attention_mask = attention_mask.to(device)
            target = target.to(device)
            pred = model(input_ids, attention_mask)
            # 每个batch_size数据的mse损失都会相加在一起
            mse_num += nn.MSELoss(reduction="sum")(pred.flatten(), target).item()
    # 最后求解rmse
    return mse_sum / len(data_loader.dataset)


# 预测过程与验证过程相似,只是data_loader里不再存有target数据,所以也不用计算mse
def perdict(model, data_loader):
    model.eval()
    # 这里的输出结果没有采用拼接的方式而是采用填充的方式,没啥变化
    result = np.zeros(len(data_loader.dataset))
    index = 0
    with torch.no_grad():
        for batch_num, (input_ids, attention_mask) in enumerate(data_loader):
            input_ids = input_ids.to(device)
            attention_mask = attention_mask.to(device)
            pred = model(input_ids, attention_mask)
            result[index: index + pred.shape[0]] = pred.flatten().to("cpu")
            index += pred.shape[0]
    return result


# 训练及验证过程
# 划分n_splits个互斥子集,每次使用其中一个子集当作验证集,剩下n_splits-1个作为训练集,进行n_splits次训练
kfold = KFold(n_splits=5, random_state=42, shuffle=True)
for fold, (train_indices, val_indices) in enumerate(kfold.split(train_df)):
    # 通过kfold的索引来获取数据:(input_ids, attention_mask, target)
    train_dataset = LitDataset(train_df.loc[train_indices])
    val_dataset = LitDataset(train_df.loc[val_indices])

    """
    DataLoader是Pytorch中的一种数据类型,把训练数据分成多个小组,每次抛出一组数据直至把所有数据都抛出
    DataLoader的参数
        1. dataset: 原始数据的输入
        2. batch_size:(1) 每次输入数据的行数
        3. shuffle:(False) 每次迭代训练时是否将数据打乱。将输入数据的顺序打乱可以使数据更有独立性,但不适用于有序特征数据
        4. num_workers:(0) 使用多少个子进程来导入数据。设置为0则是使用
        5. drop_last:(False) 是否丢弃最后不足batch_size的数据
    """
    train_loader = DataLoader(train_dataset, batch_size=16, drop_last=True, shuffle=True, num_workers=2)
    val_loader = DataLoader(val_dataset, batch_size=16, drop_last=False, shuffle=False, num_workers=2)
    model = LitModel().to('cuda:0')
    # 创建优化器,将需要更新的参数传入优化器,并且分组设置不同的学习率和权重衰减
    optimizer = create_optimizer(model)
    """
    使用transformers框架中optimization模块实现学习率动态调整
    optimization模块中包含6种常见的学习率动态调整方式:constant, constant_with_warmup, linear, polynomial, cosine, cosine_with_restarts
    1. constant 常数学习率动态调整:即恒定不变的常数(相当于没用)
        """
        # 现在已经修改了API位置,为transformers.get_constant_schedule
        from transformers import optimization
        model = Model()
        model.train()
        steps = 1000
        optimizer = torch.optim.Adam(model.parameters(), lr=1.0)
        # 用来得到对应的常数学习率变化的实例化对象,last_epoch用于在恢复训练时指定上次结束时的epoch数量
        scheduler = optimization.get_constant_schedule(optimizer, last_epoch=-1)
        for _ in range(steps):
            loss = model(x)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            # 进行学习率的更新
            scheduler.step()
        """
    2. constant_with_warmup 带warmup的常数学习率变化(在训练刚开始先使用一个学习率,训练一些epoches或iterations,等模型稳定再修改为预先设置学习率)
        """
        # 在warmup预热阶段,即steps=[0, num_warmup_steps]过程,学习率在0和优化器中设置的初始lr之间线性增加,预热阶段后维持恒定的学习率
        scheduler = optimization.get_constant_schedule_with_warmup(optimizer, num_warmup_steps=300)
        """
    3. linear 带warmup的常数学习率变化(学习率先从0线性增加到预设,再从预设线性降低至0)
        """
        # 在warmup预热阶段,即从开始到num_warmup_steps过程,学习率在0和优化器中设置的初始lr之间线性增加
        # 在warmup预热阶段之后,即从num_warmup_steps到num_training_steps过程,学习率从优化器中设置的初始lr线性减少至0
        scheduler = optimization.get_linear_schedule_with_warmup(optimizer, num_warmup_steps=300, num_training_steps=steps)
        """
    4. polynomial 基于多项式的学习率动态调整策略
        """
        # 在warmup预热阶段,即从开始到num_warmup_steps过程,学习率在0和优化器中设置的初始lr之间线性增加
        # 在warmup预热阶段之后,即从num_warmup_steps到num_training_steps过程,学习率以多项式衰减方式从初始lr减少至lr_end
        # power表示多项式的次数,当power=1时(默认)等价于get_linear_schedule_with_warmup函数,lr_end表示学习率衰减的最小值
        scheduler = optimization.get_polynomial_decay_schedule_with_warmup(optimizer, num_warmup_steps=300,
            num_training_steps=steps, lr_end=1e-7, power=3)
        """
    5. cosine 基于cosine函数的学习率动态调整方法
        """
        # 在warmup预热阶段,即从开始到num_warmup_steps过程,学习率在0和优化器中设置的初始lr之间线性增加
        # 在warmup预热阶段之后,即从num_warmup_steps到num_training_steps过程,学习率以余弦函数的方式进行周期性变换
        # num_cycles表示循环的次数
        scheduler = optimization.get_cosine_schedule_with_warmup(optimizer, num_warmup_steps=300, num_training_steps=steps,
            num_cycles=2)
        """
    6. cosine_with_restarts 基于cosine函数的硬重启学习率动态调整方法,硬重启就是学习率衰减到0之后直接变回初始lr(即最大值)的方法
        """
        # 在warmup预热阶段,即从开始到num_warmup_steps过程,学习率在0和优化器中设置的初始lr之间线性增加
        # 在warmup预热阶段之后,即从num_warmup_steps到num_training_steps过程,学习率以余弦函数的方式先下降至0,然后变为初始lr(即最大值)
        # 
        scheduler = optimization.get_cosine_with_hard_restarts_schedule_with_warmup(optimizer, num_warmup_steps=300,
            num_training_steps=steps, num_cycles=2)
        """
    """
    # 使用基于cosine函数的学习率动态调整方法,其中循环次数为1,即warmup从0到初始lr,从初始lr以余弦函数减少至0
    scheduler = get_cosine_schedule_with_warmup(optimizer,num_warmup_steps=50, num_training_steps=num_epochs*len(train_loader))
    # 记录val数据集的rmse
    list_val_rmse.append(train(model, model_path, train_loader, val_loader, optimizer, scheduler=scheduler))

    # 每一次fold结束之后删除模型结构即参数,重新加载这些内容,并且释放内存
    del model
    gc.collect()

    print("\nPerformance estimates:")
    print(list_val_rmse)
    print("Mean": np.array(list_val_rmse).mean())


# 测试过程
all_predictions = np.zeros((len(list_val_rmse), len(test_df)))
test_dataset = LitDataset(test_df, inference_only=True)
test_loader = DataLoader(test_dataset, batch_size=16, drop_last=False, shuffle=True, num_workers=2)
# 使用不同fold上训练出来的模型来进行效果测试
for index in range(len(list_val_rmse)):
    model_path = f"model_{index+1}.pth"
    print(f"\nUsing {model_path}")
    model = LitModel()
    # 在获取模型结构的基础上加载保存的模型参数权重
    model.load_state_dict(torch.load(model_path))
    model.to(device)
    all_predictions[index] = perdict(model, test_loader)
    del model
    gc.collect()

标签: none

评论已关闭