赞
踩
【参考:中文情感分析之TextCNN_狮子HH的博客-CSDN博客】写得非常不错
TextCNN模型的结构比较简单,由输入表征 --> 卷积层 --> 最大池化 --> 全连接层 --> 输出softmax
组成
我们从某电商网站中拿到了50000条评论数据,这些数据中好评和差评数据各占25000条,其中的一条好评数据是“质量好,做工也不错,而且尺码标准。”。拿到这些数据后,我们进行的第一步处理是数据预处理过程,即对这50000条数据中的每一条进行分词、去停用词操作。分词故名思义就是将句子切分成词语,分词后得到“质量 好 , 做工 也 不错 , 而且 尺码 标准”。去停用词是去掉无意义的词语或符号,如‘也’、‘而且’、标点符号等起连接作用的词,去停用词后得到“质量 好 做工 不错 尺码 标准”。经过预处理过程后我们就把原来的中文句子,转化为这样的词语组合,原来的每条句子变成了由多个词组成的词组。
将句子拆成词组之后,需要继续将其转化为方便计算机处理的数字,最直观的想法就是给每个词分一个编号,以编号来代替词语。这个想法是可行的,然而我们也没必要对所有词语进行编号,因为这之间肯定有很多词语只是偶尔出现了一次。所以我们考虑对出现的所有词语进行统计分析,看下各个词语出现的概率,然后取出现概率最高的前n个词语对其进行编号,将词语映射为id。构建了这样一个映射关系后,就可以将各个词组转换为数字列表。“质量 好 做工 不错 尺码 标准”就变成了[15, 20, 210, 3, 337, 241],也就是说“质量”这个词语的编号就是15。这里有个问题,我们在编号过程中丢掉了一些低频词,那这些词要怎么转为数字呢?先不做处理,给他个特殊标记,如OOV。
至此,词组转成了数字列表,但是由于原句子长短不一,转换后的数字列表也长短不齐,由于后续模型的输入需要固定尺寸,因此还需要对数字列表进行补全/截断操作。假设要将数字列表的长度都变为10,补全后得到[15, 20, 210, 3, 337, 241, 0, 0, 0, 0]。
将词语转为数字后,我们就可以来构建TextCNN模型了,模型的第一层是Embedding层,这一层的作用是将词语转换为词向量(word embedding)。我们所拥有的数据量并不大,所以考虑使用预训练好的词向量来进行模型训练。构建Embedding层需要使用之前将词语转数值时的词语和id的映射关系,假设word_index表示word–>id的映射,那么embedding矩阵即为id–>embedding_vector的映射,也就是说embedding矩阵是一个词向量列表,它的第i个元素表示原word_index中id为i的词对应的词向量(embedding矩阵每一行代表一个词语
)。绕了一圈,做的工作其实就是将单词转为了词向量,只不过因为模型输入需要是数字,所以多做了词语到数值,数值到词向量的转换。如果觉得比较绕,可以简单的将Embedding层理解为将词语转为词向量。(对于之前的标记,由于它不在词向量矩阵中,所以经过该层后以0向量填充,相当于将低频词都映射为0向量了)
假设输入样本长度为10(每个样本即为一个词语),词向量维度为300,经过Embedding层后,输出为10 × 300 的词向量矩阵。至此,我们已经成功地将中文文本转化为规整的m × n矩阵,这样的数据可以作为各种机器学习模型的输入。
在TextCNN模型中,数据经过Embedding层转换后送入卷积层,这里卷积使用一维卷积,这是因为虽然Embedding层输出是一个二维矩阵,但是在词向量维度上的卷积运算是无意义的,因此只进行时间维度上的卷积运算来获取相邻词之间的关系。一维卷积带来的一个问题是,需要设计不同尺寸的滤波器核来捕获不同尺度的信息。直观上看,中文词以2个或4个为一组的比较多,因此考虑使用滤波器核尺寸为2、4和5的(shape:(行数,词向量长度))三种滤波器
进行卷积操作,每种滤波器使用128个。10×300的矩阵经过kernal_size为2,4,5的一维卷积操作后分别得到9(10-2+1) × 128(滤波器的个数 = 生成的特征图的个数) 、7 × 128 和6 × 12 。
注意:一维卷积不代表卷积核只有一维,也不代表被卷积的feature也是一维。一维的意思是说卷积的方向是一维的。 上面就是从上往下卷积
数据经过卷积层进行特征映射后,送入时序最大池化层,即一维全局最大池化,该层的主要作用是提取各滤波器输出的主要特征,过滤掉无效数据的影响。经过最大池化处理后,之前处理中的padding补全操作或者无映射关系标记为等处理的数据都会过滤掉。
经过时序最大池化处理后得到的是3组(前面三种滤波器)128维(滤波器个数)的向量,我们将这3组输出合并起来得到384维的向量数据,将其作为全连接层的输入。
全连接层使用一个有两个隐层的神经网络,每层的节点数设置为128,在两层隐层之间加入dropout层用于减轻过拟合。dropout层就是在每次训练过程中按概率随机丢弃一些参数,比如当丢弃率设置为0.5的时候,在每次训练过程中更新隐层权重的时候会以50%的概率对128个节点的权重进行更新,也就是说在每次训练回合中只更新其中的64个节点的参数。dropout层可以有效减轻模型的过拟合程度,使得模型具有较好的泛化性能。
from keras.preprocessing import sequence
from keras.models import Sequential
from keras.layers import Dense,Dropout,Embedding
from keras.layers import Conv1D,GlobalMaxPool1D
from keras.datasets import imdb
# from plot_model import plot_model # 这个没用
from keras.utils.vis_utils import plot_model # 画模型图
import os
# 需要转这个软件 Graphviz
os.environ["PATH"] += os.pathsep + 'C:/Program Files/Graphviz/bin'
# 最大词汇数量 max_words=10000 # 最长句子设置为400 # 这里句子长度值的是句子词汇数量,句子有100个词则长度为100 max_len=400 # 批次大小 batch_size=32 # 25000 // 32 = 782 一批次有大概782个数据 # 词向量长度 embedding_dims=128 # 训练周期 epochs=3 # 滤波器数量 filter=64 # 卷积核大小 shape(3,词向量的长度) 宽为3的矩形,长为词向量的长度 kernel_size=3 # 载入imdb评论数据集,设置最大词汇数,只保留出现频率最高的前max_words个词 # 出现频率越高,编号越小。词的编号从4开始,也就是频率最大的词编号为4。 # 编号0表示padding,1表示句子的开始(每个句子第一个编号都是1),2表示OOV,3表示预留(所有的数据中都没有3) # Out-of-vocabulary,简称OOV,表示不在字典中的词 # 数据的标签为0和1。0表示负面情感,1表示正面情感。 (x_train,y_train),(x_test, y_test)=imdb.load_data( num_words=max_words) # 查看测试集第0个句子 print(x_test[0])
[1, 591, 202, 14, 31, 6, 717, 10, 10, 2, 2, 5, 4, 360, 7, 4, 177, 5760, 394, 354, 4, 123, 9, 1035, 1035, 1035, 10, 10, 13, 92, 124, 89, 488, 7944, 100, 28, 1668, 14, 31, 23, 27, 7479, 29, 220, 468, 8, 124, 14, 286, 170, 8, 157, 46, 5, 27, 239, 16, 179, 2, 38, 32, 25, 7944, 451, 202, 14, 6, 717]
# 获得imdb数据集的字典,字典的键是英语词汇,值是编号 # 注意这个字典的编词汇编号跟数据集中的词汇编号是不对应的 # 数据集中的编号减三才能得到这个字典的编号,(前面写了词的编号从4开始)举个例子: # 比如在x_train中'a'的编号为6,在word2id中'a'的编号为3 word2id = imdb.get_word_index() # 把字典的键值对反过来:键是编号,值是英语词汇 # 编号数值范围:0-88587 # value+3把字典中词汇的编号跟x_train和x_test数据中的编号对应起来 id2word = dict([(value+3, key) for (key, value) in word2id.items()]) # 设置预留字符 id2word[3] = '[RESERVE]' # 设置Out-of-vocabulary字符 id2word[2] = '[OOV]' # 设置起始字符 id2word[1] = '[START]' # 设置填充字符 id2word[0] = '[PAD]' # In[6]: # 在词典中查询得到原始英语句子,如果编号不在字典用则用'?'替代 decoded_review = ' '.join([id2word.get(i, '?') for i in x_test[0]]) print(decoded_review)
x_train shape: (25000, 400) x_test shape: (25000, 400) [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 591 202 14 31 6 717 10 10 2 2 5 4 360 7 4 177 5760 394 354 4 123 9 1035 1035 1035 10 10 13 92 124 89 488 7944 100 28 1668 14 31 23 27 7479 29 220 468 8 124 14 286 170 8 157 46 5 27 239 16 179 2 38 32 25 7944 451 202 14 6 717]
# 构建模型 model=Sequential() # Embedding是一个权值矩阵,包含所有词汇的词向量,Embedding的行数等于词汇数max_word,列数等于词向量长度 # Embedding的作用是获得每个词对应的词向量,这里的词向量是没有经过预训练的随机值,会跟随模型一起训练 # max_words词汇数,embedding_dims词向量长度 # 模型训练时数据输入为(batch, maxlen) model.add( Embedding(input_dim=max_words,output_dim=embedding_dims) ) # 设置一个一维卷积 model.add( Conv1D(filters=filter ,kernel_size=kernel_size ,strides=1 ,padding='same' # 卷积后的特征图大小和原始图像大小相同 ,activation='relu' ) ) # 卷积计算后得到的数据为(batch, maxlen, filters) # GlobalMaxPooling1D-全局最大池化计算每一张特征图的最大值 # 池化后得到(batch, filters) model.add(GlobalMaxPool1D()) # 加上Dropout model.add(Dropout(0.5)) # 最后2分类,设置2个神经元 model.add( Dense(units=2,activation='softmax') ) # 模型参数 model.summary() # 画图 # https://keras.io/zh/utils/#plot_model plot_model(model=model ,to_file='cnn_1d_model.png' ,show_shapes=True # 是否显示形状信息 ,show_layer_names=True # 是否显示图层名称 ,rankdir="TB" # "TB":垂直图 "LR":水平图 ,expand_nested=True # 是否将嵌套模型展开为簇。 ,dpi=96 # 图片每英寸点数。 ,show_layer_activations=True )
Model: "sequential" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= embedding (Embedding) (None, None, 128) 1280000 conv1d (Conv1D) (None, None, 64) 24640 global_max_pooling1d (Globa (None, 64) 0 lMaxPooling1D) dropout (Dropout) (None, 64) 0 dense (Dense) (None, 2) 130 ================================================================= Total params: 1,304,770 Trainable params: 1,304,770 Non-trainable params: 0 _________________________________________________________________
# sparse_categorical_crossentropy和categorical_crossentropy都是交叉熵代价函数 # categorical_crossentropy需要把标签变成独热编码one-hot # sparse_categorical_crossentropy不需要把标签变成独热编码one-hot(不是真的不需要,而且程序中会自动帮你做转换) # 所以这个程序中的标签没有转独热编码one-hot model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=['accuracy']) # 训练模型 model.fit( x=x_train ,y=y_train ,batch_size=batch_size ,epochs=epochs ,validation_data=(x_test,y_test) ) #%% # 保存模型 model.save('cnn_1d_model.h5')
Epoch 1/3
782/782 [==============================] - 45s 57ms/step - loss: 0.4677 - accuracy: 0.7700 - val_loss: 0.3169 - val_accuracy: 0.8680
Epoch 2/3
782/782 [==============================] - 48s 61ms/step - loss: 0.2982 - accuracy: 0.8784 - val_loss: 0.2896 - val_accuracy: 0.8805
Epoch 3/3
782/782 [==============================] - 45s 58ms/step - loss: 0.2178 - accuracy: 0.9146 - val_loss: 0.3051 - val_accuracy: 0.8742
导入数据
分词
建立词典(过滤一些符号)词:编号 word_index
把前面分的词全部转化为编号
把每段文本转换为序列
??? 还是不是很理解过程
构建训练集
训练
#%% import jieba import pandas as pd import numpy as np from keras.layers import Dense,Input,Dropout from keras.layers import Conv2D,GlobalMaxPool2D,Embedding,concatenate from keras.preprocessing.text import Tokenizer from keras.preprocessing.sequence import pad_sequences from keras.models import Model,load_model from keras.backend import expand_dims from keras.layers import Lambda import keras.backend as K from sklearn.model_selection import train_test_split import json from keras.utils.vis_utils import plot_model # 画模型图 import os # 需要转这个软件 Graphviz os.environ["PATH"] += os.pathsep + 'C:/Program Files/Graphviz/bin' ```pythob #%% # 批次大小 batch_size=128 # 训练周期 epochs=3 # 词向量长度 embedding_dims=128 # 滤波器数量 filter=32 # 这个数据前半部分都是正样本,后半部分都是负样本 data=pd.read_csv('weibo_senti_100k.csv') # 查看数据前5行 data.head()
#%% # 计算正样本数量 poslen = sum(data['label']==1) # 计算负样本数量 neglen = sum(data['label']==0) print('正样本数量:', poslen) print('负样本数量:', neglen) #%% # 测试一下结巴分词的使用 print(list(jieba.cut('做父母一定要有刘墉这样的心态,不断地学习,不断地进步'))) #%% #定义分词函数,对传入的x进行分词 cw = lambda x: list(jieba.cut(x)) # apply传入一个函数,把cw函数应用到data['review']的每一行 # 把分词后的结果保存到data['words']中 data['word']=data['review'].apply(cw) data.head()
正样本数量: 59993
负样本数量: 59995
[‘做’, ‘父母’, ‘一定’, ‘要’, ‘有’, ‘刘墉’, ‘这样’, ‘的’, ‘心态’, ‘,’, ‘不断’, ‘地’, ‘学习’, ‘,’, ‘不断’, ‘地’, ‘进步’]
#%% # 计算一条数据最多有多少个词汇 max_length=max([len(x) for x in data['word']]) # 打印看到结果为202,最长的句子词汇数不算太多 # 后面就以202作为标准,把所有句子的长度都填充到202的长度 # 比如最长的句子为2000,那么说明有些句子太长了,我们可以设置一个小一点的值作为所有句子的标准长度 # 比如设置1000,那么超过1000的句子只取前面1000个词,不足1000的句子填充到1000的长度 print(max_length) # 202 #%% # 把data['words']中所有的list都变成字符串格式 # 这里有个空格 texts=[' '.join(x) for x in data['word']] # 查看一条评论,现在数据变成了字符串格式,并且词与词之间用空格隔开 # 这是为了满足下面数据处理对格式的要求,下面要使用Tokenizer对数据进行处理 print(texts[4]) # #%% # 实例化Tokenizer,设置字典中最大词汇数为30000 # Tokenizer会自动过滤掉一些符号比如:!"#$%&()*+,-./:;<=>?@[\\]^_`{|}~\t\n tokenizer=Tokenizer(num_words=30000) # 传入我们的训练数据,建立词典,词的编号根据词频设定,频率越大,编号越小, tokenizer.fit_on_texts(texts=texts) # 把词转换为编号,编号大于30000的词会被过滤掉 sequences=tokenizer.texts_to_sequences(texts=texts) # 把序列设定为max_length的长度,超过max_length的部分舍弃,不到max_length则补0 # padding='pre'在句子前面进行填充,padding='post'在句子后面进行填充 X=pad_sequences(sequences=sequences,maxlen=max_length,padding='pre') print(X.shape) print(X[4]) # 梦想 有 多 大 , 舞台 就 有 多 大 ! [ 鼓掌 ]
(119988, 202)
[ 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0
...
0 0 0 0 0 581 18 75 77 1
1946 20 18 75 77 19]
#%% # 获取字典 dict_text=tokenizer.word_index #%% # 在字典中查询词 print(dict_text['梦想']) print(dict_text['睡觉']) # 581 # 352 #%% # 把token_config保存到json文件中,方便在模型预测阶段使用 file=open('token_config.json','w',encoding='utf-8') # 把tokenizer变成json数据 token_config=tokenizer.to_json() # tokenizer的一些配置 # 保存json数据 json.dump(obj=token_config,fp=file)
(119988, 2)
[[0 1]
[0 1]
[0 1]
…
[1 0]
[1 0]
[1 0]]
#%%
# 定义标签
# 01为正样本,10为负样本
positive_labels=[[0,1] for _ in range(poslen)]
negative_labels=[[1,0] for _ in range(neglen)]
# 合并标签
Y=np.array(positive_labels+negative_labels)
print(Y.shape)
print(Y)
#%%
print(Y[59992]) # 59993个正样本 59995个负样本
print(Y[59993])
print(Y[59994])
#%%
# 切分数据集
x_train,x_test,y_train,y_test=train_test_split(X,Y,test_size=0.2)
print(x_train.shape)
print(y_train.shape)
print(x_test.shape)
print(y_test.shape)
(95990, 202)
(95990, 2)
(23998, 202)
(23998, 2)
#%% # 定义函数式模型 # https://keras.io/zh/getting-started/functional-api-guide/ # 以张量为参数,并且返回一个张量 # 定义模型输入,shape-(batch, max_length=202) sequences_input=Input(shape=(max_length,)) # Embedding层,30000表示30000个词,每个词对应的向量为128维 # 该层只能用作模型中的第一层 input_dim=词汇表大小 output_dim=词向量的维度 embedding_layer=Embedding(input_dim=30000,output_dim=embedding_dims) # embedded_sequences的shape-(batch, 202, 128) embedded_sequences=embedding_layer(sequences_input) # embedded_sequences的shape变成了(batch, 202, 128, 1) # -1 指的是最后一个 embedded_sequences=K.expand_dims(x=embedded_sequences,axis=-1) # 卷积核大小为3,列数必须等于词向量长度 cnn1=Conv2D(filters=filter ,kernel_size=(3,embedding_dims) ,activation='relu' )(embedded_sequences) cnn1=GlobalMaxPool2D()(cnn1) # 卷积核大小为4,列数必须等于词向量长度 cnn2=Conv2D(filters=filter ,kernel_size=(4,embedding_dims) ,activation='relu' )(embedded_sequences) cnn2=GlobalMaxPool2D()(cnn2) # 卷积核大小为5,列数必须等于词向量长度 cnn3=Conv2D(filters=filter ,kernel_size=(5,embedding_dims) ,activation='relu' )(embedded_sequences) cnn3=GlobalMaxPool2D()(cnn3) # 合并 merge=concatenate([cnn1,cnn2,cnn3],axis=-1) # 全连接层 x=Dense(128,activation='relu')(merge) # Dropout层 x=Dropout(0.5)(x) # 输出层 preds=Dense(2,activation='softmax')(x) # 定义模型 model=Model(sequences_input,preds) #%% # 模型参数 model.summary() # 画图 # https://keras.io/zh/utils/#plot_model plot_model(model=model ,to_file='cnn_2d_model.png' ,show_shapes=True # 是否显示形状信息 ,show_layer_names=True # 是否显示图层名称 ,rankdir="TB" # "TB":垂直图 "LR":水平图 ,expand_nested=True # 是否将嵌套模型展开为簇。 ,dpi=200 # 图片每英寸点数。 ,show_layer_activations=True )
Model: "model" __________________________________________________________________________________________________ Layer (type) Output Shape Param # Connected to ================================================================================================== input_1 (InputLayer) [(None, 202)] 0 [] embedding (Embedding) (None, 202, 128) 3840000 ['input_1[0][0]'] tf.expand_dims (TFOpLambda) (None, 202, 128, 1) 0 ['embedding[0][0]'] conv2d (Conv2D) (None, 200, 1, 32) 12320 ['tf.expand_dims[0][0]'] conv2d_1 (Conv2D) (None, 199, 1, 32) 16416 ['tf.expand_dims[0][0]'] conv2d_2 (Conv2D) (None, 198, 1, 32) 20512 ['tf.expand_dims[0][0]'] global_max_pooling2d (GlobalMa (None, 32) 0 ['conv2d[0][0]'] xPooling2D) global_max_pooling2d_1 (Global (None, 32) 0 ['conv2d_1[0][0]'] MaxPooling2D) global_max_pooling2d_2 (Global (None, 32) 0 ['conv2d_2[0][0]'] MaxPooling2D) concatenate (Concatenate) (None, 96) 0 ['global_max_pooling2d[0][0]', 'global_max_pooling2d_1[0][0]', 'global_max_pooling2d_2[0][0]'] dense (Dense) (None, 128) 12416 ['concatenate[0][0]'] dropout (Dropout) (None, 128) 0 ['dense[0][0]'] dense_1 (Dense) (None, 2) 258 ['dropout[0][0]'] ================================================================================================== Total params: 3,901,922 Trainable params: 3,901,922 Non-trainable params: 0 __________________________________________________________________________________________________
#%% model.compile( loss='categorical_crossentropy' ,optimizer='adam' ,metrics=['acc'] ) model.fit(x=x_train ,y=y_train ,batch_size=batch_size ,epochs=epochs ,validation_data=(x_test,y_test)) model.save('cnn_2d_model.h5') #%%
Epoch 1/3
750/750 [==============================] - 119s 158ms/step - loss: 0.0801 - acc: 0.9677 - val_loss: 0.0448 - val_acc: 0.9813
Epoch 2/3
750/750 [==============================] - 118s 157ms/step - loss: 0.0413 - acc: 0.9819 - val_loss: 0.0511 - val_acc: 0.9812
Epoch 3/3
750/750 [==============================] - 118s 157ms/step - loss: 0.0344 - acc: 0.9828 - val_loss: 0.0668 - val_acc: 0.9797
from keras.models import load_model from keras.preprocessing.text import tokenizer_from_json from keras.preprocessing.sequence import pad_sequences import jieba import numpy as np import json json_file=open('token_config.json','r',encoding='utf-8') token_config=json.load(json_file) tokenizer=tokenizer_from_json(token_config) model=load_model('cnn_2d_model.h5') def predict(text): # 对句子分词 cw=list(jieba.cut(text))# ['今天天气', '真', '好', ',', '我要', '去', '打球'] # list转字符串,元素之间用' '隔开 texts=' '.join(cw) # 字符串 '今天天气 真 好 , 我要 去 打球' # print([texts])# ['今天天气 真 好 , 我要 去 打球'] # 把词转换为编号,编号大于30000的词会被过滤掉 sequences=tokenizer.texts_to_sequences([texts]) # [texts] 是把字符串变成列表 # model.input_shape为(None, 202),202为训练模型时的序列长度 # 把序列设定为202的长度,超过202的部分舍弃,不到202则补0 sequences=pad_sequences(sequences=sequences ,maxlen=model.input_shape[1] # 202 ,padding = 'pre') # 模型预测 shape(1,2) predict_result=model.predict(sequences) # 取出predict_result中元素最大值所对应的索引 result=np.argmax(predict_result) if(result==1): print('正面情绪') else: print('负面情绪') if __name__ == '__main__': predict('今天天气真好,我要去打球') predict("一大屋子人,结果清早告停水了,我崩溃到现在[抓狂]") predict("今天我很高兴") # pass
正面情绪
负面情绪
正面情绪
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。