資料科學在文字分析中的應用 :中英文 NLP(上)

語言: CN / TW / HK

《後疫情時代,資料科學賦能旅遊行業服務質量提升》這篇博文中,我們介紹了貓途鷹文字分析專案的背景和解決方案,並展示了最終的分析結果。接下來,對於中英文 NLP 感興趣的讀者,我們會為大家詳細講解資料採集、資料入庫、資料清理和資料建模步驟中涉及的原理和程式碼實現。由於篇幅的限制,上篇會重點講解資料採集、資料入庫和資料清理這三個步驟,下篇則會講解資料建模的完整流程。

資料採集

1.  抓取工具分析

網頁內容抓取是從網際網路上獲取資料的方式之一。對於使用 Python 進行網頁抓取的開發者,比較主流的工具有以下幾種:

Beautiful Soup

Beautiful Soup 是幾種工具中最容易上手的網頁抓取庫,它可以快速幫助開發者從 HTML 或 XML 格式的檔案中獲取資料。在這個過程中,Beautiful Soup 會一定程度上讀取這類檔案的資料結構,並在此基礎上提供許多與查詢和獲取資料內容相關的方程。除此之外,Beautiful Soup 完善、易於理解的文件和活躍的社群使得開發者不僅可以快速上手,也能快速精通,並靈活運用於開發者自己的應用當中。

不過正因為這些工作特性,相較於其他庫而言,Beautiful Soup也有比較明顯的缺陷。首先,Beautiful Soup 需要依賴其他 Python庫(如 Requests)才能向物件伺服器傳送請求,實現網頁內容的抓取;也需要依賴其他 Python 解析器(如 html.parser)來解析抓取的內容。其次,由於Beautiful Soup需要提前讀取和理解整個檔案的資料框架以便之後內容的查詢,從檔案讀取速度的角度來看,Beautiful Soup 相對較慢。在許多網頁資訊抓取的過程中,需要的資訊可能只佔一小部分,這樣的讀取步驟並不是必需的。

Scrapy

Scrapy 是非常受歡迎的開源網頁抓取庫之一,它最突出的特性是抓取速度快,又因為它基於 Twisted 非同步網路框架,使用者傳送的請求是以無阻塞機制傳送給伺服器的,比阻塞機制更靈活,也更節省資源。因此,Scrapy 擁有了以下這些特性:

  • 對於 HTML 型別網頁,使用XPath或者CSS表述獲取資料的支援
  • 可運行於多種環境,不僅僅侷限於 Python。Linux、Windows、Mac 等系統都可以使用 Scrapy 庫。
  • 擴充套件性強
  • 速度和效率較高
  • 需要的記憶體、CPU 資源較少

縱然 Scrapy 是功能強大的網頁抓取庫,也有相關的社群支援,但生澀難懂的文件使許多開發者望而卻步,上手比較難。

Selenium

Selenium 的起源是為了測試網頁應用程式而開發的,它獲取網頁內容的方式與其他庫截然不同。Selenium 在結構設計上是通過自動化網頁操作來獲取網頁返回的結果,和 Java 的相容性很好,也可以輕鬆應對 AJAX 和 PJAX 請求。和 Beautiful Soup 相似,Selenium 的上手相對簡單,但與其他庫相比,它最大的優勢是可以處理在網頁抓取過程中出現的需要文字輸入才能獲取資訊、或者是彈出頁面等這種需要使用者在瀏覽器中有介入動作的情況。這樣的特性使得開發者對網頁抓取的步驟更加靈活,Selenium 也因此成為了最流行的網頁抓取庫之一。

由於在獲取景點評論的過程中需要應對搜尋欄輸入、彈出頁面和翻頁等情況,在本專案中,我們會使用 Selenium 進行網頁文字資料的抓取。

2.  網頁資料和結構的初步瞭解

各個網站在開發的過程中都有自己獨特的結構和邏輯。同樣是基於 HTML 的網頁,即使 UI 相同,背後的層級關係都可能大相徑庭。這意味著理清網頁抓取的邏輯不僅要了解目標網頁的特性,也要對未來同一個網址的更新換代、同類型其他平臺的網頁特性有所瞭解,通過比較相似的部分整理出一個相對靈活的抓取邏輯。

貓途鷹國際版網站的網頁抓取步驟與中文版網站的步驟相似,這裡我們以 www.tripadvisor.cn 為例,先觀察一下從首頁到景點評論的大致步驟。

步驟一:進入首頁,在搜尋欄中輸入想要搜尋的景點名稱並回車

 

步驟二:頁面更新,出現景點列表,選擇目標景點。

在搜尋景點名稱後,我們需要在圖中所示的列表裡鎖定目標景點。這裡可以有兩層邏輯疊加幫助我們達到這個目的:

  • 貓途鷹的搜尋引擎本身會對景點名稱和搜尋輸入進行比較,通過自己內部的邏輯將符合條件的景點排名靠前
  • 我們可以在結果出現後使用省份、城市等資訊篩選得到目標景點

步驟三:點選目標景點,彈出新頁面,切換至該頁面並尋找相關評論

根據評論格式的特點,我們可以抓取的資訊如下:

  • 使用者
  • 使用者所在地
  • 評分
  • 點評標題
  • 到訪日期
  • 旅行型別
  • 詳細點評
  • 撰寫日期

步驟四:翻頁獲取更多評論

可以看到,在獲取相關網頁的過程中有許多需要瀏覽器去完成的動作,這也是我們選擇 Selenium 的原因。因此,我們的網頁抓取程式會在資料抓取之前,進行相同的步驟。

開發網頁抓取程式時一個非常便利的定位所需內容在 HTML 程式碼中位置的方法是,在瀏覽器中將滑鼠移至內容所在的區域,右鍵選擇 “Inspect”,瀏覽器會彈出網頁 HTML 元素並定位到和內容相關的程式碼。基於這種方法,我們可以使用 Selenium 進行自動化操作和資料抓取。

以上述評論為例,它在 HTML 結構中的位置如下:

在使用 Selenium 時,元素類別和 class 名稱可以幫助我們定位到相關內容,進行進一步操作,抓取相關文字資料。我們可以使用這兩種定位方法:CSS 或 XPATH,開發者可以根據自身需求進行選擇。最終,我們執行的網頁抓取程式大致可以分成兩個步驟:

  • 第一步:傳送請求,使用 Selenium 操作瀏覽器找到指定景點的評論頁面
  • 第二步:進入評論頁面,抓取評論資料    

3.  獲取評論資料

這部分的功能實現需要先安裝和匯入以下 Python 庫:

from selenium import webdriver
import chromedriver_binary
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
import time
import datetime
import re
import pandas as pd
from utility import print_log_message, read_from_config

其中,utility 是一個輔助模組,包含列印會話和發生時間的方程,以及從 ini 設定檔案中讀取程式資訊的方程。utility 中的輔助方程可以反覆出現在需要的模組中。

#utility.py
import time
import configparser
def print_log_message(app_name, procedure, message):
   ts = time.localtime()
   print(time.strftime("%Y-%m-%d %H:%M:%S", ts) + " **" + app_name + "** " + procedure + ":", message)
   return
def read_from_config(file_name, section, var):
   config = configparser.ConfigParser()
   config.read(file_name)
   var_value = config.get(section, var)
   return var_value

在開始網頁抓取之前,我們需要先啟動一個網頁會話程序。

# Initiate web session
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--no-sandbox')     
chrome_options.add_argument('--window-size=1920,1080')
chrome_options.add_argument('--headless')
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--disable-dev-shm-usage')
wd = webdriver.Chrome(ChromeDriverManager().install(),chrome_options=chrome_options)
wd.get(self.web_url)
wd.implicitly_wait(5)
review_results = {}

考慮到執行環境不是 PC 或資源充足的例項,我們需要在程式碼中說明程式沒有顯示方面的需求。ChromeDriverManager() 可以幫助程式在沒有 Chrome 驅動的環境中下載需要的驅動檔案,並傳遞給 Selenium 的會話程序。

注意,許多網頁內容與 Chrome 版本、資源和系統環境、時間有關。本專案中使用的網頁並不受這類資訊或環境的影響,但會受瀏覽器顯示設定的限制,進而影響被抓取的內容。請大家在開發此類抓取程式時,注意核對網頁顯示資訊與實際抓取資料是否吻合。

進入貓途鷹主頁(http://www.tripadvisor.cn/)後,在搜尋欄輸入目標景點名稱並回車,進入新頁面後,在景點列表里根據搜尋引擎排序、省份和城市,尋找並點選進入正確的景點頁面。這裡,我們以“外灘”為例:

location_name = '外灘' 
city = '上海' 
state = '上海' 
# Find search box
wd.find_element(By.CSS_SELECTOR, '.weiIG.Z0.Wh.fRhqZ>div>form>input').click()
# Enter location name
wd.find_element(By.XPATH, '//input[@placeholder="去哪裡?"]').send_keys(f'{location_name}')
wd.find_element(By.XPATH, '//input[@placeholder="去哪裡?"]').send_keys(Keys.ENTER)
# Find the right location with city + province info
element = wd.find_element(By.XPATH,
                                 f'//*[@class="address-text" and contains(text(), "{city}") and contains(text(), "{state}")]')
element.click()

在點選目標景點後,切換至跳轉出的新頁面。進入景點評論頁面之後,我們就可以根據頁面 HTML 的結構和評論在其程式碼層級中的位置將所需資訊抓取下來。Selenium 在尋找某一個元素時,會在整個網頁框架中尋找相關資訊,並不能像其他一些網頁抓取庫一樣鎖定某一個部分並只在該部分中尋找想要的元素。因此,我們需要將一類資訊統一抓取出來,然後剔除一些不需要的資訊。這一過程需要反覆核對真實網頁上顯示的資訊,以防將不需要的內容抓取出來,影響資料質量。

抓取使用的程式碼如下:

comment_section = wd.find_element(By.XPATH, '//*[@data-automation="WebPresentation_PoiReviewsAndQAWeb"]')
# user id
user_elements = comment_section.find_elements(By.XPATH, '//div[@class="ffbzW _c"]/div/div/div/span[@class="WlYyy cPsXC dTqpp"]')
user_list = [x.text for x in user_elements]

對於英文評論資料的抓取,除了網頁框架有一些區別以外,關於地點的資料要更復雜一些,需要進一步的處理。我們在抓取的過程中,預設逗號為分隔符,逗號前的值為城市,逗號後的值為國家地區。

# location
loca_elements = comment_section.find_elements(By.XPATH,
                                                         '//div[@class="ffbzW _c"]/div/div/div/div/div[@class="WlYyy diXIH bQCoY"]')
loca_list = [x.text[5:] for x in loca_elements]
# trip type
trips_element = comment_section.find_elements(By.XPATH, '//*[@class="eRduX"]')
trip_types = [self.separate_trip_type(x.text) for x in trips_element]

注意,由於評價時間的定位相對困難,文字 class 類別會包含網頁景點介紹的資訊,我們需要把這部分不需要的資料剔除。

# comment date
comments_date_element = comment_section.find_elements(By.CSS_SELECTOR, '.WlYyy.diXIH.cspKb.bQCoY')
# drop out the first element
comments_date_element.pop(0)
comments_date = [x.text[5:] for x in comments_date_element]

由於使用者評分並非文字,我們需要從 HTML 的結構中找到代表它的元素,以此來計算星級多少。在貓途鷹的網頁 HTML 中,代表星級的元素是 “bubble”,我們需要在 HTML 結構中找到相關的程式碼,將程式碼中的星級資料提取出來。

# rating
rating_element = comment_section.find_elements(By.XPATH,
                                                         '//div[@class="dHjBB"]/div/span/div/div[@style="display: block;"]')
rating_list = []
for rating_code in rating_element:
       code_string = rating_code.get_attribute('innerHTML')
       s_ind = code_string.find(" bubble_")
       rating_score = code_string[s_ind + len(" bubble_"):s_ind + len(" bubble_") + 1]
       rating_list.append(rating_score)
# comments title
comments_title_elements = comment_section.find_elements(By.XPATH,
                                                                   '//*[@class="WlYyy cPsXC bLFSo cspKb dTqpp"]')
comments_title = [x.text for x in comments_title_elements]
# comments content
comments_content_elements = wd.find_element(By.XPATH,
                                                       '//*[@data-automation="WebPresentation_PoiReviewsAndQAWeb"]'
                                                       ).find_elements(By.XPATH, '//*[@class="duhwe _T bOlcm dMbup "]')
comments_content = [x.text for x in comments_content_elements]

在評論中查詢圖片和尋找星級的邏輯一樣,先要在 HTML 結構中找到代表圖片的部分,然後在程式碼中確認評論中是否包含圖片資訊。

# if review contains pictures
pic_sections = comment_section.find_elements(By.XPATH,
                                                        '//div[@class="ffbzW _c"]/div[@class="hotels-community-tab-common-Card__card--ihfZB hotels-community-tab-common-Card__section--4r93H comment-item"]')
pic_list = []
for r in pic_sections:
       if 'background-image' in r.get_attribute('innerHTML'):
                     pic_list.append(1)
                 else:
                     pic_list.append(0)

綜上所述,我們可以將評論資料按照輸入景點名和所需評論頁數從貓途鷹網站抓取下來並進行整合,最終儲存為一個 Pandas DataFrame。

整個過程可以實現自動化,打包成一個名為 data_processor 的 .py 格式檔案。如需獲取評論資料,我們只需執行以下方程,即可獲得 Pandas DataFrame 格式的景點評論資訊。

#引入之前定義的Python Class:
from data_processor import WebScrapper
scrapper = WebScrapper()
#執行網頁抓取方程抓取中文語料:
trip_review_data = scrapper.trip_advisor_zh_scrapper_runner(location, location_city, location_state, page_n=int(n_pages))

其中 location 代表景點名稱,location_city 和 location_state 代表景點所在的城市和省份,page_n 代表需要抓取的頁數。

資料入庫

在得到抓取的評論資料後,我們可以將資料存進資料庫,以便資料分享,進行下一步的分析和建模。以 PieCloudDB Database 為例,我們可以使用 Python 的 Postgres SQL 驅動與 PieCloudDB 進行連線。

本專案實現資料入庫的方式是,在獲取了評論資料並整合為 Pandas DataFrame 後,我們將藉助 SQLAlchemy 引擎將 Pandas 資料通過 psycopg2 上傳至資料庫。首先,我們需要定義連線資料庫的引擎:

from sqlalchemy import create_engine
import psycopg2
engine = create_engine('postgresql+psycopg2://user_name:password@db_ip:port /database')

其中 postgresql + psycopg2 是我們在連線資料庫時需要使用的驅動,user_name 是資料庫使用者名稱,password 是對應的登陸密碼,db_ip 為資料庫 ip 或 endpoint,port 為資料庫外部連線介面,database 是資料庫名稱。

將引擎傳遞給 Pandas 後,我們就可以輕鬆地將 Pandas DataFrame 上傳至資料庫,完成入庫操作。

data.to_sql(table_name, engine, if_exists=‘replace’, index=False)

data 是我們需要入庫的 Pandas DataFrame 資料,table_name 是表名,engine 是我們之前定義的 SQLAlchemy 引擎, if_exists=‘replace’ 和 index=False 則是 Pandas to_sql() 方程的選項。這裡選項的含義是,如果表已存在則用現有資料替代已有資料,並且在入庫過程中,我們不需要考慮索引。

資料清洗

在這個步驟中,我們會根據原資料的特性對評論資料進行清理,為後續的建模做準備。抓取下來的評論資料包含以下三種類別的資訊:

  • 使用者資訊(如所在地等)
  • 評論資訊(如是否包含圖片資訊等)
  • 評論語料

在正式進入這個步驟前,我們需要匯入以下程式碼庫,其中部分程式碼庫會在資料建模步驟使用:

import numpy as np
import pandas as pd
import psycopg2
from sqlalchemy import create_engine
import langid
import re
import emoji
from sklearn.preprocessing import MultiLabelBinarizer
import demoji
import random
from random import sample
import itertools
from collections import Counter
import matplotlib.pyplot as plt

使用者資訊與評論資訊的運用主要在 BI 部分體現,建模部分主要依靠評論語料資料。我們需要根據評論語言採取合適的清理、分詞和建模方法。首先,我們從資料庫中調取資料,通過以下程式碼可以實現。

中文評論資料:

df = pd.read_sql('SELECT * FROM "上海_上海_外灘_source_review"', engine)
df.shape

英文評論資料:

df = pd.read_sql('SELECT * FROM "Shanghai_Shanghai_The Bund (Wai Tan)_source_review_EN"', engine)
df.shape

我們在中文版網站抓取了171頁評論,每頁有10個評論,合計1710條評論;在國際版網站抓取了200頁評論,合計2000條評論。

1.  資料型別處理

由於寫入資料庫的資料都是字串型別,我們需要先對每一列資料的資料型別進行校對和轉換。在中文評論資料中,需要轉換的變數是評論時間和評分。

df['comment_date'] = pd.to_datetime(df['comment_date'])
df['rating'] = df['rating'].astype(str)
df['comment_year'] = df['comment_date'].dt.year
df['comment_month'] = df['comment_date'].dt.month

2.  瞭解資料狀況

在處理空值和轉換資料之前,我們可以大致瀏覽一下資料,對空值狀況有一個初步的瞭解。

df.isnull().sum()

中文評論資料的空值大致情況如下:

與中文評論資料不同的是,英文評論資料中需要處理的空白資料要多一些,主要集中在使用者所在地和旅行型別兩個變數當中。

3.  處理旅行型別空值

對於存在空值的變數,我們可以通過對變數各類別的統計來大致瞭解其特性。以旅行型別(trip_type)為例,該變數有6種類型,其中一種是使用者未表明的旅行型別,這類資料都以空值形式存在:

df.groupby(['trip_type']).size()

因為旅行型別是分類變數,在本專案的情況下,我們用類別“未知”或“NA”填充空值。

中文評論資料:

df['trip_type'] = df['trip_type'].fillna('未知')

英文評論資料:

df['trip_type'] = df['trip_type'].fillna('NA')

在中文評論的文字分析中,旅行型別分為以下六種,與英文是對應的關係:全家遊、商務行、情侶遊、獨自旅行、結伴旅行、未知。為了方便之後的分析,我們需要建立一個查詢表,將兩種語言的旅行型別對應起來。

zh_trip_type = ['全家遊', '商務行', '情侶遊', '獨自旅行', '結伴旅行', '未知']
en_trip_type = ['Family', 'Business', 'Couples', 'Solo', 'Friends', 'NA']
trip_type_df = pd.DataFrame({'zh_type':zh_trip_type, 'en_type':en_trip_type})

然後將該表寫進資料庫,以便後續的視覺化分析。

trip_type_df.to_sql("tripadvisor_TripType_lookup", engine, if_exists="replace", index=False)

4.  處理英文評論資料中使用者所在地資訊

在英文評論資料中,由於使用者所在地為使用者自行填充的資訊,地區資料非常混亂,並非按照某一個順序或者邏輯來填充。城市和國家欄位不僅需要處理空值,還需要校正。在抓取資料時,我們抓取地區資訊的邏輯為:

  • 如果地區資訊用逗號隔開,前一個詞為城市,後一個詞為國家/省份
  • 如果沒有逗號,則預設該資訊為國家資訊

對於國際版網站的評論分析,我們選擇細分使用者所在地到國家層級。注意,由於很多使用者有拼寫錯誤或填寫虛假地名的問題,我們的目標是儘可能地在力所能及的範圍內修正資訊,如校正大小寫、縮寫、對應城市資訊等。這裡,我們的具體解決方法是:

  • 將縮寫的國家/省份提取出來並單獨處理(以美國為主,使用者在填寫地區資訊時只填寫州名)
  • 檢視除縮寫以外的國家資訊,如國家名稱未出現在國家列表裡,則認為是城市資訊
  • 國家欄位中出現的城市名錯填(如大型城市)和拼寫錯誤問題,則手動修改處理

注意,本專案中使用的國家、地區名參考自 國家名稱資訊來源美國各州及其縮寫來源

首先,我們從檔案系統中讀取國家資訊:

country_file = open("countries.txt", "r")
country_data = country_file.read()
country_list = country_data.split("\n")
countries_lower = [x.lower() for x in country_list]
讀取美國州名及其縮寫資訊:
state_code = pd.read_csv("state_code_lookup.csv")

下列方程可以讀取一個國家名字串,並判斷是否需要清理和修改:

def formating_country_info(s_input):
   if s_input is None: #若字串輸入為空值,返回空值
       return None
   if s_input.strip().lower() in countries_lower: #若字串輸入在國家列表中,返回國家名
       c_index = countries_lower.index(s_input.strip().lower())
       return country_list[c_index]
   else:
       if len(s_input) == 2: #若輸入為縮寫,在美國州名、墨西哥省名和英國縮寫中查詢,若可以找到,返回對應國家名稱
           if s_input.strip().upper() in state_code["code"].to_list():
               return "United States"
           elif s_input.strip().upper() == "UK":
               return "United Kingdom"
           elif s_input.strip().upper() in ("RJ", "GO", "CE"):
               return "Mexico"
           elif s_input.strip().upper() in ("SP", "SG"):
               return "Singapore"
           else:
               # could not detect country info
               return None
       else: #其他情況,需要手動修改國家名稱
           if s_input.strip().lower() == "caior":
               return "Egypt"
           else:
               return None

擁有了清理單個值的方程後,我們可以通過 .apply() 函式將該方程應用至 Pandas DataFrame 中代表國家資訊的列中。

df["location_country"] = df["location_country"].apply(formating_country_info)

然後,檢查一下清理後的結果:

df["location_country"].isnull().sum()

我們注意到空值的數量有所增加,除了修正部分資料以外,對於一些不存在的地名,以上方程會將其轉換為空值。接下來,我們來處理城市資訊,並將可能被分類為城市的國家資訊補充至國家變數中。我們可以根據國家的名稱篩選可能錯位的資訊,將這類資訊作為國家資訊的填充,剩下的預設為城市名稱。

def check_if_country_info(city_list):
   clean_list = []
   country_fill_list = []
   for city in city_list:
       if city is None:
           clean_list.append(None)
           country_fill_list.append(None)
       elif city.strip().lower() in countries_lower: #如城市變數中出現的是國家名,記錄國家名稱
           c_index = countries_lower.index(city.strip().lower())
           country_name = country_list[c_index]
           if country_name == "Singapore": #如城市名為新加坡,保留城市名,如不是則將原先的城市名轉換為空值
               clean_list.append(country_name)
           else:
               clean_list.append(None)
           country_fill_list.append(country_name)
       else:
           # format city string
           city_name = city.strip().lower().capitalize()
           clean_list.append(city_name)
           country_fill_list.append(None)
   return clean_list, country_fill_list

執行上述方程,我們會得到兩個數列,一個為清理後的城市資料,一個為填充國家資訊的資料。

city_list, country_fillin = check_if_country_info(df["location_city"].to_list())

在資料中新建一個列,儲存填充國家資訊的數列。

df["country_fill_temp"] = country_fillin

替換英文評論資料中的城市資訊,並將新建的列填充進國家資訊的空值中,再將用來填充的列刪除。

df["location_city"] = city_list
df["location_country"] = df["location_country"].fillna(df["country_fill_temp"])
df = df.drop(columns=["country_fill_temp"])

至此,我們就講解完成了本專案中資料採集、資料入庫和資料清理步驟的原理和程式碼實現。雖然處理資料的過程艱辛且漫長,但因此能將大量原始資料轉換成有用的資料是非常有價值的。如果大家對於更高階的資料建模步驟感興趣,想知道如何實現文字資料的 emoji 分析、分詞關鍵詞、文字情感分析、詞性詞頻分析和主題模型文字分類,請持續關注 Data Science Lab 的後續博文。

 

 


 

 

參考資料:

 

本文中部分資料來自網際網路,如若侵權,請聯絡刪除