第16章 如何开发基于单词的神经语言模型
语言建模涉及到在给定现有单词序列的情况下预测序列中的下一个单词,语言模型是许多自然语言处理模型中的关键元素,例如机器翻译和语音识别。语言模型的框架选择必须与语言模型的使用方式相匹配。在本教程中,您将了解在在童谣中生成短序列时,语言模型的框架如何影响模型的能。完成本教程后,您将了解:
为给定的应用程序开发基于单词的语言模型的良好框架的挑战。
如何为基于单词的语言模型开发单字,双字和基于行的框架。
如何使用拟合语言模型生成序列。
16.1 教程概述
本教程分为以下几部分:
- 框架语言建模
- 杰克和吉尔童谣
- 模型1:单字输入,单字输出序列
- 模型2:逐行序列
- 模型3:双字输入,单字输出序列
16.2 框架语言建模
统计语言模型是从原始文本中学习,并且在给定已经存在于序列单词的情况下预测序列后下一个单词的概率。语言模型是用于挑战自然语言处理问题大型模型中的关键组件,如机器翻译和语音识别。它们也可以作为独立模型开发,并用于生成与源文本具有相同统计属性的新序列。
语言模型一次学习和预测一个单词,网络的训练用单词序列作为输入,每次处理一个单词,其中可以为每个输入序列进行预测和学习。类似地,在进行预测时,可以用一个或几个单词作为起点启动预测过程,然后收集预测的单词并将其作为后续预测的输入呈现,以便建立生成的输出序列。因此,每个模型都要考虑拆分源文本为输入和输出序列,使模型可以学习预测单词。有许多方法可以从源文本构建序列以进行语言建模。在本教程中,我们将介绍Keras深度学习库中开发基于单词的语言模型的3种不同方法,没有最好可以适用于所有类型问题的方法,不同的问题只能分别对待。
16.3 杰克和吉尔童谣
杰克和吉尔是一个简单的童谣。它由4行组成,如下所示:
Jack and Jill went up the hill
To fetch a pail of water
Jack fell down and broke his crown
And Jill came tumbling after
清单16.1:Jack和Jill童谣。
我们将使用它作为我们的源文本来探索基于单词的语言模型的不同框架。我们可以在Python中定义这个文本如下:
# source text
data = """ Jack and Jill went up the hill\n
To fetch a pail of water\n
Jack fell down and broke his crown\n
And Jill came tumbling after\n """
代码清单16.2:本教程的示例文本
16.4 模型1:单字输入,单字输出序列
我们可以从一个非常简单的模型开始。给定一个单词作为输入,模型将学习预测序列中的下一个
X, y
Jack, and
and, Jill
Jill, went
...
代码清单16.3:输入和输出对的示例
第一步是将文本编码为整数,源文本中的每个小写字都被赋予一个唯一的整数,我们可以将单词序列转换为整数序列。Keras提供了可用于执行此编码的Tokenizer类。首先,Tokenizer拟合源文本,以开发从单词到唯一整数的映射。然后调用texts_to_sequences()函数将文本序列转换为整数序列。
# integer encode text
tokenizer = Tokenizer()
tokenizer.fit_on_texts([data])
encoded = tokenizer.texts_to_sequences([data])[0]
代码清单16.4:在示例文本上训练Tokenizer的示例
我们需要确定词汇表的大小,以便稍后在模型中定义单词嵌入层中,以及使用one-hot编码对输出单词进行编码,可以通过访问word_index属性从训练好的Tokenizer中获得词汇表的大小。
# determine the vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print( ' Vocabulary Size: %d ' % vocab_size)
代码清单16.5:汇总词汇表的大小。
运行这段代码,得到词汇表的大小是21个单词,我们给其加1,原因是我们需要将最大编码字的整数指定为数组索引,例如,编码为1到21的字,其中数组指示0到21或22个位置。接下来,我们需要创建单词序列以拟合模型,其中一个单词作为输入,一个单词作为输出。
# create word -> word sequences
sequences = list()
for i in range(1, len(encoded)):
sequence = encoded[i-1:i+1]
sequences.append(sequence)
print( ' Total Sequences: %d ' % len(sequences))
代码清单16.6:编码源文本的示例
运行这一部分表明我们总共有24个输入输出对来训练网络。
Total Sequences: 24
代码清单16.7:汇总编码文本的输出示例
然后我们可以将序列分成输入(X)和输出元素(y),这很简单,因为我们在数据中只有两列。
# split into X and y elements
sequences = array(sequences)
X, y = sequences[:,0],sequences[:,1]
代码清单16.8:将编码文本拆分为输入和输出对。
我们将训练我们的模型来预测词汇表中所有单词的概率分布,这意味着我们需要将输出元素从单个整数转换为one-hot编码,对于词汇表中的其他单词都为0,对于实际单词所在的索引位置的值为1。这为我们训练网络提供一个基础,我们可以根据输入和真是的单词one-hot编码对比计算损失函数并更新模型。Keras提供了to_categorical()函数,可以使用它将整数转换为one-hot编码,同时指定词汇表大小为分类的数量。
# one hot encode outputs
y = to_categorical(y, num_classes=vocab_size)
代码清单16.9:对输出字进行热编码。
我们现在准备定义神经网络模型。该模型在输入层中的使用嵌入层学习单词,词汇表中的每个单词都会生成一个实值向量,向量具有确定统一的长度,我们定义为10。输入序列是单个字,因input_lenght=1,跟着输入层接着是LSTM隐藏层,具有50个隐藏单元,这远远超过了需求,输出层是一个全连接层,负责确定是词汇表中的那个单词,分类大小就是词汇表的大小,使用softmax激活函数输出可能单词的概率分布。
# define the model
def define_model(vocab_size):
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation= 'softmax' ))
# compile network
model.compile(loss= 'categorical_crossentropy' , optimizer= 'adam' , metrics=[ 'accuracy' ])
# summarize defined model
model.summary()
plot_model(model, to_file= 'model.png' , show_shapes=True)
return model
代码清单16.10:定义和编译语言模型
网络结构如下:
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
embedding_5 (Embedding) (None, 1, 10) 220
_________________________________________________________________
lstm_5 (LSTM) (None, 50) 12200
_________________________________________________________________
dense_5 (Dense) (None, 22) 1122
=================================================================
Total params: 13,542
Trainable params: 13,542
Non-trainable params: 0
_________________________________________________________________
代码清单16.11:汇总已定义模型的示例输出
然后将定义的模型的图表保存到名为model.png的文件中。
图16.1:定义的基于单词的语言模型的图。
对于本教程中的每个示例,我们将使用相同的通用网络结构,对学习的嵌入层进行微小更改。我们可以在编码的文本数据上编译和拟合网络。从技术上讲,我们正在建模多类分类问题(预测词汇表中的单词),因此使用分类交叉熵损失函数,我们在每个迭代结束时使用有效的Adam算法实现梯度下降和跟踪精度,该模型训练500个迭代,也许需要不了这么的训练迭代。网络配置没有针对此和后续实验进行调整;选择了一个性能余量的配置,以确保我们可以专注于语言模型的框架设计。
在模型拟合之后,我们通过从词汇表中选定的单词传递给它并让模型预测下一个单词来测试它。在这里,我们选择'Jack'并编码传递给模型并调用model.predict_classes()来获取预测单词的整数输出。然后根据词汇表和整数的映射找出确定的单词。
# evaluate
in_text = 'Jack'
print(in_text)
encoded = tokenizer.texts_to_sequences([in_text])[0]
encoded = array(encoded)
yhat = model.predict_classes(encoded, verbose=0)
for word, index in tokenizer.word_index.items():
if index == yhat:
print(word)
代码清单16.12:评估拟合语言模型
然后重复该过程几次以建立生成的单词序列,为了使这更容易,我们将行为结合在一个函数中,我们可以通过传入我们的模型和种子词来调用它。
# generate a sequence from the model
def generate_seq(model, tokenizer, seed_text, n_words):
in_text, result = seed_text, seed_text
# generate a fixed number of words
for _ in range(n_words):
# encode the text as integer
encoded = tokenizer.texts_to_sequences([in_text])[0]
encoded = array(encoded)
# predict a word in the vocabulary
yhat = model.predict_classes(encoded, verbose=0)
# map predicted word index to word
out_word = ''
for word, index in tokenizer.word_index.items():
if index == yhat:
out_word = word
break
# append to input
in_text, result = out_word, result + ' ' + out_word
return result
代码清单16.13:给定拟合模型生成输出序列的函数
我们可以将所有这些结合在一起。完整的代码清单如下。
from numpy import array
from keras.preprocessing.text import Tokenizer
from keras.utils import to_categorical
from keras.utils.vis_utils import plot_model
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM
from keras.layers import Embedding
# generate a sequence from the model
def generate_seq(model, tokenizer, seed_text, n_words):
in_text, result = seed_text, seed_text
# generate a fixed number of words
for _ in range(n_words):
# encode the text as integer
encoded = tokenizer.texts_to_sequences([in_text])[0]
encoded = array(encoded)
# predict a word in the vocabulary
yhat = model.predict_classes(encoded, verbose=0)
# map predicted word index to word
out_word = ''
for word, index in tokenizer.word_index.items():
if index == yhat:
out_word = word
break
# append to input
in_text, result = out_word, result + ' ' + out_word
return result
# define the model
def define_model(vocab_size):
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation='softmax'))
# compile network
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
# summarize defined model
model.summary()
plot_model(model, to_file='model.png', show_shapes=True)
return model
# source text
data = "Jack and Jill went up the hill\n" \
"To fetch a pail of water\n" \
"Jack fell down and broke his crown\n" \
"And Jill came tumbling after\n"
# integer encode text
tokenizer = Tokenizer()
tokenizer.fit_on_texts([data])
encoded = tokenizer.texts_to_sequences([data])[0]
# determine the vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print(' Vocabulary Size: %d ' % vocab_size)
# create word -> word sequences
sequences = list()
for i in range(1, len(encoded)):
sequence = encoded[i - 1:i + 1]
sequences.append(sequence)
print(' Total Sequences: %d ' % len(sequences))
# split into X and y elements
sequences = array(sequences)
X, y = sequences[:, 0], sequences[:, 1]
# one hot encode outputs
y = to_categorical(y, num_classes=vocab_size)
# define model
model = define_model(vocab_size)
# fit network
model.fit(X, y, epochs=500, verbose=1)
# evaluate
print(generate_seq(model, tokenizer, 'Jack', 6))
代码清单16.14:model1的完整示例
运行该示例打印每个训练时期的损失和准确性。
......
Epoch 495/500
24/24 [==============================] - 0s 124us/step - loss: 0.2343 - acc: 0.8750
Epoch 496/500
24/24 [==============================] - 0s 155us/step - loss: 0.2341 - acc: 0.8750
Epoch 497/500
24/24 [==============================] - 0s 118us/step - loss: 0.2338 - acc: 0.8750
Epoch 498/500
24/24 [==============================] - 0s 125us/step - loss: 0.2335 - acc: 0.8750
Epoch 499/500
24/24 [==============================] - 0s 127us/step - loss: 0.2333 - acc: 0.8750
Epoch 500/500
24/24 [==============================] - 0s 129us/step - loss: 0.2330 - acc: 0.8750
代码清单16.15:拟合语言模型的示例输出
jack => and
jack => fell
代码清单16.16:预测下一个单词的示例输出
等等。在运行结束时,Jack被传入并生成预测或新序列。我们得到一个合理的序列作为输出。
注意:鉴于神经网络的随机性,您的具体结果可能会有所不同。考虑运行几次示例。
Jack and jill went up the hill
代码清单16.17:预测单词序列的示例输出
这是一个很好的第一个切割语言模型,但没有充分利用LSTM处理输入序列的能力和使用更广泛的上下文消除一些模糊的成对序列的歧义。
16.5 模型2:逐行序列
另一种方法是逐行拆分源文本,然后将每一行拆分为一系列构建的单词。例如:
X, y
_, _, _, _, _, Jack, and
_, _, _, _, Jack, and, Jill
_, _, _, Jack, and, Jill, went
_, _, Jack, and, Jill, went, up
_, Jack, and, Jill, went, up, the
Jack, and, Jill, went, up, the, hill
代码清单16.18:将问题框架化为单词序列。
这种方法可以允许模型使用每一行的上下文来帮助模型在单字输入和输出模型产生歧义的情况下来消除歧义。在这种情况下,这是以跨行预测单词为代价的,如果我们只对建模和生成文本行感兴趣,这不失为一个解决办法。请注意,在此表示中,我们需要填充序列以确保它们满足固定长度输入。这是使用Keras时的要求。首先,我们可以使用已经拟合源文本的Tokenizer逐行创建整数序列。
# create line-based sequences
sequences = list()
for line in data.split( '\n' ):
encoded = tokenizer.texts_to_sequences([line])[0]
for i in range(1, len(encoded)):
sequence = encoded[:i+1]
sequences.append(sequence)
print( ' Total Sequences: %d ' % len(sequences))
代码清单16.19:准备单词序列的示例
接下来,我们可以填充准备好的序列。我们可以使用Keras中的pad_sequences()函数来完成此操作。这首先涉及找到最长的序列,然后使用它作为填充所有其他序列的长度。
# pad input sequences
max_length = max([len(seq) for seq in sequences])
sequences = pad_sequences(sequences, maxlen=max_length, padding= 'pre' )
print( ' Max Sequence Length: %d ' % max_length)
代码清单16.20:填充单词序列的示例
接下来,我们可以将序列拆分为输入和输出元素,就像之前一样。
# split into input and output elements
sequences = array(sequences)
X, y = sequences[:,:-1],sequences[:,-1]
y = to_categorical(y, num_classes=vocab_size)
代码清单16.21:准备输入和输出序列的示例
然后可以像之前一样定义模型,除了输入序列不在是单个字长度。具体来说,输入序列长度是max length-1,之所以-1,因为当我们计算序列的最大长度时,它们包括输入和输出元素。
# define the model
def define_model(vocab_size, max_length):
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=max_length - 1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation='softmax'))
# compile network
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
# summarize defined model
model.summary()
plot_model(model, to_file='model.png', show_shapes=True)
return model
代码清单16.22:定义并编译语言模型
我们可以像以前一样使用该模型生成新序列,更新generate_seq()函数以通过在每次迭代时将预测添加到输入单词列表来构建输入序列。
# generate a sequence from a language model
def generate_seq(model, tokenizer, max_length, seed_text, n_words):
in_text = seed_text
# generate a fixed number of words
for _ in range(n_words):
# encode the text as integer
encoded = tokenizer.texts_to_sequences([in_text])[0]
# pre-pad sequences to a fixed length
encoded = pad_sequences([encoded], maxlen=max_length, padding='pre')
# predict probabilities for each word
yhat = model.predict_classes(encoded, verbose=0)
# map predicted word index to word
out_word = ''
for word, index in tokenizer.word_index.items():
if index == yhat:
out_word = word
break
# append to input
in_text += ' ' + out_word
return in_text
代码清单16.23:生成给定输入文本的单词序列的函数
将所有这些结合在一起,下面提供了完整的代码示例。
from numpy import array
from keras.preprocessing.text import Tokenizer
from keras.utils import to_categorical
from keras.preprocessing.sequence import pad_sequences
from keras.utils.vis_utils import plot_model
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM
from keras.layers import Embedding
# generate a sequence from a language model
def generate_seq(model, tokenizer, max_length, seed_text, n_words):
in_text = seed_text
# generate a fixed number of words
for _ in range(n_words):
# encode the text as integer
encoded = tokenizer.texts_to_sequences([in_text])[0]
# pre-pad sequences to a fixed length
encoded = pad_sequences([encoded], maxlen=max_length, padding= 'pre' )
# predict probabilities for each word
yhat = model.predict_classes(encoded, verbose=0)
# map predicted word index to word
out_word = ''
for word, index in tokenizer.word_index.items():
if index == yhat:
out_word = word
break
# append to input
in_text += ' ' + out_word
return in_text
# define the model
def define_model(vocab_size, max_length):
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=max_length-1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation= 'softmax' ))
# compile network
model.compile(loss= 'categorical_crossentropy' , optimizer= 'adam' , metrics=[ 'accuracy' ])
# summarize defined model
model.summary()
plot_model(model, to_file= 'model.png' , show_shapes=True)
return model
# source text
data = "Jack and Jill went up the hill\n" \
"To fetch a pail of water\n" \
"Jack fell down and broke his crown\n" \
"And Jill came tumbling after\n"
# prepare the tokenizer on the source text
tokenizer = Tokenizer()
tokenizer.fit_on_texts([data])
# determine the vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print( ' Vocabulary Size: %d ' % vocab_size)
# create line-based sequences
sequences = list()
for line in data.split( '\n' ):
encoded = tokenizer.texts_to_sequences([line])[0]
for i in range(1, len(encoded)):
sequence = encoded[:i+1]
sequences.append(sequence)
print( ' Total Sequences: %d ' % len(sequences))
# pad input sequences
max_length = max([len(seq) for seq in sequences])
sequences = pad_sequences(sequences, maxlen=max_length, padding= 'pre' )
print( ' Max Sequence Length: %d ' % max_length)
# split into input and output elements
sequences = array(sequences)
X, y = sequences[:,:-1],sequences[:,-1]
y = to_categorical(y, num_classes=vocab_size)
# define model
model = define_model(vocab_size, max_length)
# fit network
model.fit(X, y, epochs=500, verbose=2)
# evaluate model
print(generate_seq(model, tokenizer, max_length-1, 'Jack' , 4))
print(generate_seq(model, tokenizer, max_length-1, 'Jill' , 4))
代码清单16.24:model2的完整示例
运行该示例可以更好地拟合源数据。添加的上下文允许模型消除一些歧义。仍有两行以“Jack”开头的文本,仍然是可能会使网络产生困扰。
......
Epoch 495/500
- 0s - loss: 0.1014 - acc: 0.9524
Epoch 496/500
- 0s - loss: 0.1012 - acc: 0.9524
Epoch 497/500
- 0s - loss: 0.1010 - acc: 0.9524
Epoch 498/500
- 0s - loss: 0.1008 - acc: 0.9524
Epoch 499/500
- 0s - loss: 0.1006 - acc: 0.9524
Epoch 500/500
- 0s - loss: 0.1004 - acc: 0.9524
代码清单16.25:拟合语言模型的示例输出
在运行结束时,我们生成两个具有不同种子词的序列:Jack和Jill。第一个生成的行看起来很好,直接匹配源文本。第二个有点奇怪,这是有道理的,因为网络只在输入序列中看到Jill,而不是在序列的开头,因此它强制输出使用单词Jill,即押韵的最后一行。
注意:鉴于神经网络的随机性,您的具体结果可能会有所不同。考虑运行几次示例。
Jack fell down and broke
And jill came tumbling after
代码清单16.26:生成单词序列的示例输出
这是一个很好的例子,说明框架如何产生更好的新行,但是在处理部分行的时候确不理想。
16.6 模型3:双字输入,单字输出序列
- 我们可以在使用单词输入和全句子输入方法之间的进行一下折中,传入单词的子序列作为输入,这就需要两个两个极端框架之间进行权衡,允许生成新行并可以从中间截断。我们将使用3个单词作为输入来预测一个单词作为输出。序列的准备与第一个示例非常相似,只是源序列数组中的偏移量不同,如下所示:
# encode 2 words -> 1 word
sequences = list()
for i in range(2, len(encoded)):
sequence = encoded[i-2:i+1]
sequences.append(sequence)
代码清单16.27:准备约束序列数据的示例
下面列出了完整的示例
from numpy import array
from keras.preprocessing.text import Tokenizer
from keras.utils import to_categorical
from keras.preprocessing.sequence import pad_sequences
from keras.utils.vis_utils import plot_model
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM
from keras.layers import Embedding
# generate a sequence from a language model
def generate_seq(model, tokenizer, max_length, seed_text, n_words):
in_text = seed_text
# generate a fixed number of words
for _ in range(n_words):
# encode the text as integer
encoded = tokenizer.texts_to_sequences([in_text])[0]
# pre-pad sequences to a fixed length
encoded = pad_sequences([encoded], maxlen=max_length, padding= 'pre' )
# predict probabilities for each word
yhat = model.predict_classes(encoded, verbose=0)
# map predicted word index to word
out_word = ''
for word, index in tokenizer.word_index.items():
if index == yhat:
out_word = word
break
# append to input
in_text += ' ' + out_word
return in_text
# define the model
def define_model(vocab_size, max_length):
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=max_length-1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation= 'softmax' ))
# compile network
model.compile(loss= 'categorical_crossentropy' , optimizer= 'adam' , metrics=[ 'accuracy' ])
# summarize defined model
model.summary()
plot_model(model, to_file= 'model.png' , show_shapes=True)
return model
# source text
data = """ Jack and Jill went up the hill\n
To fetch a pail of water\n
Jack fell down and broke his crown\n
And Jill came tumbling after\n """
# integer encode sequences of words
tokenizer = Tokenizer()
tokenizer.fit_on_texts([data])
encoded = tokenizer.texts_to_sequences([data])[0]
# retrieve vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print( ' Vocabulary Size: %d ' % vocab_size)
# encode 2 words -> 1 word
sequences = list()
for i in range(2, len(encoded)):
sequence = encoded[i-2:i+1]
sequences.append(sequence)
print( ' Total Sequences: %d ' % len(sequences))
# pad sequences
max_length = max([len(seq) for seq in sequences])
sequences = pad_sequences(sequences, maxlen=max_length, padding= 'pre' )
print( ' Max Sequence Length: %d ' % max_length)
# split into input and output elements
sequences = array(sequences)
X, y = sequences[:,:-1],sequences[:,-1]
y = to_categorical(y, num_classes=vocab_size)
# define model
model = define_model(vocab_size, max_length)
# fit network
model.fit(X, y, epochs=500, verbose=2)
# evaluate model
print(generate_seq(model, tokenizer, max_length-1, ' Jack and ' , 5))
print(generate_seq(model, tokenizer, max_length-1, ' And Jill ' , 3))
print(generate_seq(model, tokenizer, max_length-1, ' fell down ' , 5))
print(generate_seq(model, tokenizer, max_length-1, ' pail of ' , 5))
代码清单16.28:model3的完整示例
再次运行示例可以很好地拟合源文本,准确度大约为95%。
注意:鉴于神经网络的随机性,您的具体结果可能会有所不同。考虑运行几次示例。
Epoch 495/500
- 0s - loss: 0.0707 - acc: 0.9565
Epoch 496/500
- 0s - loss: 0.0706 - acc: 0.9565
Epoch 497/500
- 0s - loss: 0.0706 - acc: 0.9565
Epoch 498/500
- 0s - loss: 0.0705 - acc: 0.9565
Epoch 499/500
- 0s - loss: 0.0705 - acc: 0.9565
Epoch 500/500
- 0s - loss: 0.0704 - acc: 0.9565
代码清单16.29:拟合语言模型的示例输出
我们看一下4个生成的例子,两个线路起始和两个起始中线。
Jack and jill went up the hill
And Jill went up the
fell down and broke his crown and
pail of water jack fell down and
代码清单16.30:生成单词序列的示例输出
第一次开始行案例正确生成,但第二个生成有问题,第二生成起点是第4行的部分内容,但第一行中也有类似内容,所以生成的时候会出现点状况。第三行的相对第二句的生成好多了,正确生成了文本,与源文本匹配。
我们可以看到语言模型的框架选择以及模型的使用要求必须兼容。一般使用语言模型时需要仔细设计,也许需要通过序列生成的现场测试进行跟踪,以确认模型要求已得到满足。
0 条 查看最新 评论
没有评论