MTeguri commited on
Commit
b8b8a5d
·
1 Parent(s): 4c0852e

Enhance app.py with type annotations, improved error handling, and detailed docstrings for functions. The Supabase client and data fetching methods have been updated for better clarity and functionality, ensuring compatibility with both v1 and v2 APIs.

Browse files
Files changed (1) hide show
  1. app.py +120 -37
app.py CHANGED
@@ -4,6 +4,8 @@
4
 
5
  import io
6
  import os
 
 
7
  import pandas as pd
8
  import numpy as np
9
  import matplotlib.pyplot as plt
@@ -22,7 +24,7 @@ try:
22
  except Exception:
23
  # 旧API互換(v1 をお使いの場合は import supabase; supabase.create_client を利用)
24
  create_client = None
25
- import supabase as supabase_v1
26
 
27
  plt.switch_backend("Agg") # サーバー実行向け
28
 
@@ -33,12 +35,25 @@ matplotlib.rcParams['font.family'] = ['DejaVu Sans', 'Hiragino Sans', 'Yu Gothic
33
 
34
  # .env 読み込み
35
  load_dotenv()
36
- SUPABASE_URL = os.environ.get("SUPABASE_URL")
37
- SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
38
- TABLE_NAME = "estimated_cause_mocdata" # ご指定のテーブル名
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- # Supabase クライアント作成(v2 優先、なければ v1)
41
- def _get_supabase_client():
 
42
  if not SUPABASE_URL or not SUPABASE_KEY:
43
  raise RuntimeError("環境変数 SUPABASE_URL または SUPABASE_KEY が設定されていません。")
44
  if create_client is not None:
@@ -46,16 +61,29 @@ def _get_supabase_client():
46
  # v1 fallback
47
  return supabase_v1.create_client(SUPABASE_URL, SUPABASE_KEY)
48
 
49
- def _fetch_supabase_df():
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  client = _get_supabase_client()
51
  # v2 と v1 で返り値が異なるため分岐
52
  try:
53
- resp = client.table(TABLE_NAME).select("*").execute()
54
- data = getattr(resp, "data", None) if hasattr(resp, "data") else None
55
  if data is None:
56
- # v1 の場合、resp が dict のことも
57
  if isinstance(resp, dict) and "data" in resp:
58
- data = resp["data"]
59
  if not data:
60
  raise RuntimeError(f"Supabase テーブル '{TABLE_NAME}' からデータを取得できませんでした。")
61
  df = pd.DataFrame(data)
@@ -63,9 +91,25 @@ def _fetch_supabase_df():
63
  raise RuntimeError(f"Supabase テーブル '{TABLE_NAME}' にレコードがありません。")
64
  return df
65
  except Exception as e:
66
- raise RuntimeError(f"Supabase 取得エラー: {e}")
 
67
 
68
- def _boxplot_image(a, b, feature_name):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  fig = plt.figure()
70
  plt.boxplot([a, b], labels=["正常(0)", "悪化(1)"])
71
  plt.title(f"Boxplot: {feature_name}")
@@ -77,28 +121,66 @@ def _boxplot_image(a, b, feature_name):
77
  img = Image.open(buf) # PIL.Image.Image
78
  return np.array(img) # Gallery は numpy 配列でもOK
79
 
80
- def analyze_from_supabase(threshold, top_k):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  # ---- データ取得 ----
82
  try:
83
  df = _fetch_supabase_df()
84
  except Exception as e:
85
- msg = f"❌ データ取得に失敗:{e}\n- .env に SUPABASE_URL / SUPABASE_KEY を設定してください\n- テーブル名: {TABLE_NAME}"
 
 
 
 
86
  return (msg, None, None, None, None, [], None)
87
 
88
- status_md = f"**テーブル:** `{TABLE_NAME}`\n\n**データ形状:** {df.shape[0]} 行 × {df.shape[1]} 列\n\n"
89
- head_df = df.head()
90
 
91
  # ---- 目的変数の作成(悪化=1, 正常=0)----
92
  target_col = "CODcr(S)sin"
93
  if target_col not in df.columns:
94
- return (f"❌ 必須列 '{target_col}' が見つかりません。現在の列: {list(df.columns)}", None, None, None, None, [], None)
 
 
 
95
 
96
  df = df.copy()
97
  # 数値化(もし文字列が混ざっていても NaN に落とす)
98
  df[target_col] = pd.to_numeric(df[target_col], errors="coerce")
99
- df["label"] = (df[target_col] > threshold).astype(int)
100
 
101
- label_counts = df["label"].value_counts(dropna=False).rename_axis("label").to_frame("count")
102
  status_md += (
103
  f"**閾値:** {threshold}\n\n"
104
  f"**目的変数の分布:**\n"
@@ -107,8 +189,8 @@ def analyze_from_supabase(threshold, top_k):
107
  )
108
 
109
  # ---- 説明変数の準備 ----
110
- X = df.drop(columns=[target_col, "label"])
111
- y = df["label"]
112
 
113
  # 既知の小数表記ゆれ対策(あれば)
114
  if "分散菌槽DO" in X.columns:
@@ -116,22 +198,22 @@ def analyze_from_supabase(threshold, top_k):
116
  X["分散菌槽DO"] = pd.to_numeric(X["分散菌槽DO"], errors="coerce")
117
 
118
  # ---- 相関 (point-biserial) ----
119
- rows = []
120
  for col in X.columns:
121
  try:
122
  col_num = pd.to_numeric(X[col], errors="coerce")
123
  r, p = pointbiserialr(y, col_num)
124
- rows.append((col, r, p))
125
  except Exception:
126
- rows.append((col, np.nan, np.nan))
127
- corr_df = (
128
  pd.DataFrame(rows, columns=["feature", "r_pb", "pval"])
129
  .set_index("feature")
130
  .sort_values(by="r_pb", key=lambda s: s.abs(), ascending=False)
131
  )
132
 
133
  # ---- t検定 ----
134
- ttest_rows = []
135
  for col in X.columns:
136
  col_num = pd.to_numeric(X[col], errors="coerce")
137
  a = col_num[y == 0].dropna()
@@ -142,22 +224,22 @@ def analyze_from_supabase(threshold, top_k):
142
  ttest_rows.append(
143
  {
144
  "feature": col,
145
- "mean_normal": a.mean(),
146
- "mean_bad": b.mean(),
147
- "pval": p,
148
- "n_normal": len(a),
149
- "n_bad": len(b),
150
  }
151
  )
152
  except Exception:
153
  pass
154
- ttest_df = (
155
  pd.DataFrame(ttest_rows).set_index("feature").sort_values(by="pval", ascending=True)
156
  if ttest_rows else pd.DataFrame()
157
  )
158
 
159
  # ---- 箱ひげ図 (ギャラリー) ----
160
- gallery_imgs = []
161
  for col in X.columns:
162
  col_num = pd.to_numeric(X[col], errors="coerce")
163
  a_plot = col_num[y == 0].dropna()
@@ -171,10 +253,10 @@ def analyze_from_supabase(threshold, top_k):
171
  X_num = X_num.loc[:, X_num.notna().sum() > 0] # すべてNaN列を落とす
172
 
173
  if X_num.shape[1] == 0:
174
- coef_df = pd.DataFrame(columns=["feature", "coef", "sign", "rank"]).set_index("feature")
175
  status_md += "\n⚠️ 数値説明変数がありませんでした。係数は空です。"
176
  else:
177
- pipe = Pipeline(
178
  steps=[
179
  ("imputer", SimpleImputer(strategy="median")),
180
  ("scaler", StandardScaler()),
@@ -199,7 +281,7 @@ def analyze_from_supabase(threshold, top_k):
199
  .drop(columns=["abs_coef"])
200
  )
201
  coef_df["rank"] = np.arange(1, len(coef_df) + 1)
202
- status_md += "\n\n**悪化原因の候補(上位{}項目)**:\n- ".format(top_k) + "\n- ".join(
203
  [f"{f}: 係数={coef[f]:.3f} {('↑' if coef[f]>0 else '↓')}" for f in top_features]
204
  )
205
  except Exception as e:
@@ -210,6 +292,7 @@ def analyze_from_supabase(threshold, top_k):
210
 
211
  return status_md, head_df, label_counts, corr_df, ttest_df, gallery_imgs, coef_df
212
 
 
213
  # === Gradio UI ===
214
  with gr.Blocks(title="水質データ 解析アプリ(Supabase版)") as demo:
215
  gr.Markdown(
 
4
 
5
  import io
6
  import os
7
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
8
+
9
  import pandas as pd
10
  import numpy as np
11
  import matplotlib.pyplot as plt
 
24
  except Exception:
25
  # 旧API互換(v1 をお使いの場合は import supabase; supabase.create_client を利用)
26
  create_client = None
27
+ import supabase as supabase_v1 # type: ignore
28
 
29
  plt.switch_backend("Agg") # サーバー実行向け
30
 
 
35
 
36
  # .env 読み込み
37
  load_dotenv()
38
+ SUPABASE_URL: Optional[str] = os.environ.get("SUPABASE_URL")
39
+ SUPABASE_KEY: Optional[str] = os.environ.get("SUPABASE_KEY")
40
+ TABLE_NAME: str = "estimated_cause_mocdata" # ご指定のテーブル名
41
+
42
+
43
+ def _get_supabase_client() -> Any:
44
+ """
45
+ Supabase クライアントを生成して返す(v2 を優先、なければ v1 にフォールバック)。
46
+
47
+ 環境変数:
48
+ SUPABASE_URL (str): Supabase の URL
49
+ SUPABASE_KEY (str): Supabase の API キー
50
+
51
+ Raises:
52
+ RuntimeError: 必要な環境変数が未設定、またはクライアント生成に失敗した場合。
53
 
54
+ Returns:
55
+ Any: Supabase クライアントオブジェクト(v2 または v1)。
56
+ """
57
  if not SUPABASE_URL or not SUPABASE_KEY:
58
  raise RuntimeError("環境変数 SUPABASE_URL または SUPABASE_KEY が設定されていません。")
59
  if create_client is not None:
 
61
  # v1 fallback
62
  return supabase_v1.create_client(SUPABASE_URL, SUPABASE_KEY)
63
 
64
+
65
+ def _fetch_supabase_df() -> pd.DataFrame:
66
+ """
67
+ Supabase の指定テーブルから全件取得し、pandas DataFrame に変換して返す。
68
+
69
+ テーブル:
70
+ TABLE_NAME: 既定では 'estimated_cause_mocdata'
71
+
72
+ Raises:
73
+ RuntimeError: 通信エラー、期待した形でデータが得られない、またはレコードが空の場合。
74
+
75
+ Returns:
76
+ pd.DataFrame: 取得したレコードの DataFrame。
77
+ """
78
  client = _get_supabase_client()
79
  # v2 と v1 で返り値が異なるため分岐
80
  try:
81
+ resp: Any = client.table(TABLE_NAME).select("*").execute()
82
+ data: Optional[List[Dict[str, Any]]] = getattr(resp, "data", None) if hasattr(resp, "data") else None
83
  if data is None:
84
+ # v1 の場合、resp が dict のこともある
85
  if isinstance(resp, dict) and "data" in resp:
86
+ data = resp["data"] # type: ignore[index]
87
  if not data:
88
  raise RuntimeError(f"Supabase テーブル '{TABLE_NAME}' からデータを取得できませんでした。")
89
  df = pd.DataFrame(data)
 
91
  raise RuntimeError(f"Supabase テーブル '{TABLE_NAME}' にレコードがありません。")
92
  return df
93
  except Exception as e:
94
+ raise RuntimeError(f"Supabase 取得エラー: {e}") from e
95
+
96
 
97
+ def _boxplot_image(
98
+ a: Union[pd.Series, Sequence[float], np.ndarray],
99
+ b: Union[pd.Series, Sequence[float], np.ndarray],
100
+ feature_name: str
101
+ ) -> np.ndarray:
102
+ """
103
+ 2群(正常/悪化)の値から箱ひげ図を描画し、画像(ndarray)を返す。
104
+
105
+ Args:
106
+ a: 正常(0) 群の値(Series, list, ndarray など数値配列)
107
+ b: 悪化(1) 群の値(Series, list, ndarray など数値配列)
108
+ feature_name (str): グラフタイトルや y 軸ラベルに用いる特徴量名
109
+
110
+ Returns:
111
+ np.ndarray: 生成した箱ひげ図の画像配列(RGB)。
112
+ """
113
  fig = plt.figure()
114
  plt.boxplot([a, b], labels=["正常(0)", "悪化(1)"])
115
  plt.title(f"Boxplot: {feature_name}")
 
121
  img = Image.open(buf) # PIL.Image.Image
122
  return np.array(img) # Gallery は numpy 配列でもOK
123
 
124
+
125
+ # 解析結果の返却タプル型(status_md, head_df, label_counts, corr_df, ttest_df, gallery_imgs, coef_df)
126
+ AnalysisResult = Tuple[
127
+ str,
128
+ Optional[pd.DataFrame],
129
+ Optional[pd.DataFrame],
130
+ Optional[pd.DataFrame],
131
+ Optional[pd.DataFrame],
132
+ List[Tuple[np.ndarray, str]],
133
+ Optional[pd.DataFrame],
134
+ ]
135
+
136
+
137
+ def analyze_from_supabase(threshold: Union[int, float], top_k: Union[int, float]) -> AnalysisResult:
138
+ """
139
+ Supabase から水質データを取得し、閾値によるラベリングを行った上で
140
+ 相関(point-biserial)、t検定、箱ひげ図、ロジスティック回帰による特徴量重要度を算出する。
141
+
142
+ Args:
143
+ threshold (int | float): 目的変数列 'CODcr(S)sin' を悪化(1) と判定する閾値。
144
+ top_k (int | float): ロジスティック回帰の係数上位として表示する特徴量数。
145
+
146
+ Returns:
147
+ AnalysisResult: 以下の7要素タプル
148
+ - status_md (str): 解析の要約(マークダウン)
149
+ - head_df (pd.DataFrame | None): 先頭行のプレビュー
150
+ - label_counts (pd.DataFrame | None): 目的変数の分布
151
+ - corr_df (pd.DataFrame | None): point-biserial 相関表
152
+ - ttest_df (pd.DataFrame | None): t検定の結果表
153
+ - gallery_imgs (List[Tuple[np.ndarray, str]]): 箱ひげ図(画像配列, タイトル)のリスト
154
+ - coef_df (pd.DataFrame | None): ロジスティック回帰の係数ランキング
155
+ """
156
  # ---- データ取得 ----
157
  try:
158
  df = _fetch_supabase_df()
159
  except Exception as e:
160
+ msg = (
161
+ f"❌ データ取得に失敗:{e}\n"
162
+ f"- .env に SUPABASE_URL / SUPABASE_KEY を設定してください\n"
163
+ f"- テーブル名: {TABLE_NAME}"
164
+ )
165
  return (msg, None, None, None, None, [], None)
166
 
167
+ status_md: str = f"**テーブル:** `{TABLE_NAME}`\n\n**データ形状:** {df.shape[0]} 行 × {df.shape[1]} 列\n\n"
168
+ head_df: pd.DataFrame = df.head()
169
 
170
  # ---- 目的変数の作成(悪化=1, 正常=0)----
171
  target_col = "CODcr(S)sin"
172
  if target_col not in df.columns:
173
+ return (
174
+ f"❌ 必須列 '{target_col}' が見つかりません。現在の列: {list(df.columns)}",
175
+ None, None, None, None, [], None
176
+ )
177
 
178
  df = df.copy()
179
  # 数値化(もし文字列が混ざっていても NaN に落とす)
180
  df[target_col] = pd.to_numeric(df[target_col], errors="coerce")
181
+ df["label"] = (df[target_col] > float(threshold)).astype(int)
182
 
183
+ label_counts: pd.DataFrame = df["label"].value_counts(dropna=False).rename_axis("label").to_frame("count")
184
  status_md += (
185
  f"**閾値:** {threshold}\n\n"
186
  f"**目的変数の分布:**\n"
 
189
  )
190
 
191
  # ---- 説明変数の準備 ----
192
+ X: pd.DataFrame = df.drop(columns=[target_col, "label"])
193
+ y: pd.Series = df["label"]
194
 
195
  # 既知の小数表記ゆれ対策(あれば)
196
  if "分散菌槽DO" in X.columns:
 
198
  X["分散菌槽DO"] = pd.to_numeric(X["分散菌槽DO"], errors="coerce")
199
 
200
  # ---- 相関 (point-biserial) ----
201
+ rows: List[Tuple[str, float, float]] = []
202
  for col in X.columns:
203
  try:
204
  col_num = pd.to_numeric(X[col], errors="coerce")
205
  r, p = pointbiserialr(y, col_num)
206
+ rows.append((col, float(r), float(p)))
207
  except Exception:
208
+ rows.append((col, float("nan"), float("nan")))
209
+ corr_df: pd.DataFrame = (
210
  pd.DataFrame(rows, columns=["feature", "r_pb", "pval"])
211
  .set_index("feature")
212
  .sort_values(by="r_pb", key=lambda s: s.abs(), ascending=False)
213
  )
214
 
215
  # ---- t検定 ----
216
+ ttest_rows: List[Dict[str, Union[str, float, int]]] = []
217
  for col in X.columns:
218
  col_num = pd.to_numeric(X[col], errors="coerce")
219
  a = col_num[y == 0].dropna()
 
224
  ttest_rows.append(
225
  {
226
  "feature": col,
227
+ "mean_normal": float(a.mean()),
228
+ "mean_bad": float(b.mean()),
229
+ "pval": float(p),
230
+ "n_normal": int(len(a)),
231
+ "n_bad": int(len(b)),
232
  }
233
  )
234
  except Exception:
235
  pass
236
+ ttest_df: pd.DataFrame = (
237
  pd.DataFrame(ttest_rows).set_index("feature").sort_values(by="pval", ascending=True)
238
  if ttest_rows else pd.DataFrame()
239
  )
240
 
241
  # ---- 箱ひげ図 (ギャラリー) ----
242
+ gallery_imgs: List[Tuple[np.ndarray, str]] = []
243
  for col in X.columns:
244
  col_num = pd.to_numeric(X[col], errors="coerce")
245
  a_plot = col_num[y == 0].dropna()
 
253
  X_num = X_num.loc[:, X_num.notna().sum() > 0] # すべてNaN列を落とす
254
 
255
  if X_num.shape[1] == 0:
256
+ coef_df: pd.DataFrame = pd.DataFrame(columns=["feature", "coef", "sign", "rank"]).set_index("feature")
257
  status_md += "\n⚠️ 数値説明変数がありませんでした。係数は空です。"
258
  else:
259
+ pipe: Pipeline = Pipeline(
260
  steps=[
261
  ("imputer", SimpleImputer(strategy="median")),
262
  ("scaler", StandardScaler()),
 
281
  .drop(columns=["abs_coef"])
282
  )
283
  coef_df["rank"] = np.arange(1, len(coef_df) + 1)
284
+ status_md += "\n\n**悪化原因の候補(上位{}項目)**:\n- ".format(int(top_k)) + "\n- ".join(
285
  [f"{f}: 係数={coef[f]:.3f} {('↑' if coef[f]>0 else '↓')}" for f in top_features]
286
  )
287
  except Exception as e:
 
292
 
293
  return status_md, head_df, label_counts, corr_df, ttest_df, gallery_imgs, coef_df
294
 
295
+
296
  # === Gradio UI ===
297
  with gr.Blocks(title="水質データ 解析アプリ(Supabase版)") as demo:
298
  gr.Markdown(