本文在win10下,基于最新版的anaconda、Keras以及其它模块,在ABAE作者开源代码的基础上进行了移植(原程序基于Python2、Keras1等老的模块)。本文讲解了程序的大致结构,并附录了一些参考资源,最后给出进行了移植和注释的程序的GitHub链接。ABAE是一种aspect extraction算法(提取意见实体的方面),其原理解读可以参看文后链接的文章。
算法介绍
在情感分析中,aspect extraction(方面提取)是最关键的任务之一,目的是提取出opinions(意见)针对的对象,例如“The beef was tender and melted in my mouth”中,aspect term(方面关键词)就是beef(细粒度)。ABAE是一种aspect extraction算法。
aspect是opinion mining中意见实体的方面,例如:我们可以把上面找到的beef作为entity,把其归为aspect(food)(粗粒度),当然把beef本身当作aspect也是可以的,但是这个粒度就太细了。为了更好地理解aspect,可以参考文献[1]和[2]。下图给出一些抽取出的aspect的例子,以建立更直观的理解。
目前在aspect extraction方面state-of-the-art的工作都是基于LDA的,而LDA本身有一定的缺点,例如:
(1)不编码word co-occurrences(词同现)统计特性,往往导致提取出的aspect中词语关联性不强(指的是top N的词语)。
(2)LDA给每个训练文档都估计一个topics分布,而review corpus往往很短,使得这个估计很困难。
文献[3]提出了一种Attention-based Aspect Extraction,ABAE算法,很好地弥补了LDA-based方法的缺点,取得了不错的效果。作者Ruidan He给出了算法的开源实现,本文主要目的是分析源码并复现其结果。
ABAE算法的原理可以参考上一篇博客。
运行环境
根据作者GitHub上的介绍,对本地环境进行配置。
作者的代码基于Python 2,而我本机上的版本是Python 3,因此实际运行中需要对一些地方进行修改(主要是print语句)。
复现过程在Win10系统下进行,Python则直接采用最新版本的anaconda。
调试的全部过程都是在命令行下进行的,代码在notepadd++和PyCharm上编辑。
程序调试过程的依赖安装会在下面的具体步骤中介绍,实际上直接将各个.py文件中import进的模块全部安装即可,如果安装过程遇到一些问题,一般可以比较容易地查到解决方法。
这里需要特别说明,各种依赖模块我在安装时都没有指定版本,也就是说基本都是安装的最新版本,因此在调试程序过程中遇到了很多问题,需要逐一解决,实际上可以安装作者使用的Theano、Keras版本,并配合Python2,这样可以避免很多麻烦。
源码与数据下载
直接将GitHub上的源码打包下载即可。下载后解压。
根据作者GitHub上的介绍下载原始数据集datasets和作者处理好的preprocessed_data,然后解压到程序主目录下,此时程序的目录如下图:
注意到解压后的语料库还是很大的,有700多Mb。
数据预处理
首先要进行数据预处理的工作,主要是两步:
(1)去掉stop words,提取名词的主干。
(2)利用word2vec算法将原始数据转换为词向量。
我们从code中找到preprocess.py和word2vec.py两个文件。
preprocess
preprocess需要调用四个文件,如下所示:
from sklearn.feature_extraction.text import CountVectorizer
from nltk.corpus import stopwords
from nltk.stem.wordnet import WordNetLemmatizer
import codecs
因此,我们需要保证已经安装了sklearn、nltk、codec模块。可以直接用pip指令安装,在命令行中输入:
pip install sklearn
pip install nltk
pip install codecs
其中,nltk模块仅仅通过pip安装还是不够的,为了程序能够顺利运行,还需要先执行以下语句加载stop words和wordnet(用来提取名词的主干),在命令行中:
python
>>>import nltk
>>>nltk.download('stopwords')
>>>nltk.download('wordnet') # 这一句可能会等待较长时间
运行preprocess之前,要注意把print语句加上括号。
preprocess首先把两个语料的训练数据按行读取,逐行处理数据;然后preprocess会逐行读取测试数据,并根据论文中的设置,对于restaurant语料只保留[‘Food’, ‘Staff’, ‘Ambience’]三个aspect的数据,并逐行处理。
下面我们把逐行处理的函数从程序中单独拿出来,稍微进行修改,保存到preprocesstest.py文件中,我们运行这个文件来查看预处理的效果。
from sklearn.feature_extraction.text import CountVectorizer
from nltk.corpus import stopwords
from nltk.stem.wordnet import WordNetLemmatizer
import codecs
# lmtzr.lemmatize(w)只修改名词,如需修改动词时态,需要lmtzr.lemmatize(w, 'v')
# remove stop words and lemmatize words of one sentence.(提取单词主干,例如:'loving'->'love')
if __name__ == '__main__':
line = 'Bar was a little bit crowded , but these five girls know how to have fun ! ! it was a little hard to understand the waitress and she seemed to have little patience with our questions .'
lmtzr = WordNetLemmatizer()
stop = stopwords.words('english')
print('[original ]', line)
text_token = CountVectorizer().build_tokenizer()(line.lower())
print('[tokenize ]', text_token)
text_rmstop = [i for i in text_token if i not in stop]
print('[rmvstops ]', text_rmstop)
text_stem = [lmtzr.lemmatize(w) for w in text_rmstop]
print('[lemmatize]', text_stem)
在命令行中运行这个文件,效果如下:
程序分别输出原始的一个review, tokenize的结果,去掉停止词的结果,提取名词主干的结果。图片明确展示了每一步操作的效果。
最后,运行preprocess.py文件,发现需要较长时间才能处理完毕,尤其是对beer数据集的预处理。作者已经给出了预处理的结果,我们可以直接使用其结果。
其余代码比较简单,不详细分析。
word2vec
word2vec利用神经网络将原始数据转换为词向量。其需要调用两个模块:gensim和codecs。我们仿照上一步直接安装即可。
利用gensim可以方便地进行word2vec操作,因此代码非常简短。需要注意的是,之所以构造一个generator传入gensim.models.Word2Vec函数,是为了避免加载数据过程中消耗很多时间,可以提高效率。
作者已经提供了训练好的模型,但是在windows下这个模型直接读取的话会出错,我们需要在命令行运行一次word2vec来更新模型,这个操作需要较长时间。更新完成后,我们可以利用这个模型来查看一些单词的向量形式,检验word2vec的效果。
import gensim
import codecs
import numpy as np
if __name__ == '__main__':
model_file = '../preprocessed_data/restaurant/w2v_embedding'
model = gensim.models.Word2Vec.load(model_file)
print(model.wv['like', 'hello'])
model_file = '../preprocessed_data/beer/w2v_embedding'
model = gensim.models.Word2Vec.load(model_file)
print(model.wv['like', 'hello'])
输出为:
这样就成功转换了。
其余代码比较简单,不详细分析。
训练
训练过程主要依赖train.py文件,首先给出运行的方法(以下各个阶段的测试都如此运行),在命令行中输入:
python train.py --emb ../preprocessed_data/restaurant/w2v_embedding --domain restaurant -o output_dir
训练的算法比较复杂(包含多个文件),也比较重要,接下来我们对其进行细致的分析。以下的每段代码都会在开头用注释的形式指明其属于哪个文件。
# train.py
import argparse
import logging
import numpy as np
from time import time
import utils as U
import codecs
logging.basicConfig(
#filename='out.log',
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
# ......
首先导入了几个模块,其中utils模块是作者自己实现的,主要用于对参数、目录的操作,是辅助的功能。其它模块需要按需安装。然后,配置了logging模块的基本设置,用于输出日志。这里配置为在命令行显示日志,输出INFO以上所有级别的日志。
# train.py
# ......
###############################################################################################################################
## Parse arguments
#
parser = argparse.ArgumentParser()
parser.add_argument("-o", "--out-dir", dest="out_dir_path", type=str, metavar='<str>', required=True, help="The path to the output directory")
parser.add_argument("-e", "--embdim", dest="emb_dim", type=int, metavar='<int>', default=200, help="Embeddings dimension (default=200)")
parser.add_argument("-b", "--batch-size", dest="batch_size", type=int, metavar='<int>', default=50, help="Batch size (default=50)")
parser.add_argument("-v", "--vocab-size", dest="vocab_size", type=int, metavar='<int>', default=9000, help="Vocab size. '0' means no limit (default=9000)")
parser.add_argument("-as", "--aspect-size", dest="aspect_size", type=int, metavar='<int>', default=14, help="The number of aspects specified by users (default=14)")
parser.add_argument("--emb", dest="emb_path", type=str, metavar='<str>', help="The path to the word embeddings file")
parser.add_argument("--epochs", dest="epochs", type=int, metavar='<int>', default=15, help="Number of epochs (default=15)")
parser.add_argument("-n", "--neg-size", dest="neg_size", type=int, metavar='<int>', default=20, help="Number of negative instances (default=20)")
parser.add_argument("--maxlen", dest="maxlen", type=int, metavar='<int>', default=0, help="Maximum allowed number of words during training. '0' means no limit (default=0)")
parser.add_argument("--seed", dest="seed", type=int, metavar='<int>', default=1234, help="Random seed (default=1234)")
parser.add_argument("-a", "--algorithm", dest="algorithm", type=str, metavar='<str>', default='adam', help="Optimization algorithm (rmsprop|sgd|adagrad|adadelta|adam|adamax) (default=adam)")
parser.add_argument("--domain", dest="domain", type=str, metavar='<str>', default='restaurant', help="domain of the corpus {restaurant, beer}")
parser.add_argument("--ortho-reg", dest="ortho_reg", type=float, metavar='<float>', default=0.1, help="The weight of orthogonol regularizaiton (default=0.1)")
args = parser.parse_args()
out_dir = args.out_dir_path + '/' + args.domain
U.mkdir_p(out_dir) # 构造输出目录
U.print_args(args) # 打印命令行参数
assert args.algorithm in {'rmsprop', 'sgd', 'adagrad', 'adadelta', 'adam', 'adamax'}
assert args.domain in {'restaurant', 'beer'}
if args.seed > 0:
np.random.seed(args.seed)
#......
然后,配置了一系列需要的命令行参数,在命令行运行train.py时,可以指定这些参数修改默认配置,指示训练按照我们的需要进行。程序还对命令行参数进行了一些初步的处理,例如拼接输出路径,并生成相应的文件夹结构;打印命令行参数;判断args.algorithm和args.domain是否符合要求;设置随机种子。
# train.py
# ......
# ###############################################################################################################################
# ## Prepare data
# #
from keras.preprocessing import sequence
import reader as dataset
# 读取数据:词典、训练数据、测试数据、每个review最大长度;数据的形式是二维数组,每一行是一个review, 每行中的具体元素是数字,表示在该词词典中的次序。
vocab, train_x, test_x, overall_maxlen = dataset.get_data(args.domain, vocab_size=args.vocab_size, maxlen=args.maxlen)
# keras只能接受长度相同的序列输入。因此如果目前序列长度参差不齐,这时需要使用pad_sequences()。该函数是将序列转化为经过填充以后的一个新序列。
train_x = sequence.pad_sequences(train_x, maxlen=overall_maxlen)
test_x = sequence.pad_sequences(test_x, maxlen=overall_maxlen)
# train_x = train_x[0:30000]
print ('Number of training examples: ', len(train_x))
print ('Length of vocab: ', len(vocab))
# 生成一批正样本:二维数组 batch_size * dim
def sentence_batch_generator(data, batch_size):
n_batch = len(data) / batch_size
batch_count = 0
np.random.shuffle(data)
while True:
if batch_count == n_batch:
np.random.shuffle(data)
batch_count = 0
batch = data[batch_count*batch_size: (batch_count+1)*batch_size]
batch_count += 1
yield batch
# 生成一批负样本: 三维数组 batch_size * neg_size * dim
def negative_batch_generator(data, batch_size, neg_size):
data_len = data.shape[0]
dim = data.shape[1]
while True:
indices = np.random.choice(data_len, batch_size * neg_size)
samples = data[indices].reshape(batch_size, neg_size, dim)
yield samples
# ......
这之后要准备数据。导入了Keras和reader模块,Keras需要安装,reader是作者实现的模块,主要用来读取和生成训练数据以及词典。对于Keras模块,由于默认的是TensorFlow后端,因此我们需要修改以下配置文件。打开:
%USERPROFILE%/.keras/keras.json
其中的内容为:
{
"image_data_format": "channels_last",
"epsilon": 1e-07,
"floatx": "float32",
"backend": "tensorflow"
}
将tensorflow修改为theano即可,具体可以参考Keras中文文档。
调用reader模块的get_data函数获取数据(二维数组+词典配合),由于每个review的长度不同,因此需要首先对齐数据。然后,构造两个迭代器用来生成mini-batch的正样本和对应的负样本。注意每个负样本都对应neg_size个负样本。
我们把print语句加上括号,把后面的程序屏蔽掉,运行train.py,会打印如下信息,证明以上的程序运行成功。
# train.py
# ......
###############################################################################################################################
## Optimizaer algorithm
#
from optimizers import get_optimizer
optimizer = get_optimizer(args)
# ......
这一段程序导入了optimizers模块,这是作者写的模块,利用Keras生成指定的优化器,以供后续训练使用。
# train.py
# ......
###############################################################################################################################
## Building model
from model import create_model
import keras.backend as K
logger.info(' Building model')
def max_margin_loss(y_true, y_pred):
return K.mean(y_pred)
model = create_model(args, overall_maxlen, vocab)
# freeze the word embedding layer
model.get_layer('word_emb').trainable=False
model.compile(optimizer=optimizer, loss=max_margin_loss, metrics=[max_margin_loss])
# ......
这一段首先导入model模块,这是作者写的模块,描述了训练图模型;model模块内部又调用了my_layers模块,其中定义了具体的层,还导入了w2vEmbReader进行初始化。模型的构建主要依赖Keras库实现。
然后,创建了待训练的模型,并指定了损失函数。
# model.py
import logging
import keras.backend as K
from keras.layers import Dense, Activation, Embedding, Input
from keras.models import Model
from my_layers import Attention, Average, WeightedSum, WeightedAspectEmb, MaxMargin
logging.basicConfig(level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
def create_model(args, maxlen, vocab):
def ortho_reg(weight_matrix):
### orthogonal regularization for aspect embedding matrix ###
w_n = weight_matrix / K.cast(K.epsilon() + K.sqrt(K.sum(K.square(weight_matrix), axis=-1, keepdims=True)), K.floatx())
reg = K.sum(K.square(K.dot(w_n, K.transpose(w_n)) - K.eye((w_n.shape[0]).eval())))
return args.ortho_reg*reg
# 词汇表大小
vocab_size = len(vocab)
##### Inputs #####
# 正例的形状:batch_size * dim, 每个元素是在词汇表中的索引值, 每个句子有多少个词就有多少索引值
# 负例的形状:batch_size * args.neg_size * dim, ditto
# 得到w
sentence_input = Input(batch_shape=(None, maxlen), dtype='int32', name='sentence_input')
neg_input = Input(batch_shape=(None, args.neg_size, maxlen), dtype='int32', name='neg_input')
##### Construct word embedding layer #####
# 嵌入层将正整数(下标)转换为具有固定大小的向量,如[[4],[20]]->[[0.25,0.1],[0.6,-0.2]]
# keras.layers.embeddings.Embedding(input_dim, output_dim, embeddings_initializer='uniform', embeddings_regularizer=None, activity_regularizer=None, embeddings_constraint=None, mask_zero=False, input_length=None)
word_emb = Embedding(vocab_size, args.emb_dim, mask_zero=True, name='word_emb')
##### Compute sentence representation #####
# 计算句子嵌入,这里设计到keras的很多细节,日后还需要深入学习
e_w = word_emb(sentence_input)
y_s = Average()(e_w)
att_weights = Attention(name='att_weights')([e_w, y_s])
z_s = WeightedSum()([e_w, att_weights])
##### Compute representations of negative instances #####
# 计算负例的z_n
e_neg = word_emb(neg_input)
z_n = Average()(e_neg)
##### Reconstruction #####
# 重构过程
p_t = Dense(args.aspect_size)(z_s)
p_t = Activation('softmax', name='p_t')(p_t)
r_s = WeightedAspectEmb(args.aspect_size, args.emb_dim, name='aspect_emb',
W_regularizer=ortho_reg)(p_t)
##### Loss #####
# 损失函数
loss = MaxMargin(name='max_margin')([z_s, z_n, r_s])
model = Model(input=[sentence_input, neg_input], output=loss)
### Word embedding and aspect embedding initialization ######
# 如果定义了emb_path, 就用文件中的数值初始化E矩阵, T使用K-means初始化
if args.emb_path:
from w2vEmbReader import W2VEmbReader as EmbReader
emb_reader = EmbReader(args.emb_path, emb_dim=args.emb_dim)
logger.info('Initializing word embedding matrix')
model.get_layer('word_emb').set_weights(emb_reader.get_emb_matrix_given_vocab(vocab, model.get_layer('word_emb').get_weights()))
logger.info('Initializing aspect embedding matrix as centroid of kmean clusters')
model.get_layer('aspect_emb').W.set_value(emb_reader.get_aspect_matrix(args.aspect_size))
return model
模型创建的过程是程序的核心,也是理解起来比较困难的地方,我们需要对照论文,思考每句话具体的含义。研究程序的时候,应该主要把握大的思路,把步骤和论文对应起来。这里涉及大量Keras层实现的细节,我个人目前了解有限,这里忽略它,以后有时间深入研究Keras和Python的更多知识。
构建好模型后,就可以进行训练了。
# train.py
# ......
###############################################################################################################################
## Training
#
from keras.models import load_model
from tqdm import tqdm
logger.info('--------------------------------------------------------------------------------------------------------------------------')
vocab_inv = {}
for w, ind in vocab.items():
vocab_inv[ind] = w
sen_gen = sentence_batch_generator(train_x, args.batch_size)
neg_gen = negative_batch_generator(train_x, args.batch_size, args.neg_size)
batches_per_epoch = len(train_x) / args.batch_size
min_loss = float('inf')
for ii in range(args.epochs):
t0 = time()
loss, max_margin_loss = 0., 0.
for b in tqdm(range(int(batches_per_epoch))):
sen_input = next(sen_gen)
neg_input = next(neg_gen)
batch_loss, batch_max_margin_loss = model.train_on_batch([sen_input, neg_input], np.ones((args.batch_size, 1)))
loss += batch_loss / batches_per_epoch
max_margin_loss += batch_max_margin_loss / batches_per_epoch
tr_time = time() - t0
if loss < min_loss:
min_loss = loss
word_emb = model.get_layer('word_emb').get_weights()
aspect_emb = model.get_layer('aspect_emb').get_weights()
word_emb = word_emb / np.linalg.norm(word_emb, axis=-1, keepdims=True)
aspect_emb = aspect_emb / np.linalg.norm(aspect_emb, axis=-1, keepdims=True)
aspect_file = codecs.open(out_dir+'/aspect.log', 'w', 'utf-8')
model.save_weights(out_dir+'/model_param')
# 由于下面很多数组最外层套了两个括号,导致很多数组len(array)都是1,所以有效的做法应该是取array[0]进行操作
for ind in range(len(aspect_emb[0])):
desc = aspect_emb[0][ind]
sims = word_emb.dot(desc.T)
# 将sims中的元素从大到小排序,然后构建一个新的数组,这个数组的第i个元素表示在sims中第i大的元素的位置(索引)
ordered_words = np.argsort(sims)[::-1][0]
desc_list = [vocab_inv[w] for w in ordered_words[:100]]
print ('Aspect %d:' % ind)
print (desc_list)
# 输出距离每个aspect在embedding空间中最近的100词词语到文件中
aspect_file.write('Aspect %d:\n' % ind)
aspect_file.write(' '.join(desc_list) + '\n\n')
logger.info('Epoch %d, train: %is' % (ii, tr_time))
logger.info('Total loss: %.4f, max_margin_loss: %.4f, ortho_reg: %.4f' % (loss, max_margin_loss, loss-max_margin_loss))
# ......
这段代码根据命令行参数配置进行训练。这里首先加载了一个tqdm模块,这个模块是用来显示进度条的,我们需要先安装它。
然后把vocab字典进行了反序,key变为序号,value变为词;反序后构造两个正例和反例的迭代器,用以生成batch数据。
循环中,则不断计算loss,并保存最小loss,一旦下一轮的loss比最小loss还小,就把aspect_embeddings和模型参数等都保存起来。
正在训练时,程序的输出如下图:
进度条从0%~100%则执行完毕一个epoch, 程序默认执行10个epoches。
在我的电脑上,运行一个epoches大约需要20分钟,因此总耗时是比较长的。
这里必须要说明的是,由于Keras已经升级到2.x版本,Python也升级到了3.x版本,作者上传的源代码会出现各种各样的问题,由于问题非常多,很多问题都需要查阅一些资料才能解决,我遇到的一部分问题是Baidu解决的,还有一部分通过stackoverflow解决。大约修改了20~30处的代码。在这里我把其中的一些问题及解决方案贴上来。
问题1.my_layers.py中无法从Keras导入initializations,修改为:
from keras import initializers as initializations
问题2.所有的K.expand_dims(r_s, dim=-2)出错(很多处),修改为:
K.expand_dims(r_s, -2)
问题3.所有的model.get_layer(‘word_emb’).W.get_values()出错, 修改为:
model.get_layer('word_emb').get_weights()
问题4.所有的xrange修改为range,所有的print修改为print()
问题5.my_layers.py中,所有类的输出形状的函数名都需要修改为:compute_output_shape,这是Keras 2.x的格式
问题6.在train.py中,在输出aspects关键词的代码段,出现了很多数组维度问题,应该进行如下修改:
# 由于下面很多数组最外层套了两个括号,导致很多数组len(array)都是1,所以有效的做法应该是取array[0]进行操作
for ind in range(len(aspect_emb[0])):
desc = aspect_emb[0][ind]
sims = word_emb.dot(desc.T)
# 将sims中的元素从大到小排序,然后构建一个新的数组,这个数组的第i个元素表示在sims中第i大的元素的位置(索引)
ordered_words = np.argsort(sims)[::-1][0]
desc_list = [vocab_inv[w] for w in ordered_words[:100]]
print ('Aspect %d:' % ind)
print (desc_list)
# 输出距离每个aspect在embedding空间中最近的100词词语到文件中
aspect_file.write('Aspect %d:\n' % ind)
aspect_file.write(' '.join(desc_list) + '\n\n')
以上错误综合起来已经有数十处了,实际上还有一些小地方需要修改。具体可以参考我上传Github的修改后的代码。对于程序更多的解释说明也直接以注释的形式添加到了我的修改版上。
训练过程中,每个epoch会输出一次aspect代表词,如下:
评估
最后一步是进行评估,评估程序在evaluation.py中。首先给出运行的方法(以下各个阶段的测试都如此运行),在命令行中输入:
python evaluation.py --domain restaurant -o output_dir
我们主要梳理下评估的过程以及评估的效果。
首先,导入了几个必要的模块,这些模块在前面我们都已经安装过了。
然后,配置命令行参数并进行一些必要处理,这些参数与训练过程的参数基本是一致的,不赘述。
然后,导入测试数据,与导入训练数据相同。
然后,构建模型,与训练过程相同。模型构建完成过后导入训练好的模型参数。
之后,创建了两个函数,用来预测和具体的评估,评估依赖sklearn的metric模块实现。
最后,则构建字典,创建test函数,保存test用例上的attention权重。
上图给出了算法在几个aspect上的指标(并不是充分训练后的)。
评估模块无需大的改动,处理一下print语句即可运行。
我的参考文献
- [Entity and aspect extraction for opinion mining]http://219.238.82.130/cache/4/03/www.cs.uic.edu/41023e88be68ade8600b1c821439043a/ZhangLiu-AEEE.pdf
- [文献1的翻译]https://www.2cto.com/kf/201611/563101.html
- [算法原文]http://www.aclweb.org/anthology/P/P17/P17-1036.pdf
- [开源代码]https://github.com/ruidan/Unsupervised-Aspect-Extraction
- [算法原理博客]https://blog.csdn.net/u013695457/article/details/80390569
- [Keras中文文档]http://keras-cn.readthedocs.io/en/latest/backend/
- [我修改和注释的代码]https://github.com/onetree1994/Modified-and-Annotated-Code-of–An–Unsupervised-Neural-Attention-Model-for-Aspect-Extraction–