Netflixの視聴データで人気動画コンテンツの法則を考察
はじめに
自己紹介
通信会社でDXビジネス企画に従事しながらAidemyでデータ分析を勉強中
分析に至った背景
Netflixのビジネスモデルは、DXの最も成功した事例の1つとして、もはや経典のような存在である。
それはネット上で映画やドラマが見れるようになったという単純な理由ではない。これまでAIの不可侵領域とされてたエンタメ分野に一石を投じたからである。
膨大な視聴データから「どんな内容ならウケるか」を分析し、ヒット作を連発することで、Netflixは圧倒的なエンタメコンテンツとして他の追随を許さない存在となった。つまり「面白い動画コンテンツは再現性がある」ということだ。
「我々はデータドリブンな会社である」とCEOが語ったように、まさにデータで成功した最高峰のDXの片鱗を掴みたく、今回の分析に至った。
分析の流れ
データを眺め、分析の方針を立てる
様々な予測モデルを試してみる
分析環境
・言語:Pyton3
・環境:Google Colabolatory
・MacOS:13.2.1(22D68)
・チップ:Apple M1
1. データを眺め、分析の方針を立てる
1-1. データの取得
Netflixの視聴データをKaggleから取得し、pandasで読み込む。
今回はGoogle Colabolatoryを用いるため、専用のdriveをマウントする。
import pandas as pd
#driveのモジュールをインポート
from google.colab import drive
#driveのマウント
drive.mount('/content/drive')
all_df = pd.read_csv('drive/My Drive/Colab Notebooks/imdb_movies_shows.csv')
データの特徴量解説
title : 映画または番組の名前
type : 映画と番組を区別
release_year : コンテンツがリリースされた年
age_certification : 年齢適合性の評価
runtime : 映画または番組の継続時間 (分単位)
genres: ジャンル(コメディ、ドラマ、ホラーなど)
Production_countries : コンテンツが制作された国
seasons: コンテンツの範囲を示すシーズン数 (番組に適用)
imdb_id : IMDb 上の各タイトルの一意の識別子
imdb_score : コンテンツの IMDb 評価(人気と品質を反映)
imdb_votes : 投票数(視聴者のエンゲージメントと人気を示す)
1-2. データを眺めてみる
分析の方針を立てるために、データの外観を確認。
def summary(df):
print(f'data shape: {df.shape}')
summ = pd.DataFrame(df.dtypes, columns=['data type'])
#nullの数
summ['#missing'] = df.isnull().sum().values
#nulllの割合
summ['%missing'] = df.isnull().sum().values / len(df) * 100
#uniqueの数
summ['#unique'] = df.nunique().values
desc = pd.DataFrame(df.describe(include='all').transpose())
summ['min'] = desc['min'].values
summ['max'] = desc['max'].values
summ['average'] = desc['mean'].values
summ['standard_deviation'] = desc['std'].values
summ['first value'] = df.loc[0].values
return summ
summary(all_df).style.background_gradient(cmap='YlOrBr')
結果
data shape: (5806, 11)
ここまでで分かったこと
・5806タイトルと11の特徴量が格納されている
・age_certificationの欠損数が多く、重要な指標だが使用不可
・seasonsの欠損数が多く、使用不可
・imdb_scoreの欠損数は少なく、補完すれば使用可能
・imdb_votesの欠損数は少なく、補完すれば使用可能
上記から、imdb_scoreとimdb_votesの欠損を補完する。
imdb_scoreは平均値、imdb_votesはminとmaxの差が非常に大きいため中央値で補完する。
#欠損が523箇所あるため平均値で補完
all_df['imdb_score'] = all_df['imdb_score'].fillna(all_df['imdb_score'].mean())
#欠損が539箇所あるため中央値で補完(標準偏差が大きくばらつきの影響を抑えるため)
all_df['imdb_votes'] = all_df['imdb_votes'].fillna(all_df['imdb_votes'].median())
1-3. データをtypeの観点で考察
type別にもう少し詳しい状況を把握するため、ヒストグラムを用いて可視化する。
#可視化に用いる特徴量を抽出
features = ["release_year","runtime","seasons","imdb_score","imdb_votes"]
#番組と映画の数の比較
sns.countplot(data=all_df,x="type")
n_cols = len(features)
fig, axs = plt.subplots(n_cols, 1, figsize=(10, n_cols*3))
#様々な特徴量でのヒストグラムをtype別に可視化
for idx, col in enumerate(features):
sns.histplot(data=all_df, x=col, ax=axs[idx], bins=20, hue="type",multiple='dodge')
axs[idx].set_title(f'Histogram of {col}')
#x軸のメモリフォーマットの設定
plt.ticklabel_format(style='plain')
#グラフが重ならないようにレイアウトを調整
plt.tight_layout()
plt.show()
結果
ここまでで分かったこと
・番組と映画は4:6程度
・番組と映画は両方とも2020年以降の作品がほとんど
・runtimeは番組と映画で差異あり
・imdb_scoreも番組と映画で差異があり、映画の方が若干低め
・imdb_votesはタイプ別に差異はほぼなく、50万以下がほとんど
上記から、特徴量'type'での差異が確認されたため、重要指標として分析に使用するためにOne-Hot Encodeingで数値化する。
#カテゴリカル変数をOne-Hot Encodeingで数値化
all_df = pd.get_dummies(all_df, columns = ['type'])
1-4. データをgenresの観点で考察
genres別にもう少し詳しい状況を把握するため、ヒストグラムを用いて可視化する。
#ジャンル毎にカウントしてヒストグラムで可視化
all_df['genres']
all_df['genres'].value_counts().head(10).plot(kind='bar')
plt.show()
'comedy'や'drama'が多いということが分かる一方で、'comedy,drama'と複数の要素が踏まれるパターンがあり、また'drama,comedy'は別でカウントされてしまっているので、考察するにはgenresの要素毎に分解してカウントする必要がある。
#genres毎のimdb_scoreの平均値を算出
#後半で利用
dic_gen_per_score = {}
for gen in all_df["genres"].unique():
dic_gen_per_score[gen] = all_df[all_df['genres'].isin([gen])]['imdb_score'].mean()
#imdb_score_meanで降順
dic_gen_per_score = sorted(dic_gen_per_score.items(), key = lambda x:x[1], reverse = True)
#DataFrameに変換
df_gen_per_score = pd.DataFrame(dic_gen_per_score,columns=['genres','imdb_score'])
#記号を排除する関数を定義
def symbol_filter(text):
target_parts_of_speech = [
"名詞-サ変接続",
"名詞-形容動詞語幹",
"名詞-一般",
"名詞-固有名詞-一般",
"名詞-固有名詞-組織",
"形容詞-自立"
]
result_word_list = []
#chasenの出力フォーマットを定義
CHASEN_ARGS = r' -F "%m\t%f[7]\t%f[6]\t%F-[0,1,2,3]\t%f[4]\t%f[5]\n"'
CHASEN_ARGS += r' -U "%m\t%m\t%m\t%F-[0,1,2,3]\t\t\n"'
tagger = MeCab.Tagger(ipadic.MECAB_ARGS + CHASEN_ARGS)
#文字列を分割
for line in tagger.parse(text).splitlines():
if line is None or line == '' or line == 'EOS' or len(line.split()) < 4:
continue
#品詞を削除
for target_part_of_speech in target_parts_of_speech:
if target_part_of_speech == line.split()[3]:
word = line.split()[2]
result_word_list.append(word)
return result_word_list
#genresの要素を抽出するリスト
genres_token = []
#imdb_scoreの平均値上位100にgenresを解析
tokenizer = Tokenizer()
#genresを抽出
for genres in df_gen_per_score.genres.values:
#genresをわかち書きして要素を取り出す
for text in tokenizer.tokenize(genres,wakati=True):
#記号を排除
token = symbol_filter(text)
#空白文字を排除
if len(token) != 0:
genres_token.append(token[0])
#genres_tokenの重複をカウント
genres_token_counter = dict(collections.Counter(genres_token))
genres_token_counter = sorted(genres_token_counter.items(), key = lambda x:x[1], reverse = True)
genres_token_counter = pd.DataFrame(genres_token_counter, columns=['token','num_token'])
#割合の列を追加
genres_token_counter["Percentage"]=(genres_token_counter['num_token']/genres_token_counter.num_token.values.sum())*100
#表示
display(genres_token_counter)
結果
genresに含まれる要素はドラマ、アクション、コメディなど偏りが大きい。imdb_scoreとgenresの関係を考察するため、genres毎のimdb_scoreの平均値が7.0以上の高得点であるタイトルのgenresに含まれる要素を確認。
#imdb_scoreの平均値が7.0より大きい項目を抽出
df_gen_per_score = df_gen_per_score[df_gen_per_score['imdb_score'] >= 7.0]
結果
ここまでで分かったこと
・imdb_scoreとの関連が強そうなgenresの要素は見つからない
・ドラマ×アクションなど、組み合わせによってはimdb_scoreとの関連が強化される可能性がある
上記からgenresの要素の有無を新たな特徴量とする。
#genresから要素を取り出す関数を定義
def symbol_filter(text):
target_parts_of_speech = [
"名詞-サ変接続",
"名詞-形容動詞語幹",
"名詞-一般",
"名詞-固有名詞-一般",
"名詞-固有名詞-組織",
"形容詞-自立"
]
result_word_list = []
#chasenの出力フォーマットを定義
CHASEN_ARGS = r' -F "%m\t%f[7]\t%f[6]\t%F-[0,1,2,3]\t%f[4]\t%f[5]\n"'
CHASEN_ARGS += r' -U "%m\t%m\t%m\t%F-[0,1,2,3]\t\t\n"'
tagger = MeCab.Tagger(ipadic.MECAB_ARGS + CHASEN_ARGS)
#文字列を分割
for line in tagger.parse(text).splitlines():
if line is None or line == '' or line == 'EOS' or len(line.split()) < 4:
continue
#品詞を削除
for target_part_of_speech in target_parts_of_speech:
if target_part_of_speech == line.split()[3]:
word = line.split()[2]
result_word_list.append(word)
return result_word_list
#genresの要素を特徴量に加えるための関数を定義
def genres_token_func(df,features_genres_token):
for index in range(0,df.shape[0]):
for fea in features_genres_token:
for token in symbol_filter(df['genres'][index]):
if token == fea or df[f'genres_{fea}'][index] == 1:
#genresの要素が含まれれば1
df[f'genres_{fea}'][index] = 1
else:
#genresの要素が含まれなければ0
df[f'genres_{fea}'][index] = 0
return df
#genresの要素
features_genres_token = ['drama','action','comedy','fantasy','animation','scifi','thriller','crime',
'romance','family','documentation','history','european','war','horror',
'sport','music','reality','western']
#データフレームに枠を新たな特徴量を加えるカラムを追加
for fea in features_genres_token:
all_df[f'genres_{fea}']= 0
all_df = genres_token_func(all_df,features_genres_token)
display(all_df)
2. 様々な予測モデルを試してみる
2-1. 準備
#imdb_scoreを予測する
target = all_df['imdb_score']
#学習に用いないカラムを削除
drop_col = ['title','production_countries','genres','age_certification','seasons','imdb_id','imdb_score']
all_df_2 = all_df.drop(drop_col, axis=1)
display(all_df_2)
検証方法はK-分割交差検証法を用い、分割数はK=10とする。
2-2. ロジスティック回帰
from sklearn.model_selection import KFold
#k-分割交差検証を用いる
#分割数10とする
cv = KFold(n_splits=10,random_state=0,shuffle=True)
train_acc_list_kf = []
val_acc_list_kf = []
# fold毎に学習データのインデックスと評価データのインデックスを得る
for i ,(trn_index, val_index) in enumerate(cv.split(all_df_2, target)):
print(f'Fold : {i}')
# データ全体(Xとy)を学習データと評価データに分割
X_train_kf ,X_val_kf = all_df_2.loc[trn_index], all_df_2.loc[val_index]
y_train_kf ,y_val_kf = target[trn_index], target[val_index]
#データを整形する
X_train_kf = np.array(X_train_kf,dtype=int)
X_val_kf = np.array(X_val_kf,dtype=int)
y_train_kf = np.array(y_train_kf,dtype=int)
y_val_kf = np.array(y_val_kf,dtype=int)
model_kf = LogisticRegression()
model_kf.fit(X_train_kf,y_train_kf)
# Train Part
y_pred_kf = model_kf.predict(X_train_kf)
train_acc_kf = accuracy_score(y_train_kf,y_pred_kf)
print(train_acc_kf)
train_acc_list_kf.append(train_acc_kf)
# Valid Part
y_pred_val_kf = model_kf.predict(X_val_kf)
val_acc_kf = accuracy_score(y_val_kf,y_pred_val_kf)
print(val_acc_kf)
val_acc_list_kf.append(val_acc_kf)
print('-'*10 + 'Result' +'-'*10)
print(f'Train_acc Ave : {np.mean(train_acc_list_kf)}')
print(f'Valid_acc Ave : {np.mean(val_acc_list_kf)}')
結果
Train_acc Ave : 0.3970221619176843
Valid_acc Ave : 0.3971701584663778
2-3. SVC分類
from sklearn.model_selection import KFold
from sklearn.svm import SVC
#k-分割交差検証を用いる
#分割数10とする
cv = KFold(n_splits=10,random_state=0,shuffle=True)
train_acc_list_svc = []
val_acc_list_svc = []
for i ,(trn_index, val_index) in enumerate(cv.split(all_df_2, target)):
print(f'Fold : {i}')
# データ全体(Xとy)を学習データと評価データに分割
X_train_svc ,X_val_svc = all_df_2.loc[trn_index], all_df_2.loc[val_index]
y_train_svc ,y_val_svc = target[trn_index], target[val_index]
#データを整形する
X_train_svc = np.array(X_train_svc,dtype=int)
X_val_svc = np.array(X_val_svc,dtype=int)
y_train_svc = np.array(y_train_svc,dtype=int)
y_val_svc = np.array(y_val_svc,dtype=int)
model_svc = SVC(random_state=0)
model_svc.fit(X_train_svc,y_train_svc)
# Train Part
y_pred_svc = model_svc.predict(X_train_svc)
train_acc_svc = accuracy_score(y_train_svc,y_pred_svc)
print(train_acc_svc)
train_acc_list_svc.append(train_acc_svc)
# Valid Part
y_pred_val_svc = model_svc.predict(X_val_svc)
val_acc_svc = accuracy_score(y_val_svc,y_pred_val_svc)
print(val_acc_svc)
val_acc_list_svc.append(val_acc_svc)
print('-'*10 + 'Result' +'-'*10)
print(f'Train_acc Ave : {np.mean(train_acc_list_svc)}')
print(f'Valid_acc Ave : {np.mean(val_acc_list_svc)}')
結果
Train_acc Ave : 0.3986679997143469
Valid_acc Ave : 0.39717134548044397
2-4. ランダムフォレスト
from sklearn.model_selection import KFold
from sklearn.ensemble import RandomForestClassifier
#k-分割交差検証を用いる
#分割数10とする
cv = KFold(n_splits=10,random_state=0,shuffle=True)
train_acc_list_rf = []
val_acc_list_rf = []
#モデルを学習させ交差検証法で評価
for i ,(trn_index, val_index) in enumerate(cv.split(all_df_2, target)):
print(f'Fold : {i}')
# データ全体(Xとy)を学習データと評価データに分割
X_train_rf ,X_val_rf = all_df_2.loc[trn_index], all_df_2.loc[val_index]
y_train_rf ,y_val_rf = target[trn_index], target[val_index]
#データを整形する
X_train_rf = np.array(X_train_rf,dtype=int)
X_val_rf = np.array(X_val_rf,dtype=int)
y_train_rf = np.array(y_train_rf,dtype=int)
y_val_rf = np.array(y_val_rf,dtype=int)
model_rf = RandomForestClassifier(random_state=0)
model_rf.fit(X_train_rf, y_train_rf)
y_pred_rf = model_rf.predict(X_train_rf)
train_acc_rf = accuracy_score(y_train_rf,y_pred_rf)
print(train_acc_rf)
train_acc_list_rf.append(train_acc_rf)
y_pred_val_rf = model_rf.predict(X_val_rf)
val_acc_rf = accuracy_score(y_val_rf,y_pred_val_rf)
print(val_acc_rf)
val_acc_list_rf.append(val_acc_rf)
print('-'*10 + 'Result' +'-'*10)
print(f'Train_acc Ave : {np.mean(train_acc_list_rf)}')
print(f'Valid_acc Ave : {np.mean(val_acc_list_rf)}')
結果
Train_acc Ave : 0.9999808612440191(なぜこうなったのか不明)
Valid_acc Ave : 0.4781298593388332
2-5. 考察
・ロジスティック回帰、SVC分類、ランダムフォレストを用いて予測したが、いずれも正答率の平均が0.5を超えないため、genresの要素の組みわせだけではimdb_scoreを正確に予測することは難しいことが分かった。
・今回の学習では用いなかったtitleを考察し、人気タイトルに含まれているワードを分析し特徴量に加えたら精度が向上するかもしれない。
最後に
このブログはAidemy Premiumのカリキュラムの一環で、受講修了条件を満たすために公開しています。