【實戰NLP】豆瓣影評情感分析

如題,使用 lstm rnn 預測豆瓣短影評情感。

  1. 數據準備 -- 使用 bs4、requests 構造豆瓣爬蟲
  2. 詞庫訓練 -- 使用 gensim 訓練中文 wiki 語料庫
  3. 訓練 rnn
  4. 預測結果

數據準備

編寫豆瓣爬蟲工具,主要爬取電影、電影評論評分數據。

使用 request.Session 維護cookie,請求電影、影評數據;

使用 json 和 BeautifulSoup 進行數據提取;

使用 MySQLdb 存儲數據

豆瓣對同一用戶的請求頻率有限制,如果統一用戶請求過快,就會要求人機身份驗證,所以登錄狀態下是不行的(除非你有海量豆瓣賬號),非登錄狀態下只能獲取每個電影的前10頁影評,共200條。另外,注意不停地更換 cookie 和 User-Agent,防止被封。盡量不要使用多線程,除非你有很多代理,否則也是很快就會被封 IP。

通過頁面:movie.douban.com/typera 可以按照分類及電影排行獲取到大量電影(同一電影可能存在不同分類,需要去重複)。需要注意的是,數據是動態載入的,分期其 joon url 及數據即可。

使用此方法,我獲取到了 15748 部電影。

通過頁面 movie.douban.com/subjec 可以獲取到影評。分析其頁面結構即可獲取到頁面上的短評。在遍歷了60%的電影之後,我獲取到了 1297427 條帶有評分的評論。其中,一星到五星的數量分別是 43806,129079, 446463, 459875, 218204。足夠用的了。

部分代碼。

get_movie.py:循環構造請求電影數據的 url,請求並解析,存儲到資料庫中。Sleep 0.3秒是防止請求過快,被封 ip。

first_in_interval = True first_in_page = True for m_type in range(movie_type, 32): if m_type == 9 or m_type == 21: continue if not first_in_interval: interval = 0 else: first_in_interval = False for i in range(interval, 100, 10): if not first_in_page: page = 0 else: first_in_page = False while True: time.sleep(0.3) c_url = get_json_url(m_type, i, page) data = session.get(c_url) if data.status_code != 200: print "Error: url: %s. code: %d" % (c_url, data.status_code) refresh_cookie(session) continue else: res = json.loads(data.text) if not res: break for item in res: movie_id = item[id] title = item[title] types = item[types] actors = item[actors] if len(item[rating]) == 2: rating = int(item[rating][1]) / 10 else: rating = 0 score = float(item[score]) if "release_date" in item: release_date = item[release_date] else: release_date = 1970-01-01 regions = item[regions] url = item[url] cover_url = item[cover_url] add_movie(movie_id, title, types, actors, rating, score, release_date, regions, url, cover_url) print "current at type[%d], interval[%d], page[%d]" % (m_type, i, page) set_movie_ckpt(m_type, i, page) random_refresh_cookie(session) page += 1

get_comment.py:請求評論列表頁面,並進行解析

def get_movie_comment(): with requests.Session() as session: while True: try: m_p = queue.get(block=False) movie_id = m_p[0] page = m_p[1] except Queue.Empty as e: return n = get_next_movie_id(movie_id) print "start get movie: %d, page: %d, 已完成:百分之 %f" % (movie_id, page, n[1] * 100.0 / (n[2] + n[1])) for i in range(page, 11): random_refresh_cookie(session) time.sleep(0.3) # print "get movie : %d, page : %d" % (movie_id, i) url = get_comment_url(movie_id, i) res = get_res(session, url) if not res: print "res get error:" + url continue if res.status_code != 200: print "Error: url: %s. code: %d" % (url, res.status_code) refresh_cookie(session) continue soup = BeautifulSoup(res.text, html.parser) comments = soup.find_all("div", class_="comment-item") if not comments: # comment for this one is over set_comment_ckpt(movie_id, i) break for comment in comments: rating = comment.find("span", class_="rating") if not rating: continue rating_class = rating[class] rating_num = 1 if "allstar10" in rating_class: rating_num = 1 if "allstar20" in rating_class: rating_num = 2 if "allstar30" in rating_class: rating_num = 3 if "allstar40" in rating_class: rating_num = 4 if "allstar50" in rating_class: rating_num = 5 comment_p = comment.find(p) comment_content = comment_p.get_text() cid = comment[data-cid] div_avatar = comment.find(div, class_=avatar) avatar_a = div_avatar.find(a) avatar_img = avatar_a.find(img) user_avatar = avatar_img[src] user_name = avatar_a[title] user_location = avatar_a[href] user_id = user_location[30:] user_id = user_id[:-1] span_comment_time = comment.find(span, class_=comment-time) comment_time = span_comment_time[title] span_votes = comment.find(span, class_=votes) votes = span_votes.get_text() lock.acquire() add_comment(cid, movie_id, user_id, user_avatar, user_name, comment_content, rating_num, comment_time, votes) lock.release() set_comment_ckpt(movie_id, i)

refresh_cookie. 我原本使用的方式是通過請求登錄頁面獲取 cookie 中的 bid,後來看到一篇文章,發現可以直接使用隨機字元串。

def refresh_cookie(session): session.headers.clear() session.cookies.clear() session.headers = { "User-Agent": make_random_useragent("pc"), "Host": "movie.douban.com", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Encoding": "gzip, deflate, sdch, br", "Accept-Language": "zh-CN, zh; q=0.8, en; q=0.6", "Cookie": "bid=%s" % "".join(random.sample(string.ascii_letters + string.digits, 11)) } # data = session.get("https://accounts.douban.com/login") # # if data.status_code != 200: # print "獲取新cookie失敗: " + str(data.status_code) # else: # print "重新獲取 cookie 成功" # session.headers[Host] = "movie.douban.com" return 200

詞庫訓練

使用中文的 wiki 語料庫。

首先下載中文語料庫 dumps.wikimedia.org/zhw ,然後使用 wikiextractor attardi/wikiextractor 解壓縮語料文本。再使用 OpenCC 進行繁體字轉換。使用正則表達式清除掉標籤,並使用 jieba 進行分詞。

分詞以及清除標籤

# coding=utf-8import jiebaimport osimport sysimport rereload(sys)sys.setdefaultencoding("utf-8")def cleanhtml(raw_html): cleanr = re.compile(<.*?>) cleantext = re.sub(cleanr, , raw_html) return cleantextif __name__ == __main__: inp, outp = sys.argv[1:3] count = 0 space = for root, dirs, files in os.walk(inp): for filename in files: if filename.startswith("wiki"): if not os.path.exists(outp + root): os.makedirs(outp + root) output = open(outp + root + "/" + filename, w) f = open(root + "/" + filename, r) for line in f.readlines(): seg_list = jieba.cut(cleanhtml(line)) output.write(space.join(seg_list) +
) output.close() # opencc 進行繁簡轉換 # status, res = commands.getstatusoutput("opencc -i " + root + "/" + filename + " -o" # + " s_" + root + "/" + filename + " -c t2s.json") # if status == 0: count += 1 print count

最後使用gensim 對分好詞、清理完標籤的文本數據進行訓練。

train.py

#!/usr/bin/env python# -*- coding: utf-8 -*-# Author: Pan Yang (panyangnlp@gmail.com)# Copyright 2017 @ Yu Zhenimport gensimimport loggingimport multiprocessingimport osimport reimport sysfrom pattern.en import tokenizefrom time import timelogging.basicConfig(format=%(asctime)s : %(levelname)s : %(message)s, level=logging.INFO)def cleanhtml(raw_html): cleanr = re.compile(<.*?>) cleantext = re.sub(cleanr, , raw_html) return cleantextclass MySentences(object): def __init__(self, dirname): self.dirname = dirname def __iter__(self): for root, dirs, files in os.walk(self.dirname): for filename in files: file_path = root + / + filename for line in open(file_path): sline = line.strip() if sline == "": continue sentence = sline.split() yield sentenceif __name__ == __main__: if len(sys.argv) != 2: print "Please use python train_with_gensim.py data_path" exit() data_path = sys.argv[1] begin = time() sentences = MySentences(data_path) model = gensim.models.Word2Vec(sentences, size=200, window=10, min_count=10, workers=multiprocessing.cpu_count()) model.save("data/word2vec_gensim") model.wv.save_word2vec_format("data/word2vec_org", "data/vocabulary", binary=False) end = time() print "Total procesing time: %d seconds" % (end - begin)

後來我覺得把影評的材料跟 wiki 材料一起訓練會比較好。

部分代碼參考一下兩篇文章:

使用 word2vec 訓練wiki中英文語料庫自然語言處理 | 我愛自然語言處理

訓練

1-2星為差評 3星為中評,4-5星為好評。對於差評、中評、好評,各採用 15000 條數據進行訓練,5000條數據作為評估。使用 tflearn 的 lstm + full_connected 進行訓練

def main(): train_x, train_y = get_train_data() test_x, test_y = get_test_data() print "正在處理訓練數據的 padding" train_x = pad_sequences_array(train_x, maxlen=100, value=0.) print "正在處理測試數據的 padding" test_x = pad_sequences_array(test_x, maxlen=100, value=0.) train_y = to_categorical(train_y, 3) test_y = to_categorical(test_y, 3) net = tflearn.input_data([None, 100, 200]) net = tflearn.lstm(net, 128, dropout=0.8) net = tflearn.fully_connected(net, 3, activation=softmax) net = tflearn.regression(net, optimizer=adam, learning_rate=0.0001, loss=categorical_crossentropy) model = tflearn.DNN(net, tensorboard_verbose=1, tensorboard_dir=log/, checkpoint_path=model/, best_checkpoint_path=best_model/, best_val_accuracy=0.8) model.load("model-19000") model.fit(train_x, train_y, validation_set=(test_x, test_y), show_metric=True, batch_size=32, validation_batch_size=32, snapshot_step=500)def pad_sequences_array(sequences, maxlen=None, dtype=float64, padding=post, truncating=post, value=0.): """ 根據 tflearn pad_sequences 構造 """ lengths = [len(s) for s in sequences] nb_samples = len(sequences) if maxlen is None: maxlen = np.max(lengths) x = (np.ones((nb_samples, maxlen, 200)) * value).astype(dtype) for idx, s in enumerate(sequences): if len(s) == 0: continue # empty list was found if truncating == pre: trunc = s[-maxlen:] elif truncating == post: trunc = s[:maxlen] else: raise ValueError("Truncating type %s not understood" % padding) if padding == post: x[idx, :len(trunc)] = trunc elif padding == pre: x[idx, -len(trunc):] = trunc else: raise ValueError("Padding type %s not understood" % padding) return x

跑了15000 步之後,正確率不再上升(0.54左右),然後吧 learning_rate 調低 10 倍,再進行訓練。 最後正確率也只是0.54 +。

預測

這裡是一些預測數據。

最左邊是行號。然後是 評論內容,原評分,預測評分。

分析

正確率既然能夠大於33%,說明還是有一定效果的呀。(有點樂觀了)

影響正確率的一些因素分析:

  1. 影評質量不高,沒有把很短的評論剔除
  2. 沒有把影評數據與 wiki 數據一起訓練,導致影評中有些辭彙在 wiki 辭彙中找不到。
  3. 少量影評的打分和評語的確不搭配 =。=
  4. 訓練數據量不夠大
  5. 網路模型本身的問題。下次可嘗試使用 CNN。

後記

昨天把評論全部導出,跟 wiki 一起訓練跑出一個詞庫,然後拿來訓練網路,交叉驗證正確率上上到0.57+了(訓練數據 90000,測試數據 30000。想多拿一些數據做訓練,可是電腦內存不夠啊)。

看來這樣做還是有一定效果的。以後做一些 nlp 項目的話可以嘗試把訓練文本與 wiki 一起跑出詞庫,效果應該都會好一些。


推薦閱讀:

人語機器
文本多分類踩過的坑
Deliberation Networks 閱讀筆記
NLP選題:6大研究方向助你開啟科研之路
機器學習:生動理解TF-IDF演算法

TAG:自然語言處理 | 機器學習 |