seq3代码

  • 感觉seq3代码是我看到的写得前2好的代码,另外的代码是transformer-xl
  • 所有的超参数都是从yaml文件中读取的,训练的时候只用指定配置文件即可,感觉和把超参放入sh文件中的方法差不多好。train_options函数返回return args, config,其中config是将yaml文件里面的东西变成了逐层的字典。
python models/sent_lm.py --config model_configs/camera/lm_prior.yaml 

sent_lm

  • 先是使用train_options读取参数,读取出来的config是一个字典。
  • 可以看到arg里面主要是对这次训练的描述,比如是否进行可视化,本次训练的名字是什么,描述是什么,resume是重新训练的ckpt的地址,使用gpu还是cpu等等,而config里面就是本次训练的超参数。
  • 几种加载词典的方式。
  • 加载数据。

dataset 数据加载

  • 首先所有的dataset都继承自torch.utils.data中的Dataset,有两种构建单词表的方法,一种是subword的一种是普通的
  • 一个写的比较好的地方就是和fairseq他们一样,首先对原始的文本进行处理,然后存放一个处理后的二进制文本,然后以后每次训练的时候只是使用这个二进制文件,这里是用pickle实现的,注意这个直接是吧一个函数的返回值完全打包了,而且是根据输入的文件名通过
        args_str = ''.join(args_to_str(args))
        key = hashlib.md5(args_str.encode()).hexdigest()
        cache_file = os.path.join(cache_dir, key)
  • 创建了一个独特的读取码,然后判断文件存不存在,如果存在的话那么就使用pickle读取文件,如果是第一次调用不存在的话那么就先读取data,然后创建。
def disk_memoize(func):
    cache_dir = os.path.join(BASE_DIR, "_cache")
    if not os.path.exists(cache_dir):
        os.makedirs(cache_dir)

    @functools.wraps(func)
    def wrapper_decorator(*args, **kwargs):
        # check fn arguments
        args_str = ''.join(args_to_str(args))
        key = hashlib.md5(args_str.encode()).hexdigest()
        cache_file = os.path.join(cache_dir, key)

        if os.path.exists(cache_file):
            print(f"Loading {cache_file} from cache!")
            with open(cache_file, 'rb') as f:
                return pickle.load(f)
        else:
            print(f"No cache file for {cache_file}...")
            data = func(*args, **kwargs)

            with open(cache_file, 'wb') as pickle_file:
                pickle.dump(data, pickle_file)

            return data

    return wrapper_decorator

# @disk_memoize
def read_corpus(file, tokenize):
    _vocab = Vocab()

    _data = []
    for line in iterate_data(file):
        tokens = tokenize(line)
        _vocab.read_sequence(tokens)
        _data.append(tokens)

    return _vocab, _data
  • iterate_data也写的很好,使用isinstance来判断,如果传入的是字符串,那么就open,如果传入的是一个可迭代的collections.Iterable那么就直接yield,而且这里的tqdm写的也很好的,total指定全部的数据,wc是使用linux里面的wc命令,通过suprocesscheck_output调用。
from subprocess import check_output
def wc(filename):
    return int(check_output(["wc", "-l", filename]).split()[0])
def iterate_data(data):
    if isinstance(data, str):
        assert os.path.exists(data), f"path `{data}` does not exist!"
        with open(data, "r") as f:
            for line in tqdm(f, total=wc(data), desc=f"Reading {data}..."):
                if len(line.strip()) > 0:
                    yield line

    elif isinstance(data, collections.Iterable):
        for x in data:
            yield x
  • 如果是使用subword,那么就要import sentencepiece as spm,然后使用subwordmodel进行切词。
  • 回到BaseLMDataset上,可以看到里面有一个__str__方法目的是为了将这个类变成一个字符串,这样直接print这个类的实例的话,就会将__str__中的东西打印出来。我们还可以看到作者这里使用了一个,这样就可以将一个列表按照表格的形式显示。
from tabulate import tabulate
return tabulate([[x[1] for x in props]], headers=[x[0] for x in props])
from tabulate import tabulate

props = [('ni','7889'),('taa','890988'),('you','safdkk')]
print(tabulate([[x[1] for x in props]], headers=[x[0] for x in props])) 
---------------------------------------输入----------------------------------
  ni     taa  you
----  ------  ------
7889  890988  safdkk

  • BaseLMDataset介绍完了接下来是class SentenceLMDataset(BaseLMDataset):这个很简单只用实例化__len____getitem__,因为self.data是一个列表,因此对于LM来说inputtarget构造好,以及句子的长度
  • 接下来是samplerloader
  • BucketBatchSampler就是根据句子的长度进行排序(这样做的目的是让一个batch内部的数据尽可能长度是一致的),然后返回。
  • 有一个很有意思的是是虽然设置的是,前面的一个是将数据分成预设的batch size,也就是平均会有1/2batch size的数据被丢弃掉,而用了第二种的话会让batch size接近预设的batch size但是丢弃的数据是最少的。
  • from torch.nn.utils.rnn import pad_sequence,在新的位置按照0来填充,填充到最大长度。
  • collate_fn是将batch_sampler中,我们可以看到_collate函数的传入参数是和train_set中的__getitem__的返回值是一样的。
  • 也就是说顺序是train_set提供__len____getitem__train_sampler打包成batch,然后collate_fn做后处理,就是进行Padding和转换成LongTensor

model 构建模型

  • 模型的总体,loss_function = nn.CrossEntropyLoss(ignore_index=0)直接就可以不对mask的做crossEntropyLossoptimizer = optim.Adam(parameters, lr=config["lr"], weight_decay=config["weight_decay"])l2正则化等价于weight_decay,得到模型后model.to(device)
  • tensor.numel()返回的是这个tensor中总共有多少个tensor
>>> a = torch.randn(1, 2, 3, 4, 5)
>>> torch.numel(a)
  • 统计有多少参数,以一个易懂的方式展现出来。

model中的RNNModule

  • 众所周知,lstm中的rnn需要进去的时候降序,然后出来的时候再恢复原始的顺序,可以看到作者的方法还是比较巧妙的,先对tensor进行降序得到降序的下标,然后对下标再升序排序之后得到的就是能恢复的顺序,x = x[sorted_i]outputs = out_unpacked[reverse_i]
class RNNModule(nn.Module, RecurrentHelper):
    def __init__(self, input_size,
                 rnn_size,
                 num_layers=1,
                 bidirectional=False,
                 dropout=0.,
                 pack=True, last=False, countdown=False):
        """
        A simple RNN Encoder, which produces a fixed vector representation
        for a variable length sequence of feature vectors, using the output
        at the last timestep of the RNN.
        Args:
            input_size (int): the size of the input features
            rnn_size (int):
            num_layers (int):
            bidirectional (bool):
            dropout (float):
        """
        super(RNNModule, self).__init__()

        self.pack = pack
        self.last = last
        self.countdown = countdown

        if self.countdown:
            self.Wt = nn.Parameter(torch.rand(1))
            input_size += 1

        self.rnn = nn.LSTM(input_size=input_size,
                           hidden_size=rnn_size,
                           num_layers=num_layers,
                           bidirectional=bidirectional,
                           batch_first=True)

        # the dropout "layer" for the output of the RNN
        self.dropout = nn.Dropout(dropout)

        # define output feature size
        self.feature_size = rnn_size

        # double if bidirectional
        if bidirectional:
            self.feature_size *= 2

    @staticmethod
    def reorder_hidden(hidden, order):
        if isinstance(hidden, tuple):
            hidden = hidden[0][:, order, :], hidden[1][:, order, :]
        else:
            hidden = hidden[:, order, :]

        return hidden

    def forward(self, x, hidden=None, lengths=None):

        batch, max_length, feat_size = x.size()

        if lengths is not None and self.pack:

            ###############################################
            # sorting
            ###############################################
            lenghts_sorted, sorted_i = lengths.sort(descending=True)
            _, reverse_i = sorted_i.sort()

            x = x[sorted_i]

            if hidden is not None:
                hidden = self.reorder_hidden(hidden, sorted_i)

            ###############################################
            # forward
            ###############################################

            if self.countdown:
                ticks = length_countdown(lenghts_sorted).float() * self.Wt
                x = torch.cat([x, ticks.unsqueeze(-1)], -1)

            packed = pack_padded_sequence(x, lenghts_sorted, batch_first=True)

            self.rnn.flatten_parameters()
            out_packed, hidden = self.rnn(packed, hidden)

            out_unpacked, _lengths = pad_packed_sequence(out_packed,
                                                         batch_first=True,
                                                         total_length=max_length)

            out_unpacked = self.dropout(out_unpacked)

            ###############################################
            # un-sorting
            ###############################################
            outputs = out_unpacked[reverse_i]
            hidden = self.reorder_hidden(hidden, reverse_i)

        else:
            # todo: make hidden return the true last states
            self.rnn.flatten_parameters()
            outputs, hidden = self.rnn(x, hidden)
            outputs = self.dropout(outputs)

        if self.last:
            return outputs, hidden, self.last_timestep(outputs, lengths,
                                                       self.rnn.bidirectional)

        return outputs, hidden

  • 设置batch_first=True,然后复原原来的顺序,所以说迭几层的LSTM实现起来真的很简单。
  • hidden_reorder的实现很简单,hidden的顺序应该是seqLen bs hs,所以把第二维进行reorder就行了

SeqReader

  • 创造了一个embeddingEmbed和一个lstmRNNModule,共享参数是这么实现的,首先我们可以看到模型的大多数参数都在从rnn的out映射到词表这个矩阵上的,所以是否我们可以将这个矩阵用embedding矩阵共享参数,因为rnn_size的大小和embedding size的大小可能不一样,所以我们可以先让rnn_size过一个linear,这个linear的参数会小很多,就是图中的down,共享参数也很简单就是self.out.weight = self.embed.embedding.weight
  • self.decode是因为虽然这个只是一个encoder,但是可以根据encoder出来的结果映射成真实的vocab也就是encoder成能懂的句子。
  • optimizer里面带有要训练的参数,而loss_function就是一个单独的loss_function
loss_function = nn.CrossEntropyLoss(ignore_index=0)
parameters = filter(lambda p: p.requires_grad, model.parameters())
optimizer = optim.Adam(parameters,
                       lr=config["lr"], weight_decay=config["weight_decay"])

trainer

  • 这是作者写的一个训练中数据可视化的一个东西,本质上是visdommatplotlib里面的东西,感觉好像是作者写了一个类似于tensorboard的东西。
  • trainer里面存放了如何加载数据进行训练,以及现在训练到了什么epoch,第多少step
best_loss = None
for epoch in range(config["epochs"]):
    train_loss = trainer.train_epoch()
    val_loss = trainer.eval_epoch()

    if config["scheduler"] == "plateau":
        scheduler.step(val_loss)

    elif config["scheduler"] == "cosine":
        scheduler.step()
    elif config["scheduler"] == "step":
        scheduler.step()

    exp.update_metric("lr", optimizer.param_groups[0]['lr'])

    exp.update_metric("ep_loss", train_loss, "TRAIN")
    exp.update_metric("ep_loss", val_loss, "VAL")
    exp.update_metric("ep_ppl", math.exp(train_loss), "TRAIN")
    exp.update_metric("ep_ppl", math.exp(val_loss), "VAL")

    print()
    epoch_log = exp.log_metrics(["ep_loss", "ep_ppl"])
    print(epoch_log)
    exp.update_value("epoch", epoch_log)

    # Save the model if the validation loss is the best we've seen so far.
    if not best_loss or val_loss < best_loss:
        best_loss = val_loss
        trainer.checkpoint()

    print("\n" * 2)

    exp.save()

  • trainer 需要做的事情,如果是重启的话要复原trainer中的epochstep,然后train_epocheval_epoch进行训练和测试返回的是各自的loss

  • 先看一下Trainer需要的传入参数,值的注意的是每个epoch训练完有一个后处理的函数,用于记录是否到了logepoch了,如果是的那么就进行记录,这个后处理函数的接口是封装好的,就是trainer的几个数据。

  • 封装的非常好,我们只需要实现一个如何计算loss的,以及当前有什么状态的即可

class LMTrainer(Trainer):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def _seq_loss(self, predictions, labels):
        _labels = labels.contiguous().view(-1)

        _logits = predictions[0]
        _logits = _logits.contiguous().view(-1, _logits.size(-1))
        loss = self.criterion(_logits, _labels)

        return loss

    def _process_batch(self, inputs, labels, lengths):
        predictions = self.model(inputs, None, lengths)

        loss = self._seq_loss(predictions, labels)
        del predictions
        predictions = None

        return loss, predictions

    def get_state(self):
        if self.train_loader.dataset.subword:
            _vocab = self.train_loader.dataset.subword_path
        else:
            _vocab = self.train_loader.dataset.vocab

        state = {
            "config": self.config,
            "epoch": self.epoch,
            "step": self.step,
            "model": self.model.state_dict(),
            "model_class": self.model.__class__.__name__,
            "optimizers": [x.state_dict() for x in self.optimizers],
            "vocab": _vocab,
        }

        return state
  • _process_batch,就是根据训练数据计算得到loss,这个函数无论是训练和测试的时候都可以用,除过loss第二个prediction是被传入batch_end_callbacks中的output,其他的地方没有用了。
  • get_state是要被写入checkpoint中的东西,其中将config也写进去了。
  • 至此sent_lm的部分算是完成了,接下来是seq3部分

seq3

  • 加载LM模型

AEDataset

  • 主要还是重构__getitem__这个函数,然后加载数据的在BaseLMDataset上已经有了read_corpus,然后self.data中就是存放的训练数据。
  • collate_fn里面转换成longtensor以及进行pad,这里不用排序,在RNN里面的forward的时候才进行排序,这里的pad_sequence是来自from torch.nn.utils.rnn import pad_sequence,按照这个batch中最大的进行填充,填充的value默认是0,但是注意要设置成batch_first
  • 继承的这个seqCollate是自己写的,使用__call__解包然后调用_collate

Model for seq3

  • SeqReader是一个encoder,如果要训练lm设置这里的decode为True,否则的话就是一个纯的encoder
  • 设置batch_first的地方有两个一个是pad_sequence的时候,一个是LSTM的时候
  • decoder就是创建一个LSTM和一个Attention
  • decoder中的get_embedding,除过step=0的时候一般得到的是sosembedding ,其他时候可以选择使用gumbel_softmax得到一个embedding
  • 有一个很奇怪的东西就是为啥要学出一个_init_input_feed
  • 为啥要给这里加这么多的信息,如果ho是上次的decoderoutput信息那么然后这里和decoder出来的生成的emebdding结合起来感觉仍然是很合理的,因此生成embedding的过程中使用了argmax相当于是信息的损失,这里把损失的信息进行了补充。
  • 这里的attention是这么做的,感觉非常的合理,而且decoder的时候是一个step一个step做的。我们可以看到ho就是decoder的输出加上context信息。
  • 控制长度这一块还看不懂。
  • 温度decoder的时候,embedding的获取方式可以是teacher forcing,可以是gready search 也可以是根据gamble_softmax加权的取出来。
  • softplus是平滑一点的relu,学习温度,首先是decoder出来的结果先过一个linear(没有bias)映射到一个数字,然后过一个softplus的激活函数然后加上原始的温度,然后被1除就是学习到的当前的温度

  • 连接以后作为decoder的返回
  • topic_idfembedding size是1,requires_gradFalse
  • embedding进行初始化
  • seq3中生成句子。
  • decoder需要gold_tokens,而作者的是无监督的方法是没有正确答案的,因此制造了一个inp_fake为了在decoder里面可以得到batchsizemax_length
  • length_countdown
def length_countdown(lengths):
    batch_size = lengths.size(0)
    max_length = max(lengths)
    desired_lengths = lengths - 1

    _range = torch.arange(0, -max_length, -1, device=lengths.device)
    _range = _range.repeat(batch_size, 1)
    _countdown = _range + desired_lengths.unsqueeze(-1)

    return _countdown

lengths = torch.LongTensor([1,2,3])
print(length_countdown(lengths))
---------------------------------------输出------------------------------------------
tensor([[ 0, -1, -2],
        [ 1,  0, -1],
        [ 2,  1,  0]])
  • 但是这个countdown也不是硬性截断的,而是乘了一个可学习的参数w_tick
  • tick这个生成的是[[1, 0.3], [-1, 0.5]]这种形式的前一个数字是距离结束还可以生成多少个单词1表示还可以生成1个单词,-1表示还可以生成-1个单词(表示多生成了-1个单词)0.3, 0.5是压缩后的句子应该是原始句子的长度。
  • cmp_lengths代表长度是否说明,直接是截断的最大长度??其实并不是截断的最大长度,因为后面还有一个length losslengthu是pack_padded_sequence再用但是encoder的时候是可以得到真实的句子长度的啊,感觉是下面的没有写好,应该根据dec1_results得到真实的length,而不是用latent_lengths - 1得到。
  • decoder的输出,有单词的概率分布,然后使用这个概率分布进行反向传递,当做是encode的输入单词的embedding
  • 然后使用相当于压缩后的句子进行encoder得到的中间表示再去
  • bridge就是对encoderhidden state进行一些线性和非线性的变换到decoder可以用到的hidden state 的维度然后当做hidden state的初始化。

train seq3.py

  • 映射矩阵从预训练的词向量中进行初始化。
  • unk和预训练词表中没有的词的gradient mask掉,训练的时候不训练。,这个操作也太强了,why?,是通过weightregister_hook来实现的。

  • 通过sklearn计算tf idftf.idf_,但是要对PAD这个token进行neutralize就是设置为1,让pad不要影响计算。

  • 让所有的embedding层是一个,十分简单,因为前面只初始化了一个inp_encoder,所以只用embed指向其他的即可。

  • 共享参数,不使用同一个decoder但是不同decoder的映射词表的矩阵是一样的。

  • 打印参数信息,注意之所以能够print model是因为实现了__str__方法。

  • 添加一些可以可视化的东西。

trainer seq3

topic loss

  • _topic_lossdec1[3]的decoder的第三个输出,是decoder每个词的概率。
  • 用每个词的idf进行训练,
  • 计算方式是emb乘以normal后的tf-idf值,然后按照最后一个维度相加,得到的一个embedding维度的数值进行运算,其实光average一个embedding得到的也是很合理的就是为了让平均的embedding尽可能的相似。
  • 但是蛋疼的一点就是只对source端做tf-idf,不知道为啥对sourse端没有做,直接注释掉了。

lm loss

  • 很简单就是算一个之前的decoder结果的logits和现在的logits算一下kl散度即可

_process_batch

  • 首先得到模型的输出,然后利用输出计算loss

  • 计算重建损失。
  • 然后是上面两种计算LM losstopic Loss,在这里可以看到其实是吧tf-idf当成了attention的权重对于embedding进行加权。

  • 还有一个句子长度的loss,所以说生成的compress的时候生成的句子长度不是强行确定就是那么长的,而是使用了loss来限制的,mask,当eos位置一致的时候loss是最小的,此时eos前面的部分eos_labels0eos后面的部分_logits0,所以只有多出来的部分才算loss

  • generator生成,感觉这一块是不是有问题啊,gumbel_softmax采样的概率虽然是根据logit采样的,但是慢了一步,为何不用统一使用embedding

  • logitdecoder生成的词表的logitdists是从gumbel_softmax中根据上一步的logit和温度采样出来的一个概率分布,然后此时的embedding也是根据这个概率分布得到的。

  • _process_batch返回值有两个一个是loss,此处是一个列表,还可以是一个单独的值,第二个batch_outputs就是一些像打印出来的信息。

  • eval_epoch重写了基础类中的eval_epoch

  • 而重写的eval_epoch直接是生成了句子,并没有计算loss,有原因可能是并不容易过拟合,所以只用人来看一下生成的句子是否合理。

  • 之前先把modeldevicetrainer,然后训练和测试的时候再把此时的batchto(device),这样做为了省显存。

batch = list(map(lambda x: x.to(self.device), batch))
  • train_epochtest_epoch是在seq3.py中调用的,而traineval中调用这这个用于根据把batch 计算loss的函数,这里得到的loss是原始的loss,可以在train_epoch进行梯度裁剪更新参数等其他动作。
  • 原来重写eval_epoch不计算loss的原因是要使用rouge作为评测标准而不是valid上面的loss
  • 重新看一下trainer,里面可以传入一个loss_weight
  • 每次执行完一个train_epoch的时候就会挨个执行batch_end_callbacks里面的函数。

关于前项正常的sample,后项使用概率近似的进行可微的反向传递

  • 实现起来很简单,就是计算loss的时候用的不是真实的采样出来的单词,而是用dec2_logits
  • topic loss这个时候也一样,就是前项的时候根据gambel_softmax的概率取得最大的概率的单词,然后过LM得到logits_oracle,计算loss的时候却是用原始的logits_dec1,计算的,所以说神经网络在训练的时候前项干了什么并不重要,重要的是怎么更新后项,一般的是用前项的直接结果计算,另一种
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容