見出し画像

#54 Pythonで始める正規性検定、チャレンジ AI×100業務(製造業)

データ分析を進める上で、「データが正規分布に従っているか?」の確認は非常に重要です。多くの統計手法が正規性を前提としているため、この確認を怠ると分析結果の信頼性が揺らぐこともあります。

今日は、正規性検定を実行できるよう、代表的な検定手法(シャピロ・ウィルク検定など)にてアプリを作っていきます。


それでは、スタートです。
*執筆者は以下を参照してください。


使ったツール・準備したこと

  • Visual Studio Code環境下でのPython

  • Gemini 2.5 Pro (experimenntal)

やってみたこと|コード・ステップ紹介

実行した内容の簡単な流れ

Gemini 2.5 Pro (experimenntal)にて以下をプロンプトから始めました。

添付の画面のイメージでpythonのコードを生成してください。
・正規性の検定
・インタラクティブな統計アプリとして、検定結果にコメントしてください。
・csvファイルを選択する形式です
・csvファイルは因子が各列にあり、先頭行が因子名です。

添付の画面は#53で作成した散布図のGUIの画像を添付しています。

以下は作成されたアプリの画像です。

■ 正規性検定アプリ画面 *3種類の検定を用意しています。

画面では、Shapiro- WilK検定を実施した結果です。

どの検定を実施したらいいか、「検定ガイド」を設けました。

■ 検定ガイドの内容

デフォルトは「Shapiro- WilK検定」にしています。

■  Q-Qプロット
*上の画面ではありませんが、「検定ガイド」の横に実行ボタンを設置しました。

ヒストグラムは統計量と併記されていた方が、データの様相が把握しやすいのでいいですね。

今後の展開・アイデア

次回取り組みたいこと

・内部監査の備え

■ 今日のコード

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import pandas as pd
from scipy.stats import shapiro, kstest, anderson, probplot # probplot をインポート
import numpy as np
import os
import matplotlib.pyplot as plt # matplotlib をインポート
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk # Tkinter連携用

class NormalityTestApp:
    def __init__(self, master):
        self.master = master
        self.master.title("正規性検定アプリ")
        self.master.geometry("850x480") # メインウィンドウサイズ

        self.df = None
        self.file_path = tk.StringVar(value="ファイル未選択")
        self.selected_column = tk.StringVar()
        self.selected_test = tk.StringVar(value="Shapiro-Wilk")

        # --- UI要素の作成 ---
        # (変更なし)
        frame_file = ttk.Frame(master, padding="10")
        frame_file.pack(fill=tk.X)
        btn_select = ttk.Button(frame_file, text="CSVファイルを選択", command=self.select_file)
        btn_select.pack(side=tk.LEFT, padx=5)
        lbl_file = ttk.Label(frame_file, textvariable=self.file_path, relief=tk.SUNKEN, padding=5)
        lbl_file.pack(side=tk.LEFT, fill=tk.X, expand=True)

        frame_run = ttk.Frame(master, padding="10")
        frame_run.pack(fill=tk.X)
        lbl_select_col = ttk.Label(frame_run, text="検定列:")
        lbl_select_col.pack(side=tk.LEFT, padx=(0, 5))
        self.combo_columns = ttk.Combobox(frame_run, textvariable=self.selected_column, state="disabled", width=15)
        self.combo_columns.pack(side=tk.LEFT, padx=(0, 10))
        lbl_select_test = ttk.Label(frame_run, text="検定方法:")
        lbl_select_test.pack(side=tk.LEFT, padx=(0, 5))
        self.combo_tests = ttk.Combobox(frame_run, textvariable=self.selected_test,
                                        values=["Shapiro-Wilk", "Kolmogorov-Smirnov", "Anderson-Darling"],
                                        state="readonly", width=18)
        self.combo_tests.pack(side=tk.LEFT, padx=(0, 10))
        btn_run_test = ttk.Button(frame_run, text="検定実行", command=self.run_test)
        btn_run_test.pack(side=tk.LEFT, padx=5)
        btn_guide = ttk.Button(frame_run, text="検定ガイド", command=self.open_guide_window)
        btn_guide.pack(side=tk.LEFT, padx=5)
        btn_qq_plot = ttk.Button(frame_run, text="Q-Qプロットで確認", command=self.show_qq_plot)
        btn_qq_plot.pack(side=tk.LEFT, padx=5)

        frame_result = ttk.Frame(master, padding="10")
        frame_result.pack(fill=tk.BOTH, expand=True)
        lbl_result = ttk.Label(frame_result, text="検定結果:")
        lbl_result.pack(anchor=tk.W)
        self.txt_result = scrolledtext.ScrolledText(frame_result, wrap=tk.WORD, width=70, height=20)
        self.txt_result.pack(fill=tk.BOTH, expand=True, pady=5)
        self.txt_result.configure(state='disabled')

    def open_guide_window(self):
        """検定の選択ガイドを表示する新しいウィンドウを開く"""
        # (変更なし)
        guide_window = tk.Toplevel(self.master)
        guide_window.title("正規性検定 選択ガイド")
        guide_window.geometry("550x450")
        guide_text = """
正規性の検定は、データが正規分布に従っているかどうかを
確認するために行われますが、どの検定を使うかは状況に
よります。以下に主な検定の特徴と使い分けの目安を
示します。

■ シャピロ・ウィルク検定 (Shapiro-Wilk)
   - 特徴: 検出力が高い(正規分布からのずれを
     見つけやすい)とされる代表的な検定。
   - 用途: サンプルサイズが比較的小さい場合(~50)に
     特に推奨されるが、数千程度まで広く使われる。
     迷ったらまずこれを選ぶのが一般的。
   - 注意: サンプルサイズが非常に大きいと、実用上
     問題ないわずかなずれでも「正規分布でない」と
     判定しやすい。

■ コルモゴロフ・スミルノフ検定 (Kolmogorov-Smirnov)
   - 特徴: データ全体の分布形状を比較する。
     正規性検定に使う場合、平均・分散をデータから
     推定するリリフォース補正版が望ましい
     (このアプリのK-S検定は近似版)。
   - 用途: サンプルサイズが大きい場合に用いられる
     ことがある。
   - 注意: シャピロ・ウィルク検定より検出力は低い傾向。
     特に分布の裾(端)のずれに鈍感。

■ アンダーソン・ダーリング検定 (Anderson-Darling)
   - 特徴: K-S検定を改良し、特に分布の裾(端)での
     ずれを検出しやすい。
   - 用途: 分布の裾の形状が重要な場合に有効。
     シャピロ・ウィルクと同等以上の検出力を持つとされる。

★ 重要な補足 ★
   - グラフの確認:
     検定結果だけでなく、必ずQ-Qプロットや
     ヒストグラムでデータの分布を視覚的に確認して
     ください。特にサンプルサイズが大きい場合は、
     検定で有意差が出てもグラフ上は正規分布と
     みなせることも多いです。

   - 目的と手法:
     利用する統計手法がどの程度正規性を前提として
     いるか(頑健性)も考慮して判断します。
"""
        txt_guide = scrolledtext.ScrolledText(guide_window, wrap=tk.WORD, padx=10, pady=10)
        txt_guide.pack(fill=tk.BOTH, expand=True)
        txt_guide.insert(tk.END, guide_text)
        txt_guide.configure(state='disabled')
        guide_window.transient(self.master)

    def show_qq_plot(self):
        """選択された列のQ-Qプロットを新しいウィンドウに表示する"""
        if self.df is None: messagebox.showwarning("警告", "まずCSVファイルを選択してください。"); return
        col_name = self.selected_column.get()
        if not col_name: messagebox.showwarning("警告", "プロットする列を選択してください。"); return

        try:
            data = self.df[col_name].dropna()
            if not pd.api.types.is_numeric_dtype(data): messagebox.showerror("エラー", f"選択された列 '{col_name}' は数値データではありません。"); return
            if len(data) < 3: messagebox.showerror("エラー", f"列 '{col_name}' の有効データ数が少なすぎます (3未満)。"); return

            plot_window = tk.Toplevel(self.master)
            plot_window.title(f"Q-Qプロット - {col_name}")
            # --- 固定のgeometry設定をコメントアウト ---
            # plot_window.geometry("650x570")
            # ---------------------------------------

            # 日本語フォント設定 (変更なし)
            try: plt.rcParams['font.family'] = 'Meiryo'
            except:
                try: plt.rcParams['font.family'] = 'IPAexGothic'
                except: print("日本語フォントが見つかりません。グラフの文字が化ける可能性があります。"); pass

            # --- Figureのサイズを調整 ---
            fig, ax = plt.subplots(figsize=(6, 4.5)) # 少し縦を短く
            # -------------------------

            probplot(data, dist="norm", plot=ax)
            ax.set_title(f"正規Q-Qプロット: {col_name}")
            ax.set_xlabel("理論分位数 (Theoretical Quantiles)")
            ax.set_ylabel("標本分位数 (Sample Quantiles)")
            fig.tight_layout() # レイアウト調整

            # ツールバーとキャンバスの配置 (変更なし)
            canvas = FigureCanvasTkAgg(fig, master=plot_window)
            canvas_widget = canvas.get_tk_widget()
            canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
            toolbar = NavigationToolbar2Tk(canvas, plot_window)
            toolbar.update()
            toolbar.pack(side=tk.BOTTOM, fill=tk.X)

            # ウィンドウサイズを内容に合わせて調整するよう促す
            plot_window.update_idletasks() # 保留中のタスクを実行
            # plot_window.geometry(f"{plot_window.winfo_reqwidth()}x{plot_window.winfo_reqheight()}") # 必要ならサイズを固定

            plot_window.transient(self.master)

        except KeyError: messagebox.showerror("エラー", f"内部エラー: 列 '{col_name}' がデータフレームに存在しません。")
        except Exception as e:
            messagebox.showerror("エラー", f"Q-Qプロット描画中に予期せぬエラーが発生しました:\n{e}")
            try: plot_window.destroy()
            except: pass


    def select_file(self):
        """CSVファイルを選択し、列名を読み込む"""
        # (変更なし)
        fTyp = [("CSVファイル", "*.csv"), ("すべてのファイル", "*.*")]
        initial_dir = os.path.expanduser("~")
        file = filedialog.askopenfilename(filetypes=fTyp, initialdir=initial_dir)
        if file:
            try:
                self.df = pd.read_csv(file, encoding='cp932')
                if self.df.empty or len(self.df.columns) == 0: messagebox.showerror("エラー", "CSVファイルが空か、列名がありません。"); self.reset_app_state(); return
                self.file_path.set(file)
                column_list = list(self.df.columns)
                self.combo_columns['values'] = column_list
                if column_list: self.selected_column.set(column_list[0]); self.combo_columns.current(0); self.combo_columns.configure(state="readonly")
                else: self.combo_columns.configure(state="disabled")
                self.clear_results()
                messagebox.showinfo("情報", f"ファイルを読み込みました。\n列数: {len(self.df.columns)}, 行数: {len(self.df)}")
            except UnicodeDecodeError: messagebox.showerror("エラー", f"ファイルの読み込みに失敗しました (文字コードエラー)。\nファイルの文字コードを確認してください (例: Shift_JIS, UTF-8など)。\n'cp932'で読み込めませんでした。"); self.reset_app_state()
            except Exception as e: messagebox.showerror("エラー", f"ファイルの読み込み中に予期せぬエラーが発生しました:\n{e}"); self.reset_app_state()
        else: pass

    def reset_app_state(self):
        """アプリの状態を初期状態に戻す"""
        # (変更なし)
        self.df = None; self.file_path.set("ファイル未選択"); self.selected_column.set("")
        self.combo_columns['values'] = []; self.combo_columns.set(''); self.combo_columns.configure(state="disabled")
        self.clear_results()

    def run_test(self):
        """選択された列と方法で正規性検定を実行し、結果を表示する"""
        # (変更なし)
        if self.df is None: messagebox.showwarning("警告", "まずCSVファイルを選択してください。"); return
        col_name = self.selected_column.get(); test_method = self.selected_test.get()
        if not col_name: messagebox.showwarning("警告", "検定する列を選択してください。"); return
        try:
            data = self.df[col_name].dropna()
            if not pd.api.types.is_numeric_dtype(data): messagebox.showerror("エラー", f"選択された列 '{col_name}' は数値データではありません。"); return
            if len(data) < 3: messagebox.showerror("エラー", f"列 '{col_name}' の有効データ数が少なすぎます (3未満)。\n有効データ数: {len(data)}"); return
            result_text = f"--- {test_method} 正規性検定 ---\n\n検定対象列: {col_name}\n有効サンプル数: {len(data)}\n\n-------------------------------------\n"
            alpha = 0.05
            recommendation = "\n\n補足: グラフ(Q-Qプロットやヒストグラム)での視覚的な確認も併せて行うことをお勧めします。"
            additional_recommendation_if_rejected = "\n      他の検定方法での確認も有効な場合があります。"
            if test_method == "Shapiro-Wilk":
                if len(data) < 3: messagebox.showerror("エラー", "シャピロ・ウィルク検定には少なくとも3つのサンプルが必要です。"); return
                stat, p_value = shapiro(data)
                result_text += f"統計量 (W): {stat:.4f}\n" + f"p値: {p_value:.4f}\n" + "-------------------------------------\n\n"
                if p_value > alpha: interpretation = f"p値 > {alpha} (有意水準) ⇒ 帰無仮説は棄却されません。\n結論: データは正規分布に従うと見なせます。" + recommendation
                else: interpretation = f"p値 <= {alpha} (有意水準) ⇒ 帰無仮説は棄却されます。\n結論: データは正規分布に従うとは言えません。" + recommendation + additional_recommendation_if_rejected
                result_text += f"解釈 (α={alpha}):\n{interpretation}"
            elif test_method == "Kolmogorov-Smirnov":
                if np.std(data, ddof=1) == 0: messagebox.showerror("エラー", f"列 '{col_name}' のデータ値が全て同じです。検定を実行できません。"); return
                standardized_data = (data - np.mean(data)) / np.std(data, ddof=1)
                stat, p_value = kstest(standardized_data, 'norm')
                result_text += f"統計量 (D): {stat:.4f}\n" + f"p値: {p_value:.4f}\n" + "-------------------------------------\n\n"
                ks_note = "\n\n(注: このK-S検定はリリフォース検定の近似であり、精度は劣る可能性があります)"
                if p_value > alpha: interpretation = f"p値 > {alpha} (有意水準) ⇒ 帰無仮説は棄却されません。\n結論: データは正規分布に従うと見なせます。" + recommendation
                else: interpretation = f"p値 <= {alpha} (有意水準) ⇒ 帰無仮説は棄却されます。\n結論: データは正規分布に従うとは言えません。" + recommendation + additional_recommendation_if_rejected
                result_text += f"解釈 (α={alpha}):\n{interpretation}" + ks_note
            elif test_method == "Anderson-Darling":
                if len(data) < 3: messagebox.showerror("エラー", "アンダーソン・ダーリング検定には少なくとも3つのサンプルが必要です。"); return
                result = anderson(data, dist='norm')
                stat = result.statistic; crit_vals = result.critical_values; sig_levels = result.significance_level / 100.0
                result_text += f"統計量 (A^2): {stat:.4f}\n\n" + "臨界値:\n"; [result_text := result_text + f"  有意水準 {level*100:.1f}% : {crit:.4f}\n" for level, crit in zip(sig_levels, crit_vals)]
                result_text += "-------------------------------------\n\n"
                idx_5_percent = np.where(np.isclose(sig_levels, 0.05))[0]
                if len(idx_5_percent) > 0:
                    crit_5 = crit_vals[idx_5_percent[0]]
                    result_text += f"解釈 (α=0.05の場合):\n"
                    if stat > crit_5: interpretation = f"統計量 ({stat:.4f}) > 臨界値 ({crit_5:.4f}) ⇒ 帰無仮説は棄却されます。\n結論: データは正規分布に従うとは言えません。" + recommendation + additional_recommendation_if_rejected
                    else: interpretation = f"統計量 ({stat:.4f}) <= 臨界値 ({crit_5:.4f}) ⇒ 帰無仮説は棄却されません。\n結論: データは正規分布に従うと見なせます。" + recommendation
                    result_text += interpretation
                else: result_text += "解釈 (α=0.05): 5%の臨界値が見つかりませんでした。" + recommendation
            self.display_results(result_text)
        except KeyError: messagebox.showerror("エラー", f"内部エラー: 列 '{col_name}' がデータフレームに存在しません。")
        except Exception as e: messagebox.showerror("エラー", f"検定中に予期せぬエラーが発生しました:\n{e}")

    def display_results(self, result_text):
        """結果をテキストエリアに表示する"""
        # (変更なし)
        self.txt_result.configure(state='normal'); self.txt_result.delete('1.0', tk.END)
        self.txt_result.insert(tk.END, result_text); self.txt_result.configure(state='disabled')

    def clear_results(self):
        """結果表示エリアをクリアする"""
        # (変更なし)
        self.txt_result.configure(state='normal'); self.txt_result.delete('1.0', tk.END)
        self.txt_result.configure(state='disabled')

if __name__ == "__main__":
    try: from ctypes import windll; windll.shcore.SetProcessDpiAwareness(1)
    except ImportError: pass
    root = tk.Tk()
    # (日本語フォント設定部分は変更なし)
    try:
        plt.rcParams['font.family'] = 'Meiryo'
        fig, ax = plt.subplots(); ax.set_title("テスト"); plt.close(fig)
        print("グラフ用に日本語フォント 'Meiryo' を設定しました。")
    except Exception as e_meiryo:
        print(f"フォント 'Meiryo' が見つかりません ({e_meiryo})。他のフォントを試します...")
        try:
            plt.rcParams['font.family'] = 'IPAexGothic'
            fig, ax = plt.subplots(); ax.set_title("テスト"); plt.close(fig)
            print("グラフ用に日本語フォント 'IPAexGothic' を設定しました。")
        except Exception as e_ipa:
            print(f"フォント 'IPAexGothic' も見つかりません ({e_ipa})。")
            print("警告: グラフの日本語が文字化けする可能性があります。")
            pass
    app = NormalityTestApp(root)
    root.mainloop()

いいなと思ったら応援しよう!