#!/usr/bin/env python import argparse import re import numpy as np import pandas as pd ZEN_TO_ASCII = str.maketrans("0123456789", "0123456789") def z2a(s): """全角数字を半角数字に変換する。""" return s.translate(ZEN_TO_ASCII) def extract_values_from_body(body): """ 研究室ごとの回答本文から GP 値を取り出す。 - 「値 カウント」表がある場合は表を優先 - 表がない場合は 1 行 1 回答として読む - 「編入生」は GP としては読まない - 「365(修正済)」のような値にも対応 """ body = z2a(body) # -------------------------------------------------- # Case 1: 「値 カウント」表がある場合 # -------------------------------------------------- m = re.search(r"^値\s+カウント\s*$", body, flags=re.MULTILINE) if m: table_part = body[m.end():] count_rows = re.findall( r"^\s*(-?\d+(?:\.\d+)?)\s+(\d+)\s*$", table_part, flags=re.MULTILINE ) values = [] for value_str, count_str in count_rows: value = float(value_str) count = int(count_str) values.extend([value] * count) return values # -------------------------------------------------- # Case 2: 回答値が1行ずつ並んでいる場合 # -------------------------------------------------- values = [] for line in body.splitlines(): line = line.strip() if not line: continue if "編入生" in line: values.append(0.0) continue if "この質問にはまだ回答がありません" in line: continue # 例: 365(修正済) m = re.match(r"^(-?\d+(?:\.\d+)?)", line) if m: values.append(float(m.group(1))) return values def parse_google_form_results(text): """ Google Forms のアンケート結果をコピペしたテキストから、 研究室番号 | 値 の DataFrame を生成する。 """ text = z2a(text) # 「研究室第1希望」などの集計部分を除外し、 # 最初の個別研究室ブロックから開始する。 # # 以下の両方に対応: # # 研究室1 # GP7 件の回答 # # 研究室1 # 7 件の回答 m = re.search( r"^研究室\s*[0-9]+[^\n]*\n" r"(?:[^\d\n]*?)" r"[0-9]+\s*件の回答", text, flags=re.MULTILINE, ) if not m: raise ValueError("個別研究室ブロックが見つかりません") text = text[m.start():] # 各研究室ブロックを取得 pattern = re.compile( r"^研究室\s*([0-9]+)[^\n]*\n" r"(?:[^\d\n]*?)" r"([0-9]+)\s*件の回答\s*\n" r"(.*?)" r"(?=" r"^研究室\s*[0-9]+[^\n]*\n" r"(?:[^\d\n]*?)" r"[0-9]+\s*件の回答" r"|\Z" r")", flags=re.MULTILINE | re.DOTALL, ) rows = [] for match in pattern.finditer(text): lab_no = int(match.group(1)) n_answers = int(match.group(2)) body = match.group(3).strip() if n_answers == 0: continue values = extract_values_from_body(body) if len(values) != n_answers: print(f"警告: 研究室{lab_no}: 回答数={n_answers}, 抽出数={len(values)}") for value in values: rows.append({"研究室番号": lab_no, "値": value}) df = pd.DataFrame(rows) if not df.empty and (df["値"] % 1 == 0).all(): df["値"] = df["値"].astype(int) return df def summarize_by_lab(df): """研究室ごとの在校生数・編入生数・GP平均・標準偏差を計算する。""" rows = [] for lab_no in sorted(df["研究室番号"].unique()): df_lab = df.loc[df["研究室番号"] == lab_no].copy() # 編入生は 0 として入れてある。 df_regular = df_lab.loc[df_lab["値"] > 10] rows.append( { "研究室": lab_no, "在校生": df_regular.shape[0], "編入生": df_lab.shape[0] - df_regular.shape[0], "GP mean": df_regular["値"].mean(), "GP std": df_regular["値"].std(), } ) return pd.DataFrame(rows).set_index("研究室") def print_summary(df, df_r): print("-" * 52) print("回答者数:", df.shape[0]) print(f"{'研究室':>6} {'在校生':>6} {'編入生':>6} {'GP mean':>10} {'GP std':>10}") print("-" * 52) for n, row in df_r.iterrows(): mean = "-" if pd.isna(row["GP mean"]) else f"{row['GP mean']:.1f}" std = "-" if pd.isna(row["GP std"]) else f"{row['GP std']:.1f}" print( f"{n:>6} " f"{int(row['在校生']):>8} " f"{int(row['編入生']):>8} " f"{mean:>10} " f"{std:>10}" ) def apply_plot_style(): """ 論文図として使いやすい matplotlib の描画設定を適用する。 仕様: - seaborn や scienceplots には依存しない。 - タイトルは付けず、軸ラベルと目盛だけで情報を伝える。 - フォントサイズ、線幅、余白、保存時 DPI を論文図向けに調整する。 - カラーマップは既定で tab10 を使う。 """ import matplotlib.pyplot as plt plt.rcParams.update( { "figure.dpi": 120, "savefig.dpi": 300, "savefig.bbox": "tight", "font.family": "sans-serif", "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"], "font.size": 11, "axes.labelsize": 12, "axes.linewidth": 1.0, "axes.spines.top": False, "axes.spines.right": False, "xtick.labelsize": 10, "ytick.labelsize": 10, "xtick.direction": "out", "ytick.direction": "out", "xtick.major.size": 4, "ytick.major.size": 4, "xtick.major.width": 1.0, "ytick.major.width": 1.0, "legend.frameon": False, "pdf.fonttype": 42, "ps.fonttype": 42, } ) def filter_plot_data(data, lower=10, upper=None): """ GP の描画対象データを抽出する。 Parameters ---------- data : pandas.DataFrame 「研究室番号」と「値」列を持つデータフレーム。 lower : float, default 10 この値より大きい回答を描画対象にする。既定値では編入生を除く。 upper : float or None, default None 指定した場合、この値未満の回答だけを描画対象にする。 Returns ------- pandas.DataFrame フィルタ後のデータ。元データは変更しない。 Raises ------ KeyError 必須列が存在しない場合。 ValueError フィルタ後に描画対象データが残らない場合。 """ required_columns = {"研究室番号", "値"} missing_columns = required_columns - set(data.columns) if missing_columns: raise KeyError(f"必須列がありません: {', '.join(sorted(missing_columns))}") df = data.loc[data["値"] > lower, ["研究室番号", "値"]].copy() if upper is not None: df = df.loc[df["値"] < upper].copy() if df.empty: raise ValueError("描画対象のデータがありません。lower/upper を確認してください。") return df def make_histogram_bins(values, bin_width): """ GP ヒストグラム用のビン境界を作成する。 仕様: - 最小値側は bin_width の倍数に切り下げる。 - 最大値側は bin_width の倍数に切り上げ、最大値が最後のビンに入るようにする。 - bin_width は正の数でなければならない。 """ if bin_width <= 0: raise ValueError("bin_width は正の数を指定してください。") min_edge = np.floor(values.min() / bin_width) * bin_width max_edge = np.ceil(values.max() / bin_width) * bin_width + bin_width return np.arange(min_edge, max_edge, bin_width) def style_axis(ax, grid_axis="y"): """ 軸まわりの体裁を統一する。 仕様: - 上枠と右枠を消す。 - 目盛を外向きにする。 - 指定軸方向に薄いグリッドを入れ、文字やデータ点と競合しない濃度にする。 """ ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.tick_params(direction="out", length=4, width=1) ax.grid(axis=grid_axis, color="0.88", linewidth=0.8) ax.set_axisbelow(True) def draw_lab_boxplot(ax, df, labs, color, rng): """ 研究室別 GP 分布を箱ひげ図と実測点で描画する。 仕様: - 箱ひげ図は外れ値を非表示にし、実測点を重ねて全回答の分布を見せる。 - 実測点には固定乱数の jitter を与え、同じ値の点の重なりを避ける。 - x 軸のカテゴリ順は labs の順序に従う。 """ positions = np.arange(1, len(labs) + 1) grouped_values = [ df.loc[df["研究室番号"] == lab, "値"].to_numpy(dtype=float) for lab in labs ] box = ax.boxplot( grouped_values, positions=positions, widths=0.55, patch_artist=True, showfliers=False, medianprops={"color": "black", "linewidth": 1.2}, boxprops={"facecolor": color, "edgecolor": "black", "linewidth": 1.0}, whiskerprops={"color": "black", "linewidth": 1.0}, capprops={"color": "black", "linewidth": 1.0}, ) for patch in box["boxes"]: patch.set_alpha(0.55) for x_position, values in zip(positions, grouped_values): jitter = rng.uniform(-0.16, 0.16, size=len(values)) ax.scatter( np.full(len(values), x_position) + jitter, values, s=18, color="black", alpha=0.45, linewidths=0, zorder=3, ) ax.set_xlim(0.4, len(labs) + 0.6) ax.set_xticks(positions) ax.set_xticklabels([str(lab) for lab in labs]) ax.set_xlabel("Laboratory") ax.set_ylabel("GP") def annotate_target_values(ax, target_values, color): """ ヒストグラム上に対象研究室の GP を縦線と数値で示す。 仕様: - 同じ GP 値は 1 本の線にまとめる。 - ラベルは軸上端の内側に置き、棒や軸ラベルとの重なりを避ける。 - 近い値が複数ある場合はラベル高さを段階的にずらす。 """ unique_values = pd.Series(target_values).value_counts().sort_index() for i, (value, count) in enumerate(unique_values.items()): ax.axvline(value, color=color, lw=1.3, alpha=0.85, linestyle="--") label = f"{value:g}" if count == 1 else f"{value:g} x{count}" ax.text( value, 0.96 - 0.11 * (i % 3), label, transform=ax.get_xaxis_transform(), ha="center", va="top", rotation=90, color=color, fontsize=9, bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.75, "pad": 1.2}, ) def draw_gp_histogram(ax, df, target_values, bin_width, color, target_color): """ 全研究室の GP ヒストグラムを描画する。 仕様: - bin_width ごとの頻度を表示する。 - 対象研究室の GP は点ではなく縦線で示し、全体分布内の位置を読みやすくする。 - 研究室別箱ひげ図と同じ主色を使い、対象研究室は tab10 の別色で強調する。 """ bins = make_histogram_bins(df["値"], bin_width) ax.hist( df["値"], bins=bins, color=color, alpha=0.62, edgecolor="black", linewidth=0.8, ) if len(target_values) > 0: annotate_target_values(ax, target_values, target_color) ax.set_xlabel("GP") ax.set_ylabel("Frequency") def plot_data(data, lower=10, upper=None, target_lab=10, bin_width=10): """ 研究室配属希望調査の GP 分布を描画する。 Parameters ---------- data : pandas.DataFrame 「研究室番号」と「値」列を持つデータフレーム。 lower : float, default 10 描画対象に含める GP の下限。値が lower より大きい回答だけを描く。 upper : float or None, default None 描画対象に含める GP の上限。None の場合は上限を設けない。 target_lab : int, default 10 ヒストグラム上で GP 値を縦線表示する研究室番号。 bin_width : float, default 10 ヒストグラムのビン幅。 Returns ------- tuple[matplotlib.figure.Figure, numpy.ndarray] 生成した Figure と 2 個の Axes。 仕様: - matplotlib と pandas/numpy のみで描画する。 - 図タイトルは付けない。 - 上段に研究室別の箱ひげ図と個別点、下段に全体 GP ヒストグラムを描く。 - 編入生は既定で除外するため、内部値 0 など lower 以下の値は描画しない。 - 色は基本的に tab10 を使用する。 """ import matplotlib.pyplot as plt df = data.copy() df = filter_plot_data(df, lower=lower, upper=upper) labs = sorted(df["研究室番号"].unique()) target_values = df.loc[df["研究室番号"] == target_lab, "値"].to_numpy(dtype=float) apply_plot_style() colors = plt.get_cmap("tab10").colors rng = np.random.default_rng(20260709) fig, axs = plt.subplots( 2, 1, figsize=(8.0, 6.2), gridspec_kw={"height_ratios": [1.1, 1]}, constrained_layout=True, ) draw_lab_boxplot(axs[0], df, labs, colors[0], rng) draw_gp_histogram(axs[1], df, target_values, bin_width, colors[0], colors[3]) describe_text = df["値"].describe() describe_text = describe_text.to_string(float_format=lambda x: f"{x:.1f}") # 下段ヒストグラムの右上に describe() を表示 axs[1].text( 0.01, 0.99, # Axes 内の右上 describe_text, transform=axs[1].transAxes, ha="left", va="top", fontsize=8, fontfamily="monospace", # 桁を揃える bbox={ "facecolor": "white", "edgecolor": "gray", "alpha": 0.85, "boxstyle": "round,pad=0.2", }, zorder=1, ) for ax in axs: style_axis(ax) plt.show() return fig, axs def main(): parser = argparse.ArgumentParser( description="Google Forms のコピペ結果から研究室別GPを集計する" ) parser.add_argument( "googleform_str", help="Google Forms からコピーしたテキストファイル" ) parser.add_argument( "-o", "--output", help="Excel 出力ファイル名。指定しない場合は出力しない" ) parser.add_argument( "-p", "--plot", action="store_true", help="各種plotを作成するかどうか。指定しない場合は作成しない。", ) args = parser.parse_args() with open(args.googleform_str, encoding="utf-8") as f: text = f.read() df = parse_google_form_results(text) # df_r = summarize_by_lab(df).sort_values(by="GP mean", ascending=False) df_r = summarize_by_lab(df) print_summary(df, df_r) if args.plot: fig, axs = plot_data(df) fig.savefig(args.googleform_str.replace(".txt", ".png"), dpi=300) print(args.googleform_str.replace(".txt", ".png"), "was created.") if args.output: with pd.ExcelWriter(args.output) as writer: df.to_excel(writer, sheet_name="raw") df_r.to_excel(writer, sheet_name="summary") print(f"{args.output} was created.") if __name__ == "__main__": main()