"""
今回はサブテーブルを集計（groupby&agg）してメインテーブルにマージしていきましょう。 

特徴量エンジニアリングで便利な[xfeat](https://github.com/pfnet-research/xfeat)をpip installしておきます。
"""
!pip install xfeat
# This Python 3 environment comes with many helpful analytics libraries installed
# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python
# For example, here's several helpful packages to load

import numpy as np # linear algebra
import pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)

# Input data files are available in the read-only "../input/" directory
# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory

import os
for dirname, _, filenames in os.walk('/kaggle/input'):
    for filename in filenames:
        print(os.path.join(dirname, filename))

# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using "Save & Run All" 
# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session
# 日付型は読み込む前にparse_dateでdatetime型として読み込むことができます。
# もちろん後からの変換も可能ですが。
df_train = pd.read_csv('../input/machine-learning-challenge-osaka-2020/train.csv', parse_dates=['Original_Release_Date', 'Current_Version_Release_Date'], index_col=0)
df_test = pd.read_csv('../input/machine-learning-challenge-osaka-2020/test.csv', parse_dates=['Original_Release_Date', 'Current_Version_Release_Date'], index_col=0)
review_train = pd.read_csv('../input/machine-learning-challenge-osaka-2020/review_train.csv')
review_test = pd.read_csv('../input/machine-learning-challenge-osaka-2020/review_test.csv')
# レビューデータもheadをみておきましょうか
review_train.head()
review_test.head()
# 説明変数（MLでは特徴量とよぶことが多い）が13個、ターゲット変数が1個あります
df_train.shape ,df_test.shape
y_train = df_train.Average_Rating_For_Version
X_train = df_train.drop(['Average_Rating_For_Version'], axis=1)
X_test = df_test.copy()
# 偽装欠損値に対処する必要があるかは場合によりますが、ここでは空欄で置換してみます。
X_train.replace({-1:np.nan}, inplace=True)
X_test.replace({-1:np.nan}, inplace=True)
# 対象年齢は末尾の"+"と除いて数値とするのが本来の序列に基づいて扱えて良いはずに思えますね。
X_train.Age_Rating = X_train.Age_Rating.apply(lambda x: int(x[:-1]))
X_test.Age_Rating = X_test.Age_Rating.apply(lambda x: int(x[:-1]))
# 数値でもdatetimeでも読めなかった特徴量について、ユニーク数をみてみましょう
# 学習データは36773行でしたから、Developer_Name、IOS_Store_Url、Descriptionはユニークに近いことがわかります。
# その他はカテゴリ変数として扱えそうです。

col_cat = []
col_other = []

for col in X_train.columns:
    if X_train[col].dtype=='object':
        print('%s: %d unique values'%(col, X_train[col].nunique()))
        
        if len(X_train)/X_train[col].nunique()>2:
            col_cat.append(col)
        else:
            col_other.append(col)
# ここでは大雑把ですが、ユニーク数がデータ総数の半分以下の場合にカテゴリ変数と扱うことにしました。
col_cat
# それ以外です。
col_other
from xfeat import TargetEncoder
from sklearn.model_selection import KFold # out-of-foldという自分自身の行を見ないで互い違いに集計する手法を用いるのでKFoldを読み込んでいます。
"""
## カテゴリ特徴量のターゲットエンコーディング
"""
# Target Encodingではそのカテゴリ値をとった際のターゲットの平均値に置き換えます。
# これはつまり期待値ですから、何の意味もない背番号を振るOrdinal Encodingよりも合理性があるように思えますよね。

# ターゲットの情報を扱うので、自分自身の行の答えを見ないで他の行を用いて集計する必要があります。
# これをout-of-fold(oof)とよびます。ここではどのようにoofを行うかを指定しています。
# この点は精度評価系の構築における交差検定と深い関わりがあります。
X_train = pd.concat([X_train, y_train], axis=1)

n_fold = 5
kf = KFold(n_splits=n_fold, shuffle=True, random_state=71)

encoder = TargetEncoder(input_cols=col_cat, 
                        target_col='Average_Rating_For_Version', 
                        fold=kf,
                        output_suffix='_te')

X_train = encoder.fit_transform(X_train) # まず学習データを読み込んで各カテゴリ値に対する平均ターゲット率を計算しておきます。またその結果を用いて学習データを変換します。
X_test = encoder.transform(X_test) # 同様にテストデータにも行います。このfitとtransform, fit_transformは様々な処理において共通して用いる頻出なsklearnの書き方になります。

X_train.drop(['Average_Rating_For_Version'], axis=1, inplace=True)
# 右端にエンコーディングされた値が追記されていることがわかります。
X_train.head()
X_test.head()
# さらに様々エンコーディングを行っても良いですが、今回はここまでにしておきます。
# エンコーディング元の生のカテゴリ特徴量を除いておきます。
X_train.drop(col_cat, axis=1, inplace=True)
X_test.drop(col_cat, axis=1, inplace=True)
# テキスト特徴量を一旦分離します。
TXT_train = X_train[col_other].fillna('#') # と同時に欠損値をそれとわかる記号で埋めておきます。
TXT_test = X_test[col_other].fillna('#')

X_train.drop(col_other, axis=1, inplace=True)
X_test.drop(col_other, axis=1, inplace=True)
# ここではオリジナルバージョンのリリース日と現行バージョンのリリース日の差分をとってみましょう
X_train['date_diff'] = (X_train.Current_Version_Release_Date - X_train.Original_Release_Date)/pd.Timedelta(days=1)
X_test['date_diff'] = (X_test.Current_Version_Release_Date - X_test.Original_Release_Date)/pd.Timedelta(days=1)
# ついでにリリース日からの経過日数でこれまでの総レーティング数を割ってみましょうか。
# １日あたりのレーティング数ですから、もしかしたら注目度とか勢いみたいな指標になるかもしれません。
# このようにデータに色々想像を巡らせて、どんな変数を作ったら効きそうかを考えてみましょう。
# このように新しいカラム名を指定して代入することで新しい特徴量を作ることができます。簡単ですね！
X_train['Total_Number_of_Ratings_per_day'] = X_train['Total_Number_of_Ratings'] / (X_train['date_diff']+1) # ゼロ割防止で１を足しておきます。
X_test['Total_Number_of_Ratings_per_day'] = X_test['Total_Number_of_Ratings'] / (X_test['date_diff']+1)
# 他にも色々使えそうですが、今回はベースラインなのでこれくらいにしておきます。
# もう使わないので除いておきます
X_train.drop(['Current_Version_Release_Date', 'Original_Release_Date'], axis=1, inplace=True)
X_test.drop(['Current_Version_Release_Date', 'Original_Release_Date'], axis=1, inplace=True)
"""
## [New!] レビューデータの集約とマージ
さて、今回からレビューデータも取り扱っていきたいと思います。ここで難しい点はメインテーブルとレビューデータはIOS_App_Idで紐づけられるものの、１対１ではなく、１対多であるということです。そのため、単純に横持ちでくっつければ良いというわけにはいかないという点がポイントです。何らかの手段でデータの粒度を合わせる必要が出てきます。ここでは、各IOS_App_Idごとに様々な統計量を集計してからマージすることにします。
"""
# テキストのままでは集計しづらいので、ここではまず何らかの簡単な特徴量を抽出します。
review_train['review_len'] = review_train.review.fillna('#').apply(lambda x: len(x)) # 欠損値を適当に#で穴埋めした上で、文章長さ（文字数カウント）をしています。
review_test['review_len'] = review_test.review.fillna('#').apply(lambda x: len(x))

review_train['review_wordcount'] = review_train.review.fillna('#').apply(lambda x: len(x.split(' '))) # 同様に穴埋めして、スペース区切りのリストにした上で要素数（単語数）を数えてます。
review_test['review_wordcount'] = review_test.review.fillna('#').apply(lambda x: len(x.split(' ')))
cols = ['vote_count', 'review_len', 'review_wordcount'] # 今回はvote_countに加えて、reviewの文章長さと単語数を用いてみます。

summary_train = review_train.groupby(['IOS_App_Id'], as_index=False)[cols].agg(['mean', 'min', 'max', 'std']) # IDごとに平均値、最小値、最大値、標準偏差を集計します
summary_train.columns = [col0+'_'+col1 for col0, col1 in summary_train.columns.to_flat_index()] # カラム名が階層構造になってしまっているので、単層にしておきます。

summary_test = review_test.groupby(['IOS_App_Id'], as_index=False)[cols].agg(['mean', 'min', 'max', 'std'])
summary_test.columns = [col0+'_'+col1 for col0, col1 in summary_test.columns.to_flat_index()]
summary_train
summary_test
X_train = X_train.merge(summary_train, on='IOS_App_Id', how='left')# メインテーブルに横持ちでくっつけます
X_test = X_test.merge(summary_test, on='IOS_App_Id', how='left')
# 後々のことを考えて、pandasのDataFrameからnumpyの配列に変換しておきます。
X_train = X_train.values
X_test = X_test.values
"""
## テキストデータからの特徴抽出
いよいよテキストデータを扱ってみましょう！
今回は[TF-IDF](https://www.kaggle.com/edchen/tf-idf)という手法を用いてテキストデータから特徴量を抽出します。
"""
# TF-IDFではテキスト内の単語の出現頻度をカウントし、（カラム方向の正規化を行った上で）特徴量を抽出します。
from sklearn.feature_extraction.text import TfidfVectorizer
# 手順がわかるように、今回は一つずつ順番にやってみます。まずタイトルからやってみます。
tfidf = TfidfVectorizer(max_features=10000, ngram_range=(1,3))

TFIDF_train = tfidf.fit_transform(TXT_train['Title']) # テキストからの特徴抽出もやり方はお馴染みのfit, fit_transform, transformですね。
TFIDF_test = tfidf.transform(TXT_test['Title'])
# 結果は疎行列で返ってきます。これは単語の出現頻度ベクトルを作成すると非常にワイドでスパース（ほとんどゼロで一部だけ値の入っているすごい横長のベクトル）
# になるためですね。
# 疎行列では値の入っているセルの位置とその値を保持することでメモリフレンドリーです。
# 疎行列の持ち方にも複数通りのものがありますので、調べてみましょう。
TFIDF_train
# 密行列に変換してみると、ほとんどゼロであることがわかります。
TFIDF_train.todense()
# Titleから抽出したTFIDF特徴量をX_train, X_test本体に横持ちで結合させましょう。

import scipy as sp
X_train = sp.sparse.hstack([X_train, TFIDF_train]) # 疎行列はscipyで扱えます。hstackはhorizontal stackの略で横持ち結合ですね。
X_test = sp.sparse.hstack([X_test, TFIDF_test])
# 同様に今度はDescriptionです。
tfidf = TfidfVectorizer(max_features=10000, ngram_range=(1,3))

TFIDF_train = tfidf.fit_transform(TXT_train['Description']) # テキストからの特徴抽出もやり方はお馴染みのfit, fit_transform, transformですね。
TFIDF_test = tfidf.transform(TXT_test['Description'])

X_train = sp.sparse.hstack([X_train, TFIDF_train]) # また横持ちで付け足します
X_test = sp.sparse.hstack([X_test, TFIDF_test])
# 交差検定で行方向のスライスができるように疎行列の持ち方をCSRに変えておきます
X_train = X_train.tocsr()
X_test = X_test.tocsr()

y_train = y_train.values
# とりあえずはここまででモデルの学習をしてみましょう
# 正規化ジニを計算する関数を用意しておきます。
# その他、メジャーなものであればsklearnに準備されています。
def gini(actual, pred, cmpcol = 0, sortcol = 1):
    assert( len(actual) == len(pred) )
    all = np.asarray(np.c_[ actual, pred, np.arange(len(actual)) ], dtype=np.float)
    all = all[ np.lexsort((all[:,2], -1*all[:,1])) ]
    totalLosses = all[:,0].sum()
    giniSum = all[:,0].cumsum().sum() / totalLosses
    
    giniSum -= (len(actual) + 1) / 2.
    return giniSum / len(actual)
 
def gini_normalized(a, p):
    return gini(a, p) / gini(a, a)
from lightgbm import LGBMRegressor # ここでは、LightGBMを使ってみます
from sklearn.model_selection import train_test_split
"""
## k分割交差検定
さて、前々回まではtrain_test_splitでランダムに一定割合（サンプルコードでは３割）を切り出して精度評価に用いてきました。
しかし、皆さんの普段の実験でもそうだと思いますが、n=1の評価というのはいかにも心許ないと感じられると思います。
このn数を増やして精度評価をする方法というのはよく知られており、k分割交差検定が用いられます、詳細は[こちら](https://www.datarobot.com/jp/wiki/cross-validation/)をご参照ください
"""
# k分割交差検定の分割設定は、TargetEncoderで用いたものと同じものを使い回します。
scores = []
y_pred_train = np.zeros(X_train.shape[0]) # 学習データ全体に対する予測値を格納する配列を用意しておきます。
y_pred_test = np.zeros(X_test.shape[0]) # テストデータに対する予測値を格納する配列を用意しておきます。

for i, (train_ix, val_ix) in enumerate(kf.split(X_train, y_train)):
    X_train_, y_train_ = X_train[train_ix], y_train[train_ix]
    X_val, y_val = X_train[val_ix], y_train[val_ix]
    
    # 学習器を初期化します
    model = LGBMRegressor(learning_rate=0.03, n_estimators=1500, random_state=71)
    
    # ここではRMSEを損失関数および評価指標として、30回スコアが改善しなかったら学習を打ち切るearly stoppingを行っています
    model.fit(X_train_, y_train_, eval_set=[(X_val, y_val)], eval_metric='rmse', early_stopping_rounds=30)
    
    # 検定データに対して予測値を取得します
    y_pred_val = model.predict(X_val)
    
    # 検定データに対する予測値を用意しておいた配列の対応する行に代入します
    y_pred_train[val_ix] = y_pred_val
    
    # 検定データに対する正規化ジニスコアを算出します
    score = gini_normalized(y_val, y_pred_val)
    scores.append(score) # 用意しておいたリストに加えます
    print('Fold%dの正規化ジニは%fです。'%(i, score))
    
    # ついでにテストデータに対する予測を行い、用意しておいた配列に足し込みます
    # 結果として、テストデータに対する予測値は各foldで構築したモデルによる予測値の平均値になります（CV Averaging）
    # これは全データでの再学習の手間を省くとともに、アンサンブル効果による精度向上も望めます。
    y_pred_test += model.predict(X_test)/n_fold
# 各foldでの正規化ジニスコア
scores
# 平均値±標準偏差
# ちょい上がりましたが、まだまだ全然足りない気がしますね！
# レビューデータにはもっと有用な情報が内在しているはずなのですが、そこに含まれるテキストデータを十分使えていない気がしますし、
# 何より最も有用そうなratingが直接効きそうなのにreview_testに含まれないというのが悩ましいですね。次回はその辺りの攻略を始めましょう！
scores = np.array(scores)
gini_mean = scores.mean()
gini_std = scores.std()
print('正規化ジニスコア： %f ±  %f'%(gini_mean, gini_std))
# 提出用フォーマットを読み込んで予測値を代入します。
submission = pd.read_csv('../input/machine-learning-challenge-osaka-2020/sample_submission.csv', index_col=0)
submission.Average_Rating_For_Version = y_pred_test
# csvファイルとして保存します
submission.to_csv('submission.csv')
submission
"""
お疲れ様でした。次回はレビューデータの更なる活用に進みたいと思います。
1. テキストデータからの特徴抽出を行い有効活用する
2. 複数テーブルを結合して有効活用する（今回の内容）
3. 欠けているカラムについて、予測値を特徴量とするアプローチ（meta-feature, 発展編）
"""