このチュートリアルではBigGorillaが提供するエンティティマッチングのためのツールの使い方を学習します。ここでは異なるデータソースから取得した二つの映画に関するデータセットを統合します。このステップはチュートリアルの最後 (Part 4) で説明していますが、事前のデータ準備のためにPart1~3において既存のPythonパッケージの利用方法も解説しています。
ステップ1: “Kaggle 5000 Movie Dataset”のダウンロード
ダウンロードするデータセットは下記のコードで指定されているurlを含むcsvファイルです。
# Importing urlib
import urllib
import os
# Creating the data folder
if not os.path.exists('./data'):
os.makedirs('./data')
# Obtaining the dataset using the url that hosts it
kaggle_url = 'https://github.com/sundeepblue/movie_rating_prediction/raw/master/movie_metadata.csv'
if not os.path.exists('./data/kaggle_dataset.csv'): # avoid downloading if the file exists
response = urllib.urlretrieve(kaggle_url, './data/kaggle_dataset.csv')
ステップ2: “IMDB Plain Text Data”のダウンロード
IMDB Plain Text Data (こちらを参照)はそれぞれ映画の1つまたは複数の属性を記述するファイルの集まりです。ここでは映画属性の1つのサブセットに焦点を当てるので、関係するのは下記のファイルの一部のみです:
- genres.list.gz
- ratings.list.gz
** 注: 上記のファイルの合計サイズは約30Mです。 下記のコードの実行には数分かかります。
import gzip
# Obtaining IMDB's text files
imdb_url_prefix = 'ftp://ftp.funet.fi/pub/mirrors/ftp.imdb.com/pub/'
imdb_files_list = ['genres.list.gz', 'ratings.list.gz']
for name in imdb_files_list:
if not os.path.exists('./data/' + name):
response = urllib.urlretrieve(imdb_url_prefix + name, './data/' + name)
urllib.urlcleanup() # urllib fails to download two files from a ftp source. This fixes the bug!
with gzip.open('./data/' + name) as comp_file, open('./data/' + name[:-3], 'w') as reg_file:
file_content = comp_file.read()
reg_file.write(file_content)
ステップ3: “IMDB Prepared Data”のダウンロード
このチュートリアルではgenres.list.gzファイルとratings.list.gzファイルのコンテンツを統合する方法を学びます。しかし、このチュートリアルをわかりやすくするために、”IMDB Plain Text Data”の中のすべてのファイルについて同じプロセスを含めることは避けています。”IMDB Prepared Data”はこのチュートリアルの後の段階で使用する”IMDB Plain Text Data”からのいくつかのファイルを統合することによって収集したデータセットです。下記のコードはこのデータセットをダウンロードします。
imdb_url = 'https://anaconda.org/BigGorilla/datasets/1/download/imdb_dataset.csv'
if not os.path.exists('./data/imdb_dataset.csv'): # avoid downloading if the file exists
response = urllib.urlretrieve(kaggle_url, './data/imdb_dataset.csv')
“ratings.list”データファイルの内容
with open("./data/ratings.list") as myfile:
head = [next(myfile) for x in range(38)]
print (''.join(head[28:38])) # skipping the first 28 lines as they are descriptive headers
“genres.list”データファイルの内容
with open("./data/genres.list") as myfile:
head = [next(myfile) for x in range(392)]
print (''.join(head[382:392])) # skipping the first 382 lines as they are descriptive header
ステップ1: “genres.list”からの情報の抽出
このステップの目標は”movies.list”から映画のタイトルとその作成年を抽出し、抽出されたデータをDataframeへ格納することです。Dataframe (Pythonパッケージpandasに入っています)はBigGorillaで推奨される主要なデータプロファイリングおよびクリーニングの1つです。テキストから希望する情報を抽出するために、Pythonパッケージ”re“に実装されている正規表現を使用します。
import re
import pandas as pd
with open("./data/genres.list") as genres_file:
raw_content = genres_file.readlines()
genres_list = []
content = raw_content[382:]
for line in content:
m = re.match(r'"?(.*[^"])"? \(((?:\d|\?){4})(?:/\w*)?\).*\s((?:\w|-)+)', line.strip())
genres_list.append([m.group(1), m.group(2), m.group(3)])
genres_data = pd.DataFrame(genres_list, columns=['movie', 'year', 'genre'])
ステップ2: “ratings.list”からの情報の抽出
with open("./data/ratings.list") as ratings_file:
raw_content = ratings_file.readlines()
ratings_list = []
content = raw_content[28:]
for line in content:
m = re.match(r'(?:\d|\.|\*){10}\s+\d+\s+(1?\d\.\d)\s"?(.*[^"])"? \(((?:\d|\?){4})(?:/\w*)?\)', line.strip())
if m is None: continue
ratings_list.append([m.group(2), m.group(3), m.group(1)])
ratings_data = pd.DataFrame(ratings_list, columns=['movie', 'year', 'rating'])
他のデータファイルの情報にも関心がある場合、そのデータファイルにも情報抽出手順を繰り返す必要があります。ここでは(チュートリアルを複雑にしないために)、映画のジャンルと評価にだけ関心があると仮定します。上記のコードは、これらの2つの属性に関する抽出データを2つのデータフレーム(genres_list と ratings_list)に格納します。
ステップ1: “Kaggle 5000 Movie Dataset”のローディング
データフレーム(Pythonパッケージpandasに入っています)はデータ探索およびデータプロファイリングに適しています。このチュートリアルのPart2で”IMDB Plain Text Data”からの抽出データをデータフレームに格納しました。”Kaggle 5000 Movies Dataset” もデータフレームにロードして、すべてのデータセットに同じデータプロファイリング手順を実行するとよいでしょう。
import pandas as pd
# Loading the Kaggle dataset from the .csv file (kaggle_dataset.csv)
kaggle_data = pd.read_csv('./data/kaggle_dataset.csv')
ステップ2: いくつかの基本的な統計の計算(プロファイリング)
各データフレームに何本の映画がリストされているかを調べましょう。
print ('Number of movies in kaggle_data: {}'.format(kaggle_data.shape[0]))
print ('Number of movies in genres_data: {}'.format(genres_data.shape[0]))
print ('Number of movies in ratings_data: {}'.format(ratings_data.shape[0]))
また、データに重複(2回以上現れる映画)があるか調べることもできます。同じ映画タイトルと作成年の別のエントリが見つかった場合、そのエントリを重複とみなします。
print ('Number of duplicates in kaggle_data: {}'.format(
sum(kaggle_data.duplicated(subset=['movie_title', 'title_year'], keep=False))))
print ('Number of duplicates in genres_data: {}'.format(
sum(genres_data.duplicated(subset=['movie', 'year'], keep=False))))
print ('Number of duplicates in ratings_data: {}'.format(
sum(ratings_data.duplicated(subset=['movie', 'year'], keep=False))))
ステップ3: 重複の処理(クリーニング)
重複を処理する多くの戦略があります。ここでは重複を処理する単純な方法を利用します。つまり、重複するエントリのうち最初のものだけを残し、それ以外を削除します。
kaggle_data = kaggle_data.drop_duplicates(subset=['movie_title', 'title_year'], keep='first').copy()
genres_data = genres_data.drop_duplicates(subset=['movie', 'year'], keep='first').copy()
ratings_data = ratings_data.drop_duplicates(subset=['movie', 'year'], keep='first').copy()
ステップ4: テキストのノーマライズ(クリーニング)
映画データベースを統合するための主要な属性は映画タイトルです。したがってこれらのタイトルをノーマライズしておくことが重要です。下記のコードはすべての映画タイトルを小文字に変換し、”‘”、”?”などの特殊文字を除去し、一部の特殊文字を置換します(たとえば”&”を”and”に)。
def preprocess_title(title):
title = title.lower()
title = title.replace(',', ' ')
title = title.replace("'", '')
title = title.replace('&', 'and')
title = title.replace('?', '')
title = title.decode('utf-8', 'ignore')
return title.strip()
kaggle_data['norm_movie_title'] = kaggle_data['movie_title'].map(preprocess_title)
genres_data['norm_movie'] = genres_data['movie'].map(preprocess_title)
ratings_data['norm_movie'] = ratings_data['movie'].map(preprocess_title)
ステップ5: いくつかの例を見てみましょう
ここでの目標は簡易サニティチェックのために各データセットについていくつかのサンプルエントリを調べることです。わかりやすくするために、このステップは”Kaggle 5000 Movies Dataset”についてのみ示します。このデータセットはkaggle_dataデータフレームに格納されています。
kaggle_data.sample(3, random_state=0)
データを調べることによってデータをクリーニングするのにどの方法が適切かを判断することができます。たとえば上記の小さなサンプルデータによって、title_year属性が浮動小数点数(有理数)として格納されていることがわかります。title_yearを文字列に変換し、欠落しているタイトル、作成年を“?”に置換するためのクリーニングステップを追加することができます。
def preprocess_year(year):
if pd.isnull(year):
return '?'
else:
return str(int(year))
kaggle_data['norm_title_year'] = kaggle_data['title_year'].map(preprocess_year)
kaggle_data.head()
Part 4: データマッチングとマージング
ここでの主要な目標は種々のソースから取得したデータをマッチングして、1つの包括的なデータセットを作成することです。Part3ではすべてのデータセットを1つのデータフレームに変換し、それを使ってデータをクリーニングしました。Part4ではここまでで作成してきたデータに対して同じデータフレームを引き続き使用します。
ステップ1: “IMDB Plain Text Data”ファイルの統合
ratings_dataとgenres_dataの両方のデータフレームが同じソース(“the IMDB Plain Text data”)からのデータを含んでいることに注意してください。したがってこれらのデータフレームに格納されているデータの間に不一致はないと想定しており、それらを結合するために必要なことは同じタイトルと作成年を共有するエントリをマッチングすることだけです。この単純な”完全一致”はデータフレームを使用することによって簡単に処理できます。
brief_imdb_data = pd.merge(ratings_data, genres_data, how='inner', on=['norm_movie', 'year'])
brief_imdb_data.head()
上記で作成したデータセットをbrief_imdb_dataと呼びます。なぜならこれは2つの属性(genreとrating)だけを含むからです。これ以降は、”IMDB Plain Text Data”からのいくつかのファイルを統合することによって作成したより包括的なIMDBデータセットのバージョンを使用します。すでにこのチュートリアルのPart1を完了している場合、このデータセットはすでにダウンロードされており“data”フォルダ内の“imdb_dataset.csv”に格納されています。下記のコードはこのデータセットをロードし、映画のタイトルと作成年の前処理を実行し、重複を除去し、データセットのサイズを表示します。
# reading the new IMDB dataset
imdb_data = pd.read_csv('./data/imdb_dataset.csv')
# let's normlize the title as we did in Part 3 of the tutorial
imdb_data['norm_title'] = imdb_data['title'].map(preprocess_title)
imdb_data['norm_year'] = imdb_data['year'].map(preprocess_year)
imdb_data = imdb_data.drop_duplicates(subset=['norm_title', 'norm_year'], keep='first').copy()
imdb_data.shape
ステップ2: KaggleおよびIMDBデータセットの統合
2つのデータセットを統合する単純なアプローチでは、単に同じ映画タイトルと作成年を共有するエントリを結合します。下記のコードではこの単純なアプローチで4,248件のマッチが見つかることがわかります。
data_attempt1 = pd.merge(imdb_data, kaggle_data, how='inner', left_on=['norm_title', 'norm_year'],
right_on=['norm_movie_title', 'norm_title_year'])
data_attempt1.shape
しかし、IMDBおよびKaggleデータセットは異なるソースから収集されたものであり、2つのデータセットの中で映画のタイトルが少し異なることが考えられます(“Wall.E”と”WallE”)。そのような一致を見つけるには、映画のタイトルの類似性を見つけ、非常に類似しているタイトルを同じエンティティとみなすことができます。BigGorillaでは、2つのデータセット間の類似エンティティを結合するツールとして、Pythonパッケージpy_stringsimjoinを推奨しています。下記のコードではpy_stringsimjoinを使って、違いが1文字以内(2つのタイトルを同じにするために必要な変更/追加/削除が1文字以内)であるすべてのタイトルを一致とみなします。類似エンティティの結合が完了した後、同じ年に作成されたタイトルのペアだけを選択します。
import py_stringsimjoin as ssj
import py_stringmatching as sm
imdb_data['id'] = range(imdb_data.shape[0])
kaggle_data['id'] = range(kaggle_data.shape[0])
similar_titles = ssj.edit_distance_join(imdb_data, kaggle_data, 'id', 'id', 'norm_title',
'norm_movie_title', l_out_attrs=['norm_title', 'norm_year'],
r_out_attrs=['norm_movie_title', 'norm_title_year'], threshold=1)
# selecting the entries that have the same production year
data_attempt2 = similar_titles[similar_titles.r_norm_title_year == similar_titles.l_norm_year]
data_attempt2.shape
類似エンティティの結合を使って4,689のタイトルのマッチが見つかりました。類似エンティティの結合によってマッチングされたタイトルで、完全に同じではないものを調べてみましょう。
data_attempt2[data_attempt2.l_norm_title != data_attempt2.r_norm_movie_title].head()
“walle”と”wall.e”のようなインスタンスは正しくマッチングされていますが、この技法では若干の誤りが発生します(たとえば”grave”と”brave”)。ここから次のような問題が出てきます:「データマッチングにどのようなメソッドを使うべきか?」、マッチングの適切性をどのように判断できるのか?」。この問題に対処するために、BigGorillaではPythongパッケージpy_entitymatchingの使用を推奨しています。これはMagellan プロジェクトの一環として開発されました。
次のステップでは、py_entitymatchingが機械学習技術を使ってデータマッチングを処理している方法、およびそれによって生成されたマッチングの適切さを評価できることを示します。
ステップ3: Magellanによるデータマッチング
サブステップA: 候補セットの検索 (ブロッキング)
このステップの目標は簡単な発見的方法を使ってマッチとみなす可能性があるペアの数を減らすことです。この作業のために、重要な属性値を1つの文字列に結合する新しい列を各データセットに作成することができます(mixtureと呼びます)。それによって前に行ったのと同じように文字列が類似するエンティティの結合を使って、重要な列の値に重複がある一連のエンティティを見つけることができます。その前に、文字列のmixtureに含まれる列を変換する必要があります。この処理はpy_stringsimjoinパッケージを使うと簡単です。
# transforming the "budget" column into string and creating a new **mixture** column
ssj.utils.converter.dataframe_column_to_str(imdb_data, 'budget', inplace=True)
imdb_data['mixture'] = imdb_data['norm_title'] + ' ' + imdb_data['norm_year'] + ' ' + imdb_data['budget']
# repeating the same thing for the Kaggle dataset
ssj.utils.converter.dataframe_column_to_str(kaggle_data, 'budget', inplace=True)
kaggle_data['mixture'] = kaggle_data['norm_movie_title'] + ' ' + kaggle_data['norm_title_year'] + \
' ' + kaggle_data['budget']
次にmixture列を使って希望する候補セット(Cと呼びます)を作成することができます。
C = ssj.overlap_coefficient_join(kaggle_data, imdb_data, 'id', 'id', 'mixture', 'mixture', sm.WhitespaceTokenizer(),
l_out_attrs=['norm_movie_title', 'norm_title_year', 'duration',
'budget', 'content_rating'],
r_out_attrs=['norm_title', 'norm_year', 'length', 'budget', 'mpaa'],
threshold=0.65)
C.shape
類似するエンティティの結合を行うことによって候補セットの数が18,317ペアに減りました。
サブステップB: キーの指定
次のステップはpy_entitymatchingパッケージに、各データフレームのキーにどの列が対応するかを指定することです。また、候補セットの2つのデータフレームの外部キーにどの列が対応するかを指定する必要があります。
import py_entitymatching as em
em.set_key(kaggle_data, 'id') # specifying the key column in the kaggle dataset
em.set_key(imdb_data, 'id') # specifying the key column in the imdb dataset
em.set_key(C, '_id') # specifying the key in the candidate set
em.set_ltable(C, kaggle_data) # specifying the left table
em.set_rtable(C, imdb_data) # specifying the right table
em.set_fk_rtable(C, 'r_id') # specifying the column that matches the key in the right table
em.set_fk_ltable(C, 'l_id') # specifying the column that matches the key in the left table
サブステップC: ブロッカのデバッグr
ここで候補セットがそれほど類似していない映画ペアを含めることができるような柔軟性を備えていることを確認しておく必要があります。そうでないと、マッチしていた可能性があるペアを除去していた可能性があります。候補セットの中のいくつかのペアを調べることによって、ブロッキングのステップが厳格すぎないかどうかを判断できます。
注: py_entitymatchingパッケージはブロッカをデバッグするためのいくつかのツールも提供しています。
C[['l_norm_movie_title', 'r_norm_title', 'l_norm_title_year', 'r_norm_year',
'l_budget', 'r_budget', 'l_content_rating', 'r_mpaa']].head()
上記のサンプリングによってブロッキングが合理的に行われていることがわかりました。
サブステップD: 候補セットからのサンプリング
このステップの目標は候補セットからサンプルを取得し、サンプリングされた候補に手動でラベルを付ける、つまり候補ペアが正しいマッチかどうかを指定することです。
# Sampling 500 pairs and writing this sample into a .csv file
sampled = C.sample(500, random_state=0)
sampled.to_csv('./data/sampled.csv', encoding='utf-8')
サンプリングされたデータにラベルを付けるためにcsvファイルに新しい列を作成し(labelと名付けます)、その列にペアが正しいペアであれば値1、そうでなければ値0を入力します。ファイルの重複を避けるために、新しいファイルにはlabeled.csvという名前を付けます。
# If you would like to avoid labeling the pairs for now, you can download the labled.csv file from
# BigGorilla using the following command (if you prefer to do it yourself, command the next line)
response = urllib.urlretrieve('https://anaconda.org/BigGorilla/datasets/1/download/labeled.csv',
'./data/labeled.csv')
labeled = em.read_csv_metadata('data/labeled.csv', ltable=kaggle_data, rtable=imdb_data,
fk_ltable='l_id', fk_rtable='r_id', key='_id')
labeled.head()
サブステップE: 機械学習アルゴリズムのトレーニング
ここからは、サンプリングしたデータセットを使って、予測タスクのための種々の機械学習アルゴリズムのトレーニングを行います。そのためにはデータセットをトレーニングセットとテストセットに分割して、予測タスクのために希望する機械学習アルゴリズムを選択する必要があります。
split = em.split_train_test(labeled, train_proportion=0.5, random_state=0)
train_data = split['train']
test_data = split['test']
dt = em.DTMatcher(name='DecisionTree', random_state=0)
svm = em.SVMMatcher(name='SVM', random_state=0)
rf = em.RFMatcher(name='RF', random_state=0)
lg = em.LogRegMatcher(name='LogReg', random_state=0)
ln = em.LinRegMatcher(name='LinReg')
nb = em.NBMatcher(name='NaiveBayes')
機械学習アルゴリズムを適用するには、その前に一連のフィーチャーを抽出する必要があります。好都合なことに、py_entitymatchingパッケージは、2つのデータセットのどの列が相互に対応するかを指定しておけば自動的に一連のフィーチャーを抽出できます。下記のコードは最初に2つのデータセットの列の対応関係を指定します。次に、py_entitymatchingパッケージを使って各列のタイプを判別します。各データセットの列のタイプ(変数l_attr_types and r_attr_typesに格納されています)を検討し、パッケージによって推奨されているtokenizersおよび類似性関数を使って、フィーチャーを抽出するための一連の命令を抽出することができます。変数Fは抽出されたフィーチャーのセットではなく、フィーチャーを計算するための命令をエンコードするために使用します。
attr_corres = em.get_attr_corres(kaggle_data, imdb_data)
attr_corres['corres'] = [('norm_movie_title', 'norm_title'),
('norm_title_year', 'norm_year'),
('content_rating', 'mpaa'),
('budget', 'budget'),
]
l_attr_types = em.get_attr_types(kaggle_data)
r_attr_types = em.get_attr_types(imdb_data)
tok = em.get_tokenizers_for_matching()
sim = em.get_sim_funs_for_matching()
F = em.get_features(kaggle_data, imdb_data, l_attr_types, r_attr_types, attr_corres, tok, sim)
希望するフィーチャーのセットFが抽出されたので、ここでトレーニングデータのフィーチャー値を計算し、データの中の欠落している値を補定することができます。ここでは欠落している値を列の平均値で置換します。
train_features = em.extract_feature_vecs(train_data, feature_table=F, attrs_after='label', show_progress=False)
train_features = em.impute_table(train_features, exclude_attrs=['_id', 'l_id', 'r_id', 'label'], strategy='mean')
計算したフィーチャーを使って種々の機械学習アルゴリズムの性能を評価して、このマッチングタスクに最も適したアルゴリズムを選択することができます。
result = em.select_matcher([dt, rf, svm, ln, lg, nb], table=train_features,
exclude_attrs=['_id', 'l_id', 'r_id', 'label'], k=5,
target_attr='label', metric='f1', random_state=0)
result['cv_stats']
報告された種々の技法の正確さから、”random forest (RF)”アルゴリズムが最高の性能を示すことがわかります。したがってマッチングにはこの技法を使うのが最も適切です。
サブステップF: マッチングの品質の評価
マッチングの品質の評価は重要です。ここでこの目的のためにトレーニングセットを使用して、ランダムフォレストがどの程度適切にマッチを予想できるかを評価します。高い精度が得られていることを確認でき、テストセットをrecallすることができます。
best_model = result['selected_matcher']
best_model.fit(table=train_features, exclude_attrs=['_id', 'l_id', 'r_id', 'label'], target_attr='label')
test_features = em.extract_feature_vecs(test_data, feature_table=F, attrs_after='label', show_progress=False)
test_features = em.impute_table(test_features, exclude_attrs=['_id', 'l_id', 'r_id', 'label'], strategy='mean')
# Predict on the test data
predictions = best_model.predict(table=test_features, exclude_attrs=['_id', 'l_id', 'r_id', 'label'],
append=True, target_attr='predicted', inplace=False)
# Evaluate the predictions
eval_result = em.eval_matches(predictions, 'label', 'predicted')
em.print_eval_summary(eval_result)
サブステップG: トレーニング済みのモデルによるデータセットのマッチング
下記のようにトレーニング済みのモデルを使って2つのテーブルをマッチングすることができます:
candset_features = em.extract_feature_vecs(C, feature_table=F, show_progress=True)
candset_features = em.impute_table(candset_features, exclude_attrs=['_id', 'l_id', 'r_id'], strategy='mean')
predictions = best_model.predict(table=candset_features, exclude_attrs=['_id', 'l_id', 'r_id'],
append=True, target_attr='predicted', inplace=False)
matches = predictions[predictions.predicted == 1]
matchesデータフレームは両方のデータセットのために抽出されたフィーチャーを格納するための多くの列を含んでいます。下記のコードはすべての不要な列を削除し、適切な形式のデータフレームを作成し、その結果として統合的なデータセットが生成されます。
from py_entitymatching.catalog import catalog_manager as cm
matches = matches[['_id', 'l_id', 'r_id', 'predicted']]
matches.reset_index(drop=True, inplace=True)
cm.set_candset_properties(matches, '_id', 'l_id', 'r_id', kaggle_data, imdb_data)
matches = em.add_output_attributes(matches, l_output_attrs=['norm_movie_title', 'norm_title_year', 'budget', 'content_rating'],
r_output_attrs=['norm_title', 'norm_year', 'budget', 'mpaa'],
l_output_prefix='l_', r_output_prefix='r_',
delete_from_catalog=False)
matches.drop('predicted', axis=1, inplace=True)
matches.head()