#54 Pythonで始める正規性検定、チャレンジ AI×100業務(製造業)
データ分析を進める上で、「データが正規分布に従っているか?」の確認は非常に重要です。多くの統計手法が正規性を前提としているため、この確認を怠ると分析結果の信頼性が揺らぐこともあります。
今日は、正規性検定を実行できるよう、代表的な検定手法(シャピロ・ウィルク検定など)にてアプリを作っていきます。
それでは、スタートです。
*執筆者は以下を参照してください。
使ったツール・準備したこと
Visual Studio Code環境下でのPython
Gemini 2.5 Pro (experimenntal)
やってみたこと|コード・ステップ紹介
実行した内容の簡単な流れ
Gemini 2.5 Pro (experimenntal)にて以下をプロンプトから始めました。
添付の画面のイメージでpythonのコードを生成してください。
・正規性の検定
・インタラクティブな統計アプリとして、検定結果にコメントしてください。
・csvファイルを選択する形式です
・csvファイルは因子が各列にあり、先頭行が因子名です。
以下は作成されたアプリの画像です。
■ 正規性検定アプリ画面 *3種類の検定を用意しています。

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

■ 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()
