📊

StreamlitでEDAを助けるアプリ開発の記録

2024/08/16に公開

概要

Streamlitでテーブルデータかつ分類タスクのEDAを助けるアプリを作ってみた。

https://huggingface.co/spaces/inami-9192631770/app

はじめに

Python,SQL,統計学,機械学習を一通り触れたのち、なにかアウトプットの題材はないかと考えた。
テーブルデータを扱うのが好きなので、KaggleのPlayGroundがちょうど良さそうなのでやってみることにした。
何回かやっていると、可視化のあたりはある程度コードの型が決まってくることに気づいた。
もっとEDAをスムーズに進められたら、モデルのパラメータ調整や新しい情報の取得に時間を使えるだろうと考えた。
そこで、データをアップロードしてマウスでポチポチするだけでデータセットの概要が分かるアプリの開発をすることにした。
ちょうど盆休みであったため、製作期間を1週間に定めた。

アプリの設計と実装

機能概要

  • ユーザーがデータをアップロードすることで、データの概要・各特徴量の要約が可視化できる。
  • また、LGBMのデフォルトパラメータで簡単な学習を行い、モデル評価と特徴量重要度をレポートで確認できる。
  • データがアップロードされない場合、デフォルトのデータでアプリに一通り触れることができる。

開発環境

プラットフォーム : Streamlit
無料で使用でき、AIアプリの構築に向いているStreamlitを選んだ。

開発ツール : VScode
VScodeでStreamlitと同じ環境にするため仮想環境を構築した。

ディレクトリ構造
app/
│
├── .streamlit/
│     └──config.toml
├── dataset
│     ├── titanic_train.csv
│     └── titanic_test.csv
├── img/
├── .gitattributes
├── README.md
├── app.py
├── function.py
└── requirements.txt

app.pyにメインのコード、function.pyに関数コードを一覧にした。
デフォルトのデータはタイタニックの生存予測を使用した。

config.toml
maxUploadSize = 1000
maxMessageSize = 1000

アップデートサイズとメッセージサイズのMAX値が初期設定200MBだったため、1GBまで読み込めるよう変更。

requirements.txt
streamlit==1.34.0
seaborn==0.13.2
matplotlib==3.7.5
lightgbm==3.2.1
scikit-learn==1.3.0

ターミナルでローカル環境にしてから(.\myenv\Scripts\activate)、
pip install -r requirements.txtでインストールを行うとstreamlitと同じ環境にでき、
streamlit run app.pyでローカルURLでアプリの動作を確認しながら作業できる。

アプリ構造

全体

ライブラリ一覧
ライブラリ
import streamlit as st
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from scipy.stats import gaussian_kde
from functools import reduce
import lightgbm as lgb
from sklearn.preprocessing import LabelEncoder
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import label_binarize
from sklearn.metrics import (
    accuracy_score,
    precision_score,
    recall_score,
    f1_score,
    log_loss,
    classification_report,
    confusion_matrix,
    roc_auc_score,
    roc_curve,
    auc,
    matthews_corrcoef
)
from function import *
アプリ全体像
st.set_page_config(layout='wide', page_title='データ分析EDA', page_icon='📊')

'''
(省略)データセットの初期設定とsession_state処理の記述(アプリ評価-結果と改善点に記載)
'''

#サイドメニュー設定
st.sidebar.title('データ分析EDA (分類)')
home = 'ホーム'
loading_data = 'データの準備'
data_summary = 'データ概要'
each_feature = '各特徴量の要約'
model = 'モデル構築'
selection = [home, loading_data, data_summary, each_feature, model]
choice = st.sidebar.selectbox('メニュー', selection, key='menu')

#メインメニュー設定
if choice == home:if choice == loading_data:if choice == data_summary:if choice == each_feature:elif choice == model:

ホーム


アプリ概要(トップ画面)とガイドの表示

if choice == home:
    home_tab1, home_tab2 = st.tabs(['アプリ概要','ガイド'])
    with home_tab1:with home_tab2:

データの準備


ユーザがデータをアップロードするページ

ページ左にアップローダーを設置。
ページ右にデータのアップロードが完了したか確認できるようにしてある。
アップロードされた場合、目的変数と説明変数の設定セレクトが表示される。

with col1:
    # データ読み込み割合を設定できるスライダー
    sample_ratio = st.slider(
        'データの読み込み割合を設定 (%)',
        min_value=10, max_value=100, step=10, value=100,
    ) / 100
    
    #アップローダー
    st.markdown('**学習用のデータをアップロード**')
    uploaded_train_file = st.file_uploader('学習用データ(以降TrainData)', type=['csv'])
    st.markdown('<br>', unsafe_allow_html=True)
    st.markdown('**予測用のデータをアップロード**')
    uploaded_test_file = st.file_uploader('予測用データ(以降TestData)', type=['csv'])
    st.markdown('<br>', unsafe_allow_html=True)

ユーザーに学習用データと予測用データを分けてアップロードしてもらう。
1GB、csv形式という制限がある。
それぞれをuploaded_train_fileuploaded_test_fileに入れる。

with col2:
    if uploaded_train_file is not None:
            df_train = pd.read_csv(uploaded_train_file)
            if sample_ratio < 1.0:
                df_train = df_train.sample(frac=sample_ratio, random_state=42)
            st.info(f'トレーニングデータがアップロードされました👌 (設定読込割合:{int(sample_ratio * 100)}%)')
            st.session_state.df_train = df_train
        else:
            st.warning('現在デフォルトのトレーニングデータを使用しています。')
    '''
    (省略)同様に予測用データの処理
    '''

    # 目的変数と説明変数の設定
    if uploaded_train_file is not None:
        feature_target = df_train.columns.tolist()
        st.error('目的変数・説明変数を指定してください。')
        # 目的変数の選択
        target = st.selectbox('目的変数', feature_target, index=0)
        features_drop_target = [feature for feature in feature_target if feature != target]
        # 説明変数の選択
        features = st.multiselect('説明変数', features_drop_target, features_drop_target)
        st.session_state.target = target
        st.session_state.features = features
    else:
        st.warning('目的変数はデフォルトです。')    

データのアップロードが確認された場合(uploaded_train_fileが存在する場合)、そのデータをdf_trainに入れる。もしユーザーがデータの割合を変更していたらその値を適用する。
アップロードされたdf_trainをst.session_state.df_trainに収納する。

予測用データも同様に行う。

uploaded_train_fileがアップロードされた場合、目的変数と説明変数を指定するセレクトボックス、マルチセレクトを表示する。
ユーザーが目的変数と説明変数を指定したら、session_stateにtarget,featuresとして収納する。

st.sesstion_state.df_train,df_test,target,featuresをコード全体の最初に呼び出すことでアップロードされたデータに更新される。


データ概要


データの概要を理解するためのページ

if choice == data_summary:
    dataset()
    tab1, tab2, tab3, tab4 = st.tabs(['データの確認', 'データの要約', 'ヒートマップ', 'ペアプロット'])

データの確認
pandasデータフレームでデータを確認できる。

データの要約

    def dataset_overview(df):
        column_names = df.columns
        data_types = df.dtypes
        missing_values = df.isnull().sum()
        total_rows = len(df)
        missing_percent = (missing_values / total_rows) * 100
        unique_counts = [df[col].nunique() for col in column_names]

        summary = pd.DataFrame({
            'Dtype': data_types,
            '欠損値': missing_values,
            '欠損値割合(%)': np.floor(missing_percent).astype(int),
            'ユニークな値の数': unique_counts
        })
        return st.dataframe(summary)


カラム名,Dtype,欠損値,欠損値割合,ユニークな値の数が確認できる。

●選んだ理由

  • Dtype
    • 文字列型であると数学的な演算が困難なため処理が必要になる。
    • モデルのアルゴリズムによっても使用できるdtypeが制限される。
    • 大きなデータセットを使う場合、dtypeを統一しint8など整数型の小さな値にすることでメモリ効率化を多少図れる。
  • 欠損値・欠損値割合
    • 欠損値処理をどうするかでモデルの性能に関わるため外せない。
  • ユニークな値
    • その特徴量の変数尺度の整理に確認するため採用した。

Kaggleコンペで欠損値割合が圧倒的に高い特徴量を削除するとモデルの性能がぐっと下がり、そのままNaNをグループとして扱ってラベルエンコードする方がモデルの性能が良くなるということがあった。
単純にNaN以外の中に目的変数に深く関わるラベルがあったという話だが、欠損値が多いから削除するだけが前処理ではないことを学んだ。

●選ばなかった理由

  • 外れ値
    • 最初は四分位範囲から外れ値を予測して表示しようと思ったが、かなり複雑かつデータの意味によって条件が異なってきて人の目が必要になるため除外した。
  • 異常値
    • 異常値も同様で人の目によって振り分けた方が正確なので除外した。

外れ値予測や異常値予測といったモデルがあれば使用しても良いのかもしれない。
なければ作ってもいいのかもしれない。

ヒートマップ

ヒートマップ作成のため一旦文字列の特徴量をラベルエンコーディングする。
session_stateにtarget,featuresで分けているため結合。
気になる特徴量を選別して表示できるようマルチセレクトを設置した。
実行ボタンを押すと処理が始まる。

corr_matrix = encoded_df[options].corr()

まずは相関行列を取得。

with tab1_col1:
plt.figure(figsize=(10, 8))
sns.heatmap(corr_matrix, annot=None, cmap='coolwarm', center=0,
            xticklabels=corr_matrix.columns, yticklabels=corr_matrix.index)
st.pyplot(plt)
plt.close()

seabornでヒートマップを取得する。特徴量が多くなってくると見にくくなるため値はannot=Noneにしている。

with tab1_col2:
    st.dataframe(corr_matrix)

データフレームで相関行列を表示してヒートマップの詳しい値を隣で確認できるようにした。

相関係数TOP10

corr_matrix_abs = corr_matrix.abs()
mask = np.triu(np.ones_like(corr_matrix_abs, dtype=bool))
tri_corr_matrix = corr_matrix_abs.where(mask)
np.fill_diagonal(tri_corr_matrix.values, np.nan)
sorted_corr = tri_corr_matrix.unstack().sort_values(ascending=False)
top_10_corr = sorted_corr.head(10)

corr_matrix_abs = corr_matrix.abs()で相関行列の値を絶対値で取得。
mask = np.triu(np.ones_like(corr_matrix_abs, dtype=bool))で上三角行列のマスクを作成。
tri_corr_matrix = corr_matrix_abs.where(mask)相関行列の上三角部分のみを残し、他の部分は NaN にする。
np.fill_diagonal(tri_corr_matrix.values, np.nan)対角線(特徴量自身の重なる部分)をNaNにする。
sorted_corr = tri_corr_matrix.unstack().sort_values(ascending=False)行列の (行, 列) ペアをインデックスとして持たせ、対応する相関係数を値にする。降順に並べて
top_10_corr = sorted_corr.head(10)でできあがり。

st.write("### Top 10")
st.dataframe(top_10_corr.reset_index(name='Correlation').rename(columns={'level_0': 'Feature1', 'level_1': 'Feature2'}))

unstackされたインデックスを普通の列に戻し、リネームでカラム名を設定しデータフレームで表示。

ペアプロット

pairplot = sns.pairplot(df_subset, hue=target, palette='viridis', plot_kws={'alpha': 0.6}, corner=True)
st.pyplot(pairplot.figure)
plt.close()

特徴量が多いと行列の各組み合わせが小さくなり見にくいため、ヒートマップ同様ユーザーが任意に選べるようにマルチセレクトで目的変数以外の特徴量を選択できるようにしてある。
実行を押すと文字列をラベルエンコードしグラフを作成する。
hue=targetでターゲットごとに色分けがされる。


各特徴量の要約


特徴量毎の要約を確認できる

feature_choice = st.sidebar.selectbox('特徴量の選択', [target] + features, index=0)

サイドメニューに特徴量を選択するセレクトボックスを設置。

関数一覧(省略)

#ユニークな値の取得と表示の関数
def get_unique_values(df, column):def display_values_with_quotes(values):#ヒストグラム
def plot_histogram_kde(df, feature_choice):#散布図
def plot_scatter(df, feature_choice, target):#棒グラフ
def plot_bar_chart(df, feature_choice, chart_key):#クラス割合棒グラフ
def plot_target_ratio(df, feature_choice, chart_key):#バイオリンプロット
def violin_plot(df, feature, chart_key):

質的変数・量的変数によって使用するグラフを分けたかったため関数に収納して使いまわす。

量的変数・質的変数・時系列変数の処理

#量的変数の処理
def numeric_feature(df_train, df_test, feature_choice):#質的変数の処理
def categorical_feature(df_train, df_test, feature_choice, max_display=10):#時系列変数の処理(一時保留中)
def datatime_feature():

質的変数・量的変数によって表示する内容を変更している。
質的変数ではユニークな値を確認したいが、量的変数で表示すると大変なことになるためである。
また、質的変数でも10個以上ユニークな値があったら'…'で省略されるようにしている。
時系列変数についてはどう扱うか迷うところがあり現在保留中である。

グラフは、
特徴把握と予測用データの比較に質的変数は棒グラフを、量的変数はヒストグラムで表示。
質的変数が棒グラフなのは降順に並べたかったからである。
量的変数がヒストグラムなのは分布を知りいためである。
目的変数との関係把握に質的変数はクラス割合棒グラフ、量的変数はバイオリンプロットと散布図を採用した。

ユーザーが任意の変数尺度に変更した場合の処理

def select_type(index_num):
    select_type = st.sidebar.radio('変数尺度の変更', ['量的変数', '質的変数'], index=index_num)
    if select_type == '量的変数':
        numeric_feature(df_train, df_test, feature_choice)
    elif select_type == '質的変数':
        categorical_feature(df_train, df_test, feature_choice)

以下コードで自動で質的変数・量的変数を振り分けるようにしているがまだ完全ではない。
ユーザーが任意の変数尺度に変更できるようラジオボタンを設置し、選んだ尺度によって処理を行う。

変数尺度の自動振り分け

    cleaned_data = df_train[feature_choice].dropna()

    # 量的変数の条件
    if pd.api.types.is_numeric_dtype(cleaned_data):
        if cleaned_data.nunique() < 50:
            select_type(1) 
        else:
            select_type(0)
    # 質的変数の条件
    elif (
            pd.api.types.is_categorical_dtype(cleaned_data) or
            pd.api.types.is_string_dtype(cleaned_data) or
            cleaned_data.dtype == 'object'
        ):
        select_type(1)
    # 時系列変数の条件
    elif pd.api.types.is_datetime64_any_dtype(cleaned_data):
        select_type(2)  # 時系列データとして処理
  • 量的変数の設定
    • Dtypeが数値であり、50以上のユニークな値を持つ場合
  • 質的変数の設定
    • Dtypeがカテゴリカルデータ・文字列データ・オブジェクトデータの場合
    • Dtypeが数値であるが、50未満のユニークな値を持つ場合
  • 時系列変数の設定
    • Dtypeがdatetime型である場合

Dtypeが数値であるが50未満であれば質的変数という50の値は、年齢の場合大体使用される年齢間は50くらいかなと考えたからである。


モデル構築


LGBMでの基本的な学習とモデル評価・特徴量重要度の取得ができ、簡単なレポートにまとめる

使用する全体データの割合を設定

data_usage_rate = st.slider('使用するTrainDataの割合を設定してください。', 0.0, 1.0, 1.0)


データ量が大きい場合、ユーザーがスライダーでデータ量を選べるように変更できる。
LGBMでのモデル作成時にスライダーのデータ割合でデータをサンプリングする。

検証データ割合の設定

data_usage_testrate = st.slider('TrainDataの検証データ割合を設定してください。', 0.0, 1.0, 0.3)


検証データ割合はモデルの性能に影響し、人によって好みがあるため変更できるようにした。

目的変数の確認、説明変数の指定

再度ユーザーに目的変数と説明変数を確認してもらう目的で設置した。

クラス不均衡の考慮

クラス比率計算
#比率を整数比に変換する関数
def compute_ratio(ratios): 
    # 比率を整数に変換
    scale_factor = 1 / min(ratios)  # 最小値でスケーリング
    int_ratios = [round(ratio * scale_factor) for ratio in ratios]
    # 最大公約数で割る
    def find_gcd(a, b):
        while b:
            a, b = b, a % b
        return a
    ratio_gcd = reduce(find_gcd, int_ratios)
    return [r // ratio_gcd for r in int_ratios]

#比率を計算
ratios = value_counts.values
ratios_int = compute_ratio(ratios)
labels = [f'{label}' for label in value_counts.index]
#比率の差を判断するための関数
def categorize_ratio(ratios):
    max_ratio = max(ratios)
    min_ratio = min(ratios)
    ratio_diff = max_ratio / min_ratio
    
    if ratio_diff >= 10:
        return '高い比率差'
    elif ratio_diff >= 5:
        return '中程度の比率差'
    elif 2<= ratio_diff < 5:
        return '低い比率差'
    else:
        return 'ほぼ均衡'

Kaggleのディスカッションでメダルゴールドの人が比率差は1:10くらいでなければ考慮しません。実際のデータでは比率差が大きいことはよくあり、考慮しすぎてデータを平らにサンプリングすると予測の安定性が落ちる場合がある。というようなことを言っていたことを参考に、高い比率差を10以上、中程度の比率差を5-10、低い比率差・ほぼ均衡を5以下として条件をつけた。

#比率のカテゴリーを決定
ratio_category = categorize_ratio(ratios)

#比率をフォーマット
class_ratio = (f"クラス比率は {':'.join(labels)} = {':'.join(map(str, ratios_int))}{ratio_category}")
st.session_state.class_ratio = class_ratio

if ratio_category == '高い比率差':
    additional_metrics = ['ROC-AUC', 'F1', 'MCC']
    st.info(f'`{class_ratio}です。データ量と比較してクラス不均衡がみられます。評価指標ROC-AUC,F1,MCCを考慮してください。`')
else:
    additional_metrics = []
    st.write(f'`{class_ratio}です。(データ量と比較してクラス不均衡が気になる場合評価指標ROC-AUC,F1,MCCを考慮してください。)`')

クラス不均衡の場合、ROC-AUC,F1,MCCが使用される。

  • ROC-AUC
    • ROC曲線は、異なるしきい値での真陽性率と偽陽性率をプロットしたもの。AUCはこの曲線の下の面積を表し、モデルの全体的な識別能力を示す。
    • 陽性と陰性の比率が極端に異なる場合でも、モデルが陽性クラスをどれだけ正確に識別できるかを評価できる。
  • F1
    • F1スコアはPrecision(適合率)とRecall(再現率)の調和平均で、両者のバランスを取る指標。
    • クラス不均衡があるとき、Accuracy(正確率)は高い割合のクラスに偏った評価をしやすくなるが、F1スコアは特に陽性クラスの予測性能に重点を置いており、少数クラス(陽性クラス)が無視されるのを防ぐことができる。
  • MCC
    • MCCは二値分類の予測性能を全体的に評価する指標。
    • MCCはクラス不均衡に対して非常に敏感である。クラスが大きく偏っている場合に、より信頼性の高い評価を提供する。
    • 正解率やF1スコアだけでは測れない、全体的な分類の精度を評価するのに優れている。

評価指標の選択

purpose1 = '全体的な正解率を評価したい'
purpose2 = 'モデルの全体的なバランスを評価したい'
purpose3 = '偽陽性を減らしたい'
purpose4 = '偽陰性を減らしたい'
purpose5 = 'モデルの確率予測の正確さを評価したい'

classification_select = st.selectbox(
    '何を目的としますか?',
    [purpose1, purpose2, purpose5, purpose3, purpose4,]
)
st.write('おすすめの評価指標')
if classification_select == purpose1:
    eval_metric_default = ['Accuracy']
elif classification_select == purpose2:
    eval_metric_default = ['F1', 'MCC']
elif classification_select == purpose3:
    eval_metric_default = ['Precision']
elif classification_select == purpose4:
    eval_metric_default = ['Recall']
else:
    eval_metric_default = ['LogLoss']

# 追加の評価指標を加える
eval_metric_default.extend(additional_metrics)

セレクト[何を目的としますか?]でユーザーが目的とする項目から評価指標を自動で選べるようにした。
また、自動でクラス不均衡を確認して忠告を出し、おすすめ評価指標にROC-AUC,F1,MCCが追加されるよう設定した。


二値分類と多クラス分類

if len(unique_classes) == 2:
    # 2クラスの場合、陽性ラベルを選択させる
    positive_label = st.radio('陽性ラベルを選んでください', options=unique_classes, key='positive_label_radio')
    negative_label = [cls for cls in unique_classes if cls != positive_label][0]
    
    # ラベルエンコーダを作成して保存
    le = LabelEncoder()
    le.fit([negative_label, positive_label])
    target_label_encoders[target] = le
    # ラベルのエンコード
    df_sample[target] = df_sample[target].map({negative_label: 0, positive_label: 1})
    
elif len(unique_classes) > 2:
    st.write('多クラス分類のため自動でラベルを割り当てます。')

else:
    st.warning("クラスが1つしかありません。")

二値分類と多クラス分類でROC-AUCや後のレポート作成に影響するため条件分けをして処理をする。
二値の場合はユーザーに陽性ラベルを選択してもらいラベルエンコード、多クラスの場合は自動でラベルを振り分ける。

LGBMモデル構造

# 選択されたデータの準備
df_sample = df_sample.sample(frac=data_usage_rate, random_state=42)

# カテゴリカルデータのエンコード(target以外)
label_encoders = {}
for col in df_sample.columns:
    if df_sample[col].dtype == 'object' and col != target:
        le = LabelEncoder()
        df_sample[col] = le.fit_transform(df_sample[col])
        label_encoders[col] = le
                    
# 特徴量とターゲットを準備
X = df_sample[model_feature_select]
y = df_sample[target]

# データの分割
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=data_usage_testrate, random_state=42)

# モデルの訓練
model = lgb.LGBMClassifier()  # 分類タスクに対応するモデル
model.fit(X_train, y_train)
y_pred = model.predict(X_test)

ユーザーの選択したデータ割合でサンプリングし、二値分類の場合はここで目的変数以外の文字列をラベルエンコードする。
X,yに目的変数と説明変数を入れ、ユーザーの選択した検証データ割合でそれぞれ学習用データと検証用データに分割する。
素のLGBMに学習用データを投げ込んでpredで学習させる。

その後検証データでそれぞれの評価指標で評価をする。
ROC-AUCはpredict_probaで予測し、二値分類と多クラス分類で条件を分けている。


ユーザー選択の評価指標の結果

def aprf(evaluation):
    if 0.9 <= evaluation:
        st.markdown('`90%以上は非常に良い性能を示しています。`')
    elif 0.7 < evaluation < 0.9:
        st.markdown('`70%~90%は比較的良い性能を示しています。`')
    else:
        st.markdown('`70%以下はあまり良い性能とは言えません。`')

Accuracy,Precision,Recall,F1,MCC,ROC-AUCについては一般的な評価として70%以下、70~90%、90%以上で非常に良い、比較的良い、あまりいいとは言えないに分けた。
LogLossについては個別に0.5以上、0.5~0.1,0.1以下であまりいいとは言えない、比較的良い、非常に良いと分けた。


レポート



モデル概要
使用データ、LGBMの学習過程を簡単に説明する。

評価結果
全ての評価結果を並べた後、混合行列と予測確立のグラフ、ROC曲線を描画する。

混合行列

cm = confusion_matrix(y_test, y_pred)
plt.figure(figsize=(8, 6))  # サイズを指定
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=unique_classes, yticklabels=unique_classes)
plt.xlabel('Predicted labels')
plt.ylabel('True labels')
plt.title('Confusion Matrix')
st.pyplot(plt)
plt.clf()

分類タスクの評価は混合行列から始まるので採用した。

予測確立グラフ

# 二値分類の場合のヒストグラム
if len(unique_classes) == 2:
    plt.figure(figsize=(10, 6))
    plt.hist(y_proba, bins=20, color='skyblue', edgecolor='black')
    plt.xlabel('Predicted Probability')
    plt.ylabel('Frequency')
    plt.title('Histogram of Predicted Probabilities')
    st.pyplot(plt)
    plt.clf()
#多クラス分類の場合のバイオリンプロット
if len(unique_classes) > 2:
    plt.figure(figsize=(10, 6))
    df_proba = pd.DataFrame(y_proba, columns=[f'{cls}' for cls in unique_classes])
    df_proba_melted = df_proba.melt(var_name='Class', value_name='Predicted Probability')
    
    sns.violinplot(x='Class', y='Predicted Probability', data=df_proba_melted, palette='viridis')
    
    plt.xlabel('Class')
    plt.xticks(rotation=90)
    plt.ylabel('Predicted Probability')
    plt.title('Violin Plot of Predicted Probabilities by Class')
    st.pyplot(plt)
    plt.clf()

ラベルがどのくらいの確率で分けられているかを知りたいためグラフを作成する。
二値分類は単純なヒストグラム、多クラスの場合確率密度プロットも考慮したがクラスが多いと重なりすぎて見にくいためバイオリンプロットを採用した。

ROC曲線
モデルの識別能力の把握、クラス不均衡のある場合を考慮して作成。

特徴量重要度

feature_importances = model.feature_importances_
importance_df = pd.DataFrame({
    'Feature': model_feature_select,
    'Importance': feature_importances
}).sort_values(by='Importance', ascending=False)

st.write("特徴量重要度")
plt.figure(figsize=(10, 6))
plt.barh(importance_df['Feature'], importance_df['Importance'], color='skyblue')
plt.xlabel('Importance')
plt.ylabel('Feature')
plt.title('Feature Importance')
plt.gca().invert_yaxis()  # 降順に並べる
st.pyplot(plt)
plt.clf()


単純なLGBMでの学習まで盛り込んだのは、評価のベースラインを知りたいというのもあるが、特徴量重要度を取得したかったからという理由もある。

プラスで相関係数TOP5も合わせて、深堀する特徴量を検討した後、特徴量エンジニアリングを行っていくことを想定している。

アプリ評価

テスト方法

ローカル環境での初期は、KagglePlayGroundの毒キノコの分類のデータで動作の確認をしていた。
毒キノコのデータで大枠ができた段階で、タイタニックのデータをアップロードして二値分類タスクの動作確認を行った。
また、洪水マルチクラス予測で多クラス分類タスクの動作確認を行った。

結果と改善点

session_stateの設定
session_stateの初期設定ができておらず、リロードするとデータがありませんのエラーがでたり、デフォルトデータとアップロードデータが混在するようになった。

session_stateの初期化によりデータがありません状態を防ぐ。
#コードの最初に持ってくる
keys_defaults = [
    ('df_train', None),
    ('df_test', None),
    ('target', None),
    ('features', None),
    ('default_df_train', None),
    ('default_df_test', None),
    ('default_target', None),
    ('default_features', None)
]

# ループを使ってセッションステートを初期化
for key, default_value in keys_defaults:
    if key not in st.session_state:
        st.session_state[key] = default_value
データセットの設定を整理することで、デフォルトとアップロードのデータの混在を防ぐ。
if st.session_state.df_train is not None and st.session_state.df_test is not None:
    df_train = st.session_state.df_train
    df_test = st.session_state.df_test
    target = st.session_state.target
    features = st.session_state.features
else:
    df_train = pd.read_csv('dataset/titanic_data/titanic_train.csv')
    df_test = pd.read_csv('dataset/titanic_data/titanic_test.csv')
    target = 'Survived'
    df_features = df_train.drop(['PassengerId', 'Survived'], axis=1)
    features = df_features.columns.tolist()
    st.session_state.default_df_train = df_train
    st.session_state.default_df_test = df_test
    st.session_state.default_target = target
    st.session_state.default_features = features
各ページ先頭に`dataset`関数を置いて、使用中データと目的変数の確認ができるようにした。
# dataset関数
def dataset():
    if st.session_state.df_train is not None:
        st.sidebar.info('ユーザーのTrainDataを使用中')
    else:
        st.sidebar.warning('デフォルトのTrainDataを使用中')

    if st.session_state.df_test is not None:
        st.sidebar.info('ユーザーのTestDataを使用中')
    else:
        st.sidebar.warning("デフォルトのTestDataを使用中")

    if st.session_state.target is not None:
        st.sidebar.info(f'目的変数: {st.session_state.target}')
    else:
        st.sidebar.warning(f'目的変数: "Survived"')
        
    st.sidebar.markdown('---')

以上で解決できた。

多クラス分類への対応
モデルの設定部分で、二値クラスでは陽性クラスを選べるようにしている。
多クラス分類でも各クラスのエンコーディングナンバーを設定できるようにしていたが、洪水データでは目的変数が6クラスあるためかなりユーザーの負担になると感じた。
そのため、多クラス分類では自動でラベルを割り当てるだけにとどめた。

分類タスクと回帰タスク
当初は分類と回帰の両方をひとつのアプリでやる予定だったが、モデル構築の際に目的変数が通常分類なら質的変数、回帰なら量的変数になるという当たり前のことを失念していたためいろいろと問題が起きた。各特徴量の目的変数との関係グラフなど変更していかなければいけないことにきづき、分類verと回帰verでアプリを分けることにここで決めた。

レポート部分
レポート部分はユーザーが選択した評価指標のみを使用して分析まで少し入れ込むつもりであったが、他の評価指標と比べたいこともあったと思い返した。だが、評価指標を複数選択すると同じ文章が重複してしまうことが気持ち悪いと感じた。
アプリの目的はあくまでEDAの手助けということに立ち返り、ユーザーの選択した評価指標を強調しつつ、すべてを網羅的に確認できるにとどめた。

時系列変数の取り扱い
今まであまり時系列変数(datatimeなど)を見る機会がなかった(契約日数や月・季節などで確認できていたため)。時系列データに関してどういったグラフが有効か悩むところであったため、変数選択から時系列を一時保留にしてある。

デフォルトデータの変更
前述のとおり、開発初期は毒キノコのデータを使用していたが、データが大きいためサンプリングしてもモデルの学習に少し時間がかかり、アプリのお試しにしては長い気がしていた。タイタニックの方で学習時間が短縮でき、馴染み深さもあるかと思いデフォルトデータをタイタニックに変更した。

レイアウトの変更
目的変数や説明変数をページが切り替わる毎に設定するようにしていたが、ユーザーが設定する部分を最小限にしたかったため[データの準備]ページの中で目的変数の設定と、いらない説明変数(idやPassengerIDなど)を省けるようにした。
また、データを一通り見た後でモデル構築の設定をする際、省く特徴量がでてきたときに備えて[モデル構築]部分で再度特徴量を設定できるようにした。
他には、特徴量同士の関係性ページがあったのだが、[データの概要]でヒートマップとペアプロットを見れるようにしたためこのページを削除した。

ディレクトリ構造の変更
1200行を超えて修正がやりにくくなったため、functionファイルを作成してグラフなどの単純な関数を収納した。

結論

総括
分類タスクの最初のEDAとしては基本的なことを網羅できていると感じる。
現役のデータサイエンティストの方に見てもらったところ、アプリとしてよく出来ているという評価をいただいた。

課題・今後の展望
盆休みの1週間で作るという目標は分類タスクverでは達成できたが、当初の設計の回帰タスクverまでは作れなかった。分類の評価指標の復習に時間がかかった。
また、時系列データも扱えるようにしていく必要がある。
Kaggleのテーブルデータのみで開発・テストを行ったため、他のデータにも対応できるか検証が必要である。
このアプリの使用により、まずはKaggleのPlayGroundで上位10%になれるよう機械学習について学習していきたい。
欠損値・異常値・変数尺度判定モデルなんてものを作っても面白いかもしれない。

参考URL

https://zenn.dev/paxdare_labo/articles/0ae79951afed45
AIアプリの土台作りについて学べる。

https://qiita.com/sypn/items/80962d84126be4092d3c
streamlitで使えるサンプルコードが掲載されており参考になった。

使用データセット(主にKaggleのPlayGround)
Titanic - Machine Learning from Disaster(タイタニックの生存予測)
みんな大好きタイタニック
Binary Prediction of Poisonous Mushrooms(毒キノコの分類)
開発時期に開催していたコンペ
Multi-Class Prediction of Obesity Risk(洪水マルチクラス予測)
多クラス分類テスト用に使用

Discussion