File size: 13,849 Bytes
f5327a7
 
cf1066a
f5327a7
 
cf1066a
b8b8a5d
 
f5327a7
 
 
 
 
 
 
 
 
 
cf1066a
 
 
 
 
 
 
 
b8b8a5d
f5327a7
 
 
cf1066a
f5327a7
cf1066a
 
 
 
 
b8b8a5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf1066a
b8b8a5d
 
 
cf1066a
 
 
 
 
 
 
b8b8a5d
 
 
 
 
 
 
 
 
 
 
 
 
 
cf1066a
 
 
b8b8a5d
 
cf1066a
b8b8a5d
cf1066a
b8b8a5d
cf1066a
 
 
 
 
 
 
b8b8a5d
 
f5327a7
b8b8a5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5327a7
 
 
 
 
 
 
 
cf1066a
 
f5327a7
b8b8a5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf1066a
f5327a7
cf1066a
f5327a7
b8b8a5d
 
 
 
 
cf1066a
f5327a7
b8b8a5d
 
f5327a7
 
cf1066a
 
b8b8a5d
 
 
 
f5327a7
 
cf1066a
 
b8b8a5d
f5327a7
b8b8a5d
cf1066a
 
 
 
 
 
f5327a7
 
b8b8a5d
 
f5327a7
cf1066a
f5327a7
 
 
 
 
b8b8a5d
f5327a7
 
cf1066a
 
b8b8a5d
f5327a7
b8b8a5d
 
f5327a7
 
 
 
 
 
b8b8a5d
f5327a7
 
 
 
 
 
 
 
 
 
b8b8a5d
 
 
 
 
f5327a7
 
 
 
b8b8a5d
cf1066a
 
f5327a7
 
 
b8b8a5d
f5327a7
 
 
 
 
 
 
 
 
 
cf1066a
f5327a7
 
b8b8a5d
f5327a7
 
b8b8a5d
f5327a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b8b8a5d
f5327a7
 
 
 
 
 
799f380
f5327a7
 
 
b8b8a5d
cf1066a
6290da0
cf1066a
 
6aae367
cf1066a
 
 
 
f5327a7
 
 
cf1066a
f5327a7
 
 
 
 
 
cf1066a
f5327a7
 
 
cf1066a
 
f5327a7
 
 
 
 
c6ad41b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# app.py
# ---- 必要ライブラリ ----
# pip install gradio pandas numpy matplotlib scipy scikit-learn pillow python-dotenv supabase

import io
import os
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import ttest_ind, pointbiserialr
from sklearn.linear_model import LogisticRegression
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
import gradio as gr
from PIL import Image
from dotenv import load_dotenv

# Supabase
try:
    from supabase import create_client  # supabase-py v2
except Exception:
    # 旧API互換(v1 をお使いの場合は import supabase; supabase.create_client を利用)
    create_client = None
    import supabase as supabase_v1  # type: ignore

plt.switch_backend("Agg")  # サーバー実行向け

# 日本語フォントの設定(環境に応じて使えるものを優先)
import matplotlib
matplotlib.rcParams['font.family'] = ['DejaVu Sans', 'Hiragino Sans', 'Yu Gothic', 'Meiryo',
                                      'Takao', 'IPAexGothic', 'IPAPGothic', 'VL PGothic', 'Noto Sans CJK JP']

# .env 読み込み
load_dotenv()
SUPABASE_URL: Optional[str] = os.environ.get("SUPABASE_URL")
SUPABASE_KEY: Optional[str] = os.environ.get("SUPABASE_KEY")
TABLE_NAME: str = "estimated_cause_mocdata"  # ご指定のテーブル名


def _get_supabase_client() -> Any:
    """
    Supabase クライアントを生成して返す(v2 を優先、なければ v1 にフォールバック)。

    環境変数:
        SUPABASE_URL (str): Supabase の URL
        SUPABASE_KEY (str): Supabase の API キー

    Raises:
        RuntimeError: 必要な環境変数が未設定、またはクライアント生成に失敗した場合。

    Returns:
        Any: Supabase クライアントオブジェクト(v2 または v1)。
    """
    if not SUPABASE_URL or not SUPABASE_KEY:
        raise RuntimeError("環境変数 SUPABASE_URL または SUPABASE_KEY が設定されていません。")
    if create_client is not None:
        return create_client(SUPABASE_URL, SUPABASE_KEY)
    # v1 fallback
    return supabase_v1.create_client(SUPABASE_URL, SUPABASE_KEY)


def _fetch_supabase_df() -> pd.DataFrame:
    """
    Supabase の指定テーブルから全件取得し、pandas DataFrame に変換して返す。

    テーブル:
        TABLE_NAME: 既定では 'estimated_cause_mocdata'

    Raises:
        RuntimeError: 通信エラー、期待した形でデータが得られない、またはレコードが空の場合。

    Returns:
        pd.DataFrame: 取得したレコードの DataFrame。
    """
    client = _get_supabase_client()
    # v2 と v1 で返り値が異なるため分岐
    try:
        resp: Any = client.table(TABLE_NAME).select("*").execute()
        data: Optional[List[Dict[str, Any]]] = getattr(resp, "data", None) if hasattr(resp, "data") else None
        if data is None:
            # v1 の場合、resp が dict のこともある
            if isinstance(resp, dict) and "data" in resp:
                data = resp["data"]  # type: ignore[index]
        if not data:
            raise RuntimeError(f"Supabase テーブル '{TABLE_NAME}' からデータを取得できませんでした。")
        df = pd.DataFrame(data)
        if df.empty:
            raise RuntimeError(f"Supabase テーブル '{TABLE_NAME}' にレコードがありません。")
        return df
    except Exception as e:
        raise RuntimeError(f"Supabase 取得エラー: {e}") from e


def _boxplot_image(
    a: Union[pd.Series, Sequence[float], np.ndarray],
    b: Union[pd.Series, Sequence[float], np.ndarray],
    feature_name: str
) -> np.ndarray:
    """
    2群(正常/悪化)の値から箱ひげ図を描画し、画像(ndarray)を返す。

    Args:
        a: 正常(0) 群の値(Series, list, ndarray など数値配列)
        b: 悪化(1) 群の値(Series, list, ndarray など数値配列)
        feature_name (str): グラフタイトルや y 軸ラベルに用いる特徴量名

    Returns:
        np.ndarray: 生成した箱ひげ図の画像配列(RGB)。
    """
    fig = plt.figure()
    plt.boxplot([a, b], labels=["正常(0)", "悪化(1)"])
    plt.title(f"Boxplot: {feature_name}")
    plt.ylabel(feature_name)
    buf = io.BytesIO()
    fig.savefig(buf, format="png", bbox_inches="tight")
    plt.close(fig)
    buf.seek(0)
    img = Image.open(buf)  # PIL.Image.Image
    return np.array(img)   # Gallery は numpy 配列でもOK


# 解析結果の返却タプル型(status_md, head_df, label_counts, corr_df, ttest_df, gallery_imgs, coef_df)
AnalysisResult = Tuple[
    str,
    Optional[pd.DataFrame],
    Optional[pd.DataFrame],
    Optional[pd.DataFrame],
    Optional[pd.DataFrame],
    List[Tuple[np.ndarray, str]],
    Optional[pd.DataFrame],
]


def analyze_from_supabase(threshold: Union[int, float], top_k: Union[int, float]) -> AnalysisResult:
    """
    Supabase から水質データを取得し、閾値によるラベリングを行った上で
    相関(point-biserial)、t検定、箱ひげ図、ロジスティック回帰による特徴量重要度を算出する。

    Args:
        threshold (int | float): 目的変数列 'CODcr(S)sin' を悪化(1) と判定する閾値。
        top_k (int | float): ロジスティック回帰の係数上位として表示する特徴量数。

    Returns:
        AnalysisResult: 以下の7要素タプル
            - status_md (str): 解析の要約(マークダウン)
            - head_df (pd.DataFrame | None): 先頭行のプレビュー
            - label_counts (pd.DataFrame | None): 目的変数の分布
            - corr_df (pd.DataFrame | None): point-biserial 相関表
            - ttest_df (pd.DataFrame | None): t検定の結果表
            - gallery_imgs (List[Tuple[np.ndarray, str]]): 箱ひげ図(画像配列, タイトル)のリスト
            - coef_df (pd.DataFrame | None): ロジスティック回帰の係数ランキング
    """
    # ---- データ取得 ----
    try:
        df = _fetch_supabase_df()
    except Exception as e:
        msg = (
            f"❌ データ取得に失敗:{e}\n"
            f"- .env に SUPABASE_URL / SUPABASE_KEY を設定してください\n"
            f"- テーブル名: {TABLE_NAME}"
        )
        return (msg, None, None, None, None, [], None)

    status_md: str = f"**テーブル:** `{TABLE_NAME}`\n\n**データ形状:** {df.shape[0]} 行 × {df.shape[1]} 列\n\n"
    head_df: pd.DataFrame = df.head()

    # ---- 目的変数の作成(悪化=1, 正常=0)----
    target_col = "CODcr(S)sin"
    if target_col not in df.columns:
        return (
            f"❌ 必須列 '{target_col}' が見つかりません。現在の列: {list(df.columns)}",
            None, None, None, None, [], None
        )

    df = df.copy()
    # 数値化(もし文字列が混ざっていても NaN に落とす)
    df[target_col] = pd.to_numeric(df[target_col], errors="coerce")
    df["label"] = (df[target_col] > float(threshold)).astype(int)

    label_counts: pd.DataFrame = df["label"].value_counts(dropna=False).rename_axis("label").to_frame("count")
    status_md += (
        f"**閾値:** {threshold}\n\n"
        f"**目的変数の分布:**\n"
        f"- 正常(0): {int(label_counts.loc[0,'count']) if 0 in label_counts.index else 0}\n"
        f"- 悪化(1): {int(label_counts.loc[1,'count']) if 1 in label_counts.index else 0}\n"
    )

    # ---- 説明変数の準備 ----
    X: pd.DataFrame = df.drop(columns=[target_col, "label"])
    y: pd.Series = df["label"]

    # 既知の小数表記ゆれ対策(あれば)
    if "分散菌槽DO" in X.columns:
        X["分散菌槽DO"] = X["分散菌槽DO"].astype(str).str.replace(",", ".", regex=False)
        X["分散菌槽DO"] = pd.to_numeric(X["分散菌槽DO"], errors="coerce")

    # ---- 相関 (point-biserial) ----
    rows: List[Tuple[str, float, float]] = []
    for col in X.columns:
        try:
            col_num = pd.to_numeric(X[col], errors="coerce")
            r, p = pointbiserialr(y, col_num)
            rows.append((col, float(r), float(p)))
        except Exception:
            rows.append((col, float("nan"), float("nan")))
    corr_df: pd.DataFrame = (
        pd.DataFrame(rows, columns=["feature", "r_pb", "pval"])
        .set_index("feature")
        .sort_values(by="r_pb", key=lambda s: s.abs(), ascending=False)
    )

    # ---- t検定 ----
    ttest_rows: List[Dict[str, Union[str, float, int]]] = []
    for col in X.columns:
        col_num = pd.to_numeric(X[col], errors="coerce")
        a = col_num[y == 0].dropna()
        b = col_num[y == 1].dropna()
        if len(a) > 1 and len(b) > 1:
            try:
                t, p = ttest_ind(a, b, equal_var=False)
                ttest_rows.append(
                    {
                        "feature": col,
                        "mean_normal": float(a.mean()),
                        "mean_bad": float(b.mean()),
                        "pval": float(p),
                        "n_normal": int(len(a)),
                        "n_bad": int(len(b)),
                    }
                )
            except Exception:
                pass
    ttest_df: pd.DataFrame = (
        pd.DataFrame(ttest_rows).set_index("feature").sort_values(by="pval", ascending=True)
        if ttest_rows else pd.DataFrame()
    )

    # ---- 箱ひげ図 (ギャラリー) ----
    gallery_imgs: List[Tuple[np.ndarray, str]] = []
    for col in X.columns:
        col_num = pd.to_numeric(X[col], errors="coerce")
        a_plot = col_num[y == 0].dropna()
        b_plot = col_num[y == 1].dropna()
        if len(a_plot) > 0 and len(b_plot) > 0:
            img_array = _boxplot_image(a_plot, b_plot, col)
            gallery_imgs.append((img_array, f"Boxplot: {col}"))

    # ---- ロジスティック回帰 ----
    X_num = X.apply(pd.to_numeric, errors="coerce").select_dtypes(include=np.number)
    X_num = X_num.loc[:, X_num.notna().sum() > 0]  # すべてNaN列を落とす

    if X_num.shape[1] == 0:
        coef_df: pd.DataFrame = pd.DataFrame(columns=["feature", "coef", "sign", "rank"]).set_index("feature")
        status_md += "\n⚠️ 数値説明変数がありませんでした。係数は空です。"
    else:
        pipe: Pipeline = Pipeline(
            steps=[
                ("imputer", SimpleImputer(strategy="median")),
                ("scaler", StandardScaler()),
                ("clf", LogisticRegression(max_iter=500, class_weight="balanced")),
            ]
        )
        try:
            pipe.fit(X_num, y)
            coef = pd.Series(pipe.named_steps["clf"].coef_[0], index=X_num.columns)
            coef_abs_sorted = coef.abs().sort_values(ascending=False)
            top_features = coef_abs_sorted.head(int(top_k)).index.tolist()

            coef_df = (
                pd.DataFrame(
                    {
                        "coef": coef,
                        "abs_coef": coef.abs(),
                        "sign": np.where(coef > 0, "↑ (増加で悪化リスク上昇)", "↓ (増加で悪化リスク低下)"),
                    }
                )
                .sort_values(by="abs_coef", ascending=False)
                .drop(columns=["abs_coef"])
            )
            coef_df["rank"] = np.arange(1, len(coef_df) + 1)
            status_md += "\n\n**悪化原因の候補(上位{}項目)**:\n- ".format(int(top_k)) + "\n- ".join(
                [f"{f}: 係数={coef[f]:.3f} {('↑' if coef[f]>0 else '↓')}" for f in top_features]
            )
        except Exception as e:
            status_md += f"\n❗ ロジスティック回帰の学習に失敗しました: {e}"
            coef_df = pd.DataFrame(columns=["feature", "coef", "sign", "rank"]).set_index("feature")

    status_md += "\n\n✅ 解析完了:Supabase データに対して ロジスティック回帰 を実行しました。"

    return status_md, head_df, label_counts, corr_df, ttest_df, gallery_imgs, coef_df


# === Gradio UI ===
with gr.Blocks(title="水質悪化原因分析") as demo:
    gr.Markdown(
        """
        # 水質悪化原因分析
        `.env` の **SUPABASE_URL** / **SUPABASE_KEY** を用意し、テーブル **estimated_cause_mocdata** からデータを取得して解析します。  
        解析対象列は **CODcr(S)sin**(悪化=1 判定用)を想定しています。
        """
    )
    with gr.Row():
        threshold_in = gr.Number(value=100, precision=0, label="CODcr(S)sin の閾値(悪化=1)")
        topk_in = gr.Slider(1, 10, value=4, step=1, label="ロジスティック回帰の上位特徴量 数")
    run_btn = gr.Button("Supabase から取得して解析", variant="primary")

    status_out = gr.Markdown()
    head_out = gr.Dataframe(label="データ先頭", interactive=False)
    label_out = gr.Dataframe(label="目的変数の分布", interactive=False)
    corr_out = gr.Dataframe(label="相関 (point-biserial)", interactive=False)
    ttest_out = gr.Dataframe(label="t検定結果(p値の小さい順)", interactive=False)
    gallery_out = gr.Gallery(label="箱ひげ図(正常 vs 悪化)", columns=2, height="auto")
    coef_out = gr.Dataframe(label="ロジスティック回帰 係数ランキング", interactive=False)

    run_btn.click(
        analyze_from_supabase,
        inputs=[threshold_in, topk_in],
        outputs=[status_out, head_out, label_out, corr_out, ttest_out, gallery_out, coef_out],
    )

if __name__ == "__main__":
    # demo.launch(share=True)  # 外部共有したい場合は share=True
    demo.launch(mcp_server=True)