13  项目: 开发用于情感分析的n-gram CNN模型

用于文本分类和情感分析的标准深度学习模型使用了词嵌入层和一维卷积神经网络,可以通过使用多个并行卷积神经网络来扩展模型,该网络使用不同的大小的卷积核读取源文档,实际上,这是为文本分析创建一个多通道卷积神经网络,用于读取具有不同n-gram大小(单词组)的文本。在本教程中,您将了解如何开发一个多通道卷积神经网络,用于电影评论数据的情绪预测。完成本教程后,您将了解:

  • 如何准备电影评论文本数据进行建模。
  • 如何在Keras中开发用于文本的多通道卷积神经网络。
  • 如何评估看不见的电影评论数据的拟合模型。

13.1 教程概述

本教程分为以下几部分:

  1. 电影评论数据集。
  2. 数据准备。
  3. 开发多渠道模型。
  4. 评估模型。

13.2 电影评论数据集

在本教程中,我们将使用Movie Review数据集。这个为情绪分析设计的数据集在前面的第9章中有描述。您可以从这里下载数据集:电影评论Polarity Dataset(评论polarity.tar.gz3MB)http://www.cs.cornell.edu/people/pabo/movie-review-data/review_polarity.tar.gz

解压缩文件后,您将拥有一个名为txt sentoken的目录,其中包含negpos两个子目录,用存储负面和正面评论。对于negpos目录走中的每个文件存储一个评论,命名约定为cv000cv999

13.3 数据准备

意:电影评论数据集的准备工作在第9章中已介绍。在本节中,我们介绍3件事:

  1. 将数据分成训练和测试集。
  2. 加载和清理数据以删除标点符号和数字。
  3. 清洗所有评论并保存。

13.3.1 分为训练和测试集

我们假设正在开发一个系统,可以预测电影评论的情绪是积极的还是消极的,这意味着在开发模型之后,我们需要对新的文本评论进行预测。这将要求对新评论执行所有相同的数据准备,就像对模型的训练数据执行得一样。我们在数据准备之前要将数据集拆分训练和测试集,这个约束一直贯穿与整个模型评估的过程中。这意味着在准备数据和模型训练期间,测试集中任何知识对我们来说都是未知的,而且在训练过程中都是不可用的。

我们使用最后的100个正面评论和100个负面评论作为测试集(200条评论),其余1,800条评论作为训练数据集。这是90%用于训练,10%用于测试。通过评论的文件名可以轻松实现拆分,其中评论为000899的评论用做训练数据,而评论为900以上的评论用于测试。

13.3.2 装载和清洗评论

文本数据已经相当干净,因此不需要太多准备工作。在不了解细节的情况下,我们将使用以下方法准备数据:

  • 以空格为分隔符分词
  • 从单词中删除所有标点符号。
  • 删除所有不完全由字母字符组成的单词。
  • 删除所有已知停用词。
  • 删除长度1个字符的所有单词。

我们可以将所有这些步骤放入clean_doc()函数中,该函数以从文件加载的原始文本作为参数,并返回已清理的标记列表。我们还定义了一个load_doc()函数,它从文件中加载文档,以便与clean_doc()函数一起使用。下面列出了清洗的第一次正面评价

from nltk.corpus import stopwords
import string
import re
# load doc into memory
def load_doc(filename):
    # open the file as read only
    file = open(filename, 'r' )
    # read all text
    text = file.read()
    # close the file
    file.close()
    return text
# turn a doc into clean tokens
def clean_doc(doc):
    # split into tokens by white space
    tokens = doc.split()
    # prepare regex for char filtering
    re_punc = re.compile( '[%s]' % re.escape(string.punctuation))
    # remove punctuation from each word
    tokens = [re_punc.sub( '' , w) for w in tokens]
    # remove remaining tokens that are not alphabetic
    tokens = [word for word in tokens if word.isalpha()]
    # filter out stop words
    stop_words = set(stopwords.words( 'english' ))
    tokens = [w for w in tokens if not w in stop_words]
    # filter out short tokens
    tokens = [word for word in tokens if len(word) > 1]
    return tokens
# load the document
filename = 'txt_sentoken/pos/cv000_29590.txt'
text = load_doc(filename)
tokens = clean_doc(text)
print(tokens)

代码清单13.1:清理电影评论的示例

运行该示例会打印一长串干净的分词。我们可以尝试更多清洗步骤,并将其留作进一步练习。

......

'place', 'even', 'acting', 'hell', 'solid', 'dreamy', 'depp', 'turning', 'typically', 'strong', 'performance', 'deftly', 'handling', 'british', 'accent', 'ians', 'holm', 'joe', 'goulds', 'secret', 'richardson', 'dalmatians', 'log', 'great', 'supporting', 'roles', 'big', 'surprise', 'graham', 'cringed', 'first', 'time', 'opened', 'mouth', 'imagining', 'attempt', 'irish', 'accent', 'actually', 'wasnt', 'half', 'bad', 'film', 'however', 'good', 'strong', 'violencegore', 'sexuality', 'language', 'drug', 'content']

代码清单13.2:清理影片评论的示例输出

13.3.3 清洗所有评论并保存

我们使用上面定义的函数来清洗所有的电影评论,我们定义process_docs()的新函数,它遍历目录中的所有评论,清洗并将它们作为列表返回,在为函数添加一个参数,以指示函数是处理训练数据还是测试评论,这样可以过滤文件名(看前面的文件名称约定),并且只清理和返回所请求的那些用于训练或测试的评论。完整功能如下所列。

# load all docs in a directory
def process_docs(directory, is_train):
    documents = list()
    # walk through all files in the folder
    for filename in listdir(directory):
        # skip any reviews in the test set
        if is_train and filename.startswith( 'cv9' ):
            continue
        if not is_train and not filename.startswith( 'cv9' ):
            continue
        # create the full path of the file to open
        path = directory + '/' + filename
        # load the doc
        doc = load_doc(path)
        # clean doc
        tokens = clean_doc(doc)
        # add to list
        documents.append(tokens)
    return documents

代码清单13.3:清理多个审阅文档的功能

我们可以调用此函数处理负面训练评论,同时需要为训练和测试数据集添加对应的分类标签,我们知道我们有900份训练文件和100份测试文件,我们使用Pythonlist来来创建电影评论的正面负面标签:负面为0、正面为1。下面名为load_clean_dataset()的函数将加载并清理选定的电影评论文本,并为评论创建分类标签

# load and clean a dataset
def load_clean_dataset(is_train):
    # load documents
    neg = process_docs( 'txt_sentoken/neg' , is_train)
    pos = process_docs( 'txt_sentoken/pos' , is_train)
    docs = neg + pos
    # prepare labels
    labels = [0 for _ in range(len(neg))] + [1 for _ in range(len(pos))]
    return docs, labels

代码清单13.4:为数据集准备评论和标签的函数

最后,我们将准备好的训练集和测试集保存到文件中,以便我们以后建模和模型评估中使用。定义save_dataset()函数将给定的数据集(Xy元素)保存到一个packle API可以加载的文件中(这个使用的函数是Python中用于保存对象的标准API)。

# save a dataset to file
def save_dataset(dataset, filename):
    dump(dataset, open(filename, 'wb' ))
    print( ' Saved: %s ' % filename)

代码清单13.5:将干净文档保存到文件的功能

13.3.4 完整的例子

我们可以将所有这些数据准备步骤结合在一起。下面列出了完整的示例。

import string
import re
from os import listdir
from nltk.corpus import stopwords
from pickle import dump
# load doc into memory
def load_doc(filename):
    # open the file as read only
    file = open(filename, 'r' )
    # read all text
    text = file.read()
    # close the file
    file.close()
    return text
# turn a doc into clean tokens
def clean_doc(doc):
    # split into tokens by white space
    tokens = doc.split()
    # prepare regex for char filtering
    re_punc = re.compile( '[%s]' % re.escape(string.punctuation))
    # remove punctuation from each word
    tokens = [re_punc.sub( '' , w) for w in tokens]
    # remove remaining tokens that are not alphabetic
    tokens = [word for word in tokens if word.isalpha()]
    # filter out stop words
    stop_words = set(stopwords.words( 'english' ))
    tokens = [w for w in tokens if not w in stop_words]
    # filter out short tokens
    tokens = [word for word in tokens if len(word) > 1]
    tokens = ' ' .join(tokens)
    return tokens
# load all docs in a directory
def process_docs(directory, is_train):
    documents = list()
    # walk through all files in the folder
    for filename in listdir(directory):
    # skip any reviews in the test set
        if is_train and filename.startswith( 'cv9' ):
            continue
        if not is_train and not filename.startswith( 'cv9' ):
            continue
        # create the full path of the file to open
        path = directory + '/' + filename
        # load the doc
        doc = load_doc(path)
        # clean doc
        tokens = clean_doc(doc)
        # add to list
        documents.append(tokens)
    return documents
# load and clean a dataset
def load_clean_dataset(is_train):
    # load documents
    neg = process_docs( 'txt_sentoken/neg' , is_train)
    pos = process_docs( 'txt_sentoken/pos' , is_train)
    docs = neg + pos
    # prepare labels
    labels = [0 for _ in range(len(neg))] + [1 for _ in range(len(pos))]
    return docs, labels
# save a dataset to file
def save_dataset(dataset, filename):
    dump(dataset, open(filename, 'wb' ))
    print( 'Saved: %s' % filename)
# load and clean all reviews
train_docs, ytrain = load_clean_dataset(True)
test_docs, ytest = load_clean_dataset(False)
# save training datasets
save_dataset([train_docs, ytrain], 'train.pkl' )
save_dataset([test_docs, ytest], 'test.pkl' )

代码清单13.6:清理和保存所有电影评论的完整示例

运行该示例清洗电影评论文档,创建分类标签,并保存训练和测试数据集分别为train.pkltest.pkl。现在我们准备开发我们的模型了。

13.4 开发多通道模型

在本节中,我们将开发一个用于情感分析预测问题的多通道卷积神经网络。本节分为3部分:

  1. 编码数据
  2. 定义模型。
  3. 完整的例子。

13.4.1 编码数据

第一步是加载已清理的训练数据集。下面定义load_dataset()函数完成加载pickle训练数据集的工作。

# load a clean dataset
def load_dataset(filename):
    return load(open(filename, 'rb' ))
trainLines, trainLabels = load_dataset( 'train.pkl' )

代码清单13.7:加载保存已清洗的评论的示例

接下来,我们在训练数据集上拟合KerasTokenizer类的实例tokenizer使用tokenzier实例来定义Embedding层的词汇表,并通过它将电影评论文档编码为整数。下面的函数create_tokenizer()将创建一个Tokenizer的实例tokenizer并拟合训练数据

# fit a tokenizer
def create_tokenizer(lines):
    tokenizer = Tokenizer()
    tokenizer.fit_on_texts(lines)
    return tokenizer

代码清单13.8:创建Tokenizer的函数

我们还需要知道输入文本序列中的最长的文本的长度,以此作为模型的输入长度,并将所有序列填充到固定这个最大长度。下面的函数max_length()将计算训练数据集中所有评论的最大长度(单词数)。

# calculate the maximum document length
def max_length(lines):
    return max([len(s.split()) for s in lines])

代码清单13.9:计算最大电影评论长度的函数

我们还需要知道Embedding层的词汇量大小,这可以从定义的Tokenizer实例中获取,如下:

# calculate vocabulary size
vocab_size = len(tokenizer.word_index) + 1

代码清单13.10:计算词汇表的大小。

最后,我们对干净的电影评论文本进行整数编码并填充,函数名称encode_text():

# encode a list of lines
def encode_text(tokenizer, lines, length):
    # integer encode
    encoded = tokenizer.texts_to_sequences(lines)
    # pad encoded sequences
    padded = pad_sequences(encoded, maxlen=length, padding= 'post' )
    return padded

代码清单13.11:编码和填充电影评论文本的函数

13.4.2 定义模型

用于文档分类的标准模型是使用Embedding层作为输入,接着是一维卷积神经网络,池化层,然后是预测输出层。卷积层中的卷积核大小定义了输入文本文档中传递卷积时要考虑的单词数量,并提供了一个分组参数。用于文档分类的多通道卷积神经网络涉及使用具有不同大小卷积核的多个版本的标准模型。这允许一次以不同的分辨率或不同的n-gram(单词组)处理文档,同时模型学习如何最好地整合这些解释。

Yoon Kim在其2014年题为Convolutional Neural Networks for Sentence Classification的论文中首次描述了这种方法。在论文中,Kim尝试了静态和动态(更新)嵌入层,我们对此做了一定的简化,只关注使用不同的内核大小。使用Kim的论文中的图表可以最好地理解这种方法,参见第14章。

Keras中,可以使用函数式API定义多输入模型,我们将定义一个带有三个输入通道的模型,用于处理4-gram6-gram8-gram的电影评论文本。每个频道由以下元素组成:

  • 输入层,用于定义输入序列的长度。
  • 嵌入图层设置为词汇表的大小和100维实值表示。
  • Conv1D层具有32个卷积核,核大小设置为一次读取的字数。
  • MaxPooling1D层用于合并卷积层的输出。
  • Flatten层以将三维输出展平为二维以进行连接。

三个通道的输出连接成一个矢量,并由Dense层和输出层处理。下面的函数定义并返回模型。作为定义模型的一部分,将打印已定义模型的摘要,并创建模型图并将其保存到文件中。

# define the model
def define_model(length, vocab_size):
    # channel 1
    inputs1 = Input(shape=(length,))
    embedding1 = Embedding(vocab_size, 100)(inputs1)
    conv1 = Conv1D(filters=32, kernel_size=4, activation= 'relu' )(embedding1)
    drop1 = Dropout(0.5)(conv1)
    pool1 = MaxPooling1D(pool_size=2)(drop1)
    flat1 = Flatten()(pool1)
    # channel 2
    inputs2 = Input(shape=(length,))
    embedding2 = Embedding(vocab_size, 100)(inputs2)
    conv2 = Conv1D(filters=32, kernel_size=6, activation= 'relu' )(embedding2)
    drop2 = Dropout(0.5)(conv2)
    pool2 = MaxPooling1D(pool_size=2)(drop2)
    flat2 = Flatten()(pool2)
    # channel 3
    inputs3 = Input(shape=(length,))
    embedding3 = Embedding(vocab_size, 100)(inputs3)
    conv3 = Conv1D(filters=32, kernel_size=8, activation= 'relu' )(embedding3)
    drop3 = Dropout(0.5)(conv3)
    pool3 = MaxPooling1D(pool_size=2)(drop3)
    flat3 = Flatten()(pool3)
    # merge
    merged = concatenate([flat1, flat2, flat3])
    # interpretation
    dense1 = Dense(10, activation= 'relu' )(merged)
    outputs = Dense(1, activation= 'sigmoid' )(dense1)
    model = Model(inputs=[inputs1, inputs2, inputs3], outputs=outputs)
    # compile
    model.compile(loss= 'binary_crossentropy' , optimizer= 'adam' , metrics=[ 'accuracy' ])
    # summarize
    model.summary()
    plot_model(model, show_shapes=True, to_file= 'multichannel.png' )
    return model

代码清单13.12:定义分类模型的函数

 

13.4.3 完整的例子

将所有这些结合在一起,下面列出了完整的示例。

from pickle import load
from numpy import array
from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences
from keras.utils.vis_utils import plot_model
from keras.models import Model
from keras.layers import Input
from keras.layers import Dense
from keras.layers import Flatten
from keras.layers import Dropout
from keras.layers import Embedding
from keras.layers.convolutional import Conv1D
from keras.layers.convolutional import MaxPooling1D
from keras.layers.merge import concatenate
# load a clean dataset
def load_dataset(filename):
    return load(open(filename, 'rb' ))
# fit a tokenizer
def create_tokenizer(lines):
    tokenizer = Tokenizer()
    tokenizer.fit_on_texts(lines)
    return tokenizer
# calculate the maximum document length
def max_length(lines):
    return max([len(s.split()) for s in lines])
# encode a list of lines
def encode_text(tokenizer, lines, length):
    # integer encode
    encoded = tokenizer.texts_to_sequences(lines)
    # pad encoded sequences
    padded = pad_sequences(encoded, maxlen=length, padding= 'post' )
    return padded
    
# define the model
def define_model(length, vocab_size):
    # channel 1
    inputs1 = Input(shape=(length,))
    embedding1 = Embedding(vocab_size, 100)(inputs1)
    conv1 = Conv1D(filters=32, kernel_size=4, activation= 'relu' )(embedding1)
    drop1 = Dropout(0.5)(conv1)
    pool1 = MaxPooling1D(pool_size=2)(drop1)
    flat1 = Flatten()(pool1)
    # channel 2
    inputs2 = Input(shape=(length,))
    embedding2 = Embedding(vocab_size, 100)(inputs2)
    conv2 = Conv1D(filters=32, kernel_size=6, activation= 'relu' )(embedding2)
    drop2 = Dropout(0.5)(conv2)
    pool2 = MaxPooling1D(pool_size=2)(drop2)
    flat2 = Flatten()(pool2)
    # channel 3
    inputs3 = Input(shape=(length,))
    embedding3 = Embedding(vocab_size, 100)(inputs3)
    conv3 = Conv1D(filters=32, kernel_size=8, activation= 'relu' )(embedding3)
    drop3 = Dropout(0.5)(conv3)
    pool3 = MaxPooling1D(pool_size=2)(drop3)
    flat3 = Flatten()(pool3)
    # merge
    merged = concatenate([flat1, flat2, flat3])
    # interpretation
    dense1 = Dense(10, activation= 'relu' )(merged)
    outputs = Dense(1, activation= 'sigmoid' )(dense1)
    model = Model(inputs=[inputs1, inputs2, inputs3], outputs=outputs)
    # compile
    model.compile(loss= 'binary_crossentropy' , optimizer= 'adam' , metrics=[ 'accuracy' ])
    # summarize
    model.summary()
    plot_model(model, show_shapes=True, to_file= 'model.png' )
    return model
# load training dataset
trainLines, trainLabels = load_dataset( 'train.pkl' )
# create tokenizer
tokenizer = create_tokenizer(trainLines)
# calculate max document length
length = max_length(trainLines)
print( 'Max document length: %d' % length)
# calculate vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print( 'Vocabulary size: %d' % vocab_size)
# encode data
trainX = encode_text(tokenizer, trainLines, length)
# define model
model = define_model(length, vocab_size)
# fit model
model.fit([trainX,trainX,trainX], array(trainLabels), epochs=7, batch_size=16)
# save the model
model.save( 'model.h5' )

代码清单13.13:拟合n-gram CNN模型的完整示例

运行该示例,将打印准备好的训练数据集的摘要。

Max document length: 1380

Vocabulary size: 44277

代码清单13.14:准备训练数据的示例输出

该模型相对较快,并且在训练数据集上显示出良好的性能。

Epoch 1/7

1800/1800 [==============================] - 3s 1ms/step - loss: 0.6913 - acc: 0.5178

Epoch 2/7

1800/1800 [==============================] - 2s 857us/step - loss: 0.4916 - acc: 0.7533

Epoch 3/7

1800/1800 [==============================] - 2s 869us/step - loss: 0.0825 - acc: 0.9733

Epoch 4/7

1800/1800 [==============================] - 2s 913us/step - loss: 0.0065 - acc: 0.9994

Epoch 5/7

1800/1800 [==============================] - 2s 944us/step - loss: 0.0018 - acc: 1.0000

Epoch 6/7

1800/1800 [==============================] - 2s 946us/step - loss: 0.0011 - acc: 1.0000

Epoch 7/7

1800/1800 [==============================] - 2s 980us/step - loss: 6.8402e-04 - acc: 1.0000

代码清单13.15:拟合模型的示例输出

已定义模型的图表将保存到文件中,清楚地显示模型的三个输入通道。

13.1:文本多通道卷积神经网络的图。

该模型训练了多个epoch并保存到文件model.h5中以供以后评估使用。

13.5 评估模型

在本节中,我们可以通过预测未见测试数据集中所有评论的情感来评估拟合模型。使用上一节中开发的数据加载函数,我们加载和编码训练和测试数据集。

# load datasets
trainLines, trainLabels = load_dataset( 'train.pkl' )
testLines, testLabels = load_dataset( 'test.pkl' )
# create tokenizer
tokenizer = create_tokenizer(trainLines)
# calculate max document length
length = max_length(trainLines)
# calculate vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print( ' Max document length: %d ' % length)
print( ' Vocabulary size: %d ' % vocab_size)
# encode data
trainX = encode_text(tokenizer, trainLines, length)
testX = encode_text(tokenizer, testLines, length)
print(trainX.shape, testX.shape)

代码清单13.16:准备训练和测试数据以评估模型。

我们可以加载保存的模型并在训练和测试数据集上进行评估。下面列出了完整的示例。

from pickle import load
from numpy import array
from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences
from keras.models import load_model

# load a clean dataset
def load_dataset(filename):
    return load(open(filename, 'rb'))
# fit a tokenizer
def create_tokenizer(lines):
    tokenizer = Tokenizer()
    tokenizer.fit_on_texts(lines)
    return tokenizer
# calculate the maximum document length
def max_length(lines):
    return max([len(s.split()) for s in lines])
# encode a list of lines
def encode_text(tokenizer, lines, length):
    # integer encode
    encoded = tokenizer.texts_to_sequences(lines)
    # pad encoded sequences
    padded = pad_sequences(encoded, maxlen=length, padding='post')
    return padded
# load datasets
trainLines, trainLabels = load_dataset('train.pkl')
testLines, testLabels = load_dataset('test.pkl')
# create tokenizer
tokenizer = create_tokenizer(trainLines)
# calculate max document length
length = max_length(trainLines)
print('Max document length: %d' % length)
# calculate vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print('Vocabulary size: %d' % vocab_size)
# encode data
trainX = encode_text(tokenizer, trainLines, length)
testX = encode_text(tokenizer, testLines, length)
# load the model
model = load_model('model.h5')
# evaluate model on training dataset
_, acc = model.evaluate([trainX, trainX, trainX], array(trainLabels), verbose=0)
print(' Train Accuracy: %.2f ' % (acc * 100))
# evaluate model on test dataset dataset
_, acc = model.evaluate([testX, testX, testX], array(testLabels), verbose=0)
print(' Test Accuracy: %.2f ' % (acc * 100))

代码清单13.17:评估拟合模型的完整示例

运行该示例将在训练和测试数据集上打印模型的性能,可以看到,正如预期的那样,在训练集的模型表现非常出色,准确率达100%。我们还可以看到模型在看不见的测试数据集上的性能也人印象深刻,达到了88.0%,这高于2014年论文中报告的模型的性能(尽管不是直接的苹果对苹果比较)。

意:鉴于神经网络的随机性,您的具体结果可能会有所不同。考虑运行几次示例。

Max document length: 1380

Vocabulary size: 44277

 Train Accuracy: 100.00

 Test Accuracy: 88.00

代码清单13.18:评估拟合模型的示例输出

13.6 扩展

本节列出了一些扩展,您如果需要对模型性能不满意,可以以试试这些想法。

  • 同的n-gram。通过更改模型中通道使用的内核大小(n-gram的数量)来探索模型,以了解它如何影响模型性能。
  • 多或更少的通道。尝试在模型中使用更多或更少的通道,并了解它如何影响模型性能。
  • 享嵌入。尝试每个通道共享相同单词嵌入的配置,并报告对模型性能的影响。深的网络。深层卷积神经网络在计算机视觉中表现更好。在这里尝试使用更深层的模型,看看它如何影响模型性能。
  • 断序列。如果最长序列与所有其他评论非常不同,则将所有序列填充到最长序列的长度可能是极端的,研究评论长度的分布并将评论截断为平均长度。
  • 断词汇。我们删除了不常出现的单词,但仍然有超过25,000个单词的大词汇量,进一步尝试减少词汇量的大小和对模型性能的影响。
  • epoch和批量大小。该模型很快拟合训练数据集,尝试训练epoch和批量大小的多样配置,并使用测试数据集作为验证集,为训练模型选择更好的epoch和批量大小的数值。
  • 使用预训练嵌入模型。尝试在模型中预先训练Word2Vec单词嵌入以及在训练期间进行进一步微调对模型性能的影响。
  • 使GloVe嵌入。尝试加载预训练的GloVe嵌入和对模型性能的影响,无论是否在训练期间进一步微调。
  • 练最终模型。使用所有可用数据训练最终模型,并使用它来预测来自互联网的真实临时电影评论。

如果你尝试其他任何扩展,可以提高模型性能,请告诉我。


0 条 查看最新 评论

没有评论
暂时无法发表评论