diff --git a/analyze_lab_placement_survey.py b/analyze_lab_placement_survey.py index c05f0ba..cd4ebcc 100755 --- a/analyze_lab_placement_survey.py +++ b/analyze_lab_placement_survey.py @@ -1,11 +1,16 @@ #!/usr/bin/env python + import argparse import re + import numpy as np import pandas as pd -ZEN_TO_ASCII = str.maketrans("0123456789", "0123456789") +ZEN_TO_ASCII = str.maketrans( + "0123456789", + "0123456789", +) def z2a(s): @@ -15,11 +20,11 @@ def z2a(s): def extract_values_from_body(body): """ - 研究室ごとの回答本文から GP 値を取り出す。 + 研究室ごとの回答本文からGP値を取り出す。 - 「値 カウント」表がある場合は表を優先 - - 表がない場合は 1 行 1 回答として読む - - 「編入生」は GP としては読まない + - 表がない場合は1行1回答として読む + - 「編入生」はGPとしては読まず、内部値0として扱う - 「365(修正済)」のような値にも対応 """ @@ -28,17 +33,23 @@ def extract_values_from_body(body): # -------------------------------------------------- # Case 1: 「値 カウント」表がある場合 # -------------------------------------------------- - m = re.search(r"^値\s+カウント\s*$", body, flags=re.MULTILINE) + 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 + 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) @@ -65,7 +76,11 @@ def extract_values_from_body(body): continue # 例: 365(修正済) - m = re.match(r"^(-?\d+(?:\.\d+)?)", line) + m = re.match( + r"^(-?\d+(?:\.\d+)?)", + line, + ) + if m: values.append(float(m.group(1))) @@ -74,25 +89,31 @@ def extract_values_from_body(body): def parse_google_form_results(text): """ - Google Forms のアンケート結果をコピペしたテキストから、 + Google Formsのアンケート結果をコピペしたテキストから、 研究室番号 | 値 - の DataFrame を生成する。 + のDataFrameを生成する。 + + 回答者が0人の研究室についても、 + + 研究室番号 | NaN + + という行を作成してDataFrameに残す。 """ text = z2a(text) - # 「研究室第1希望」などの集計部分を除外し、 + # 「第一希望研究室」などの集計部分を除外し、 # 最初の個別研究室ブロックから開始する。 # - # 以下の両方に対応: + # 以下の両方に対応する。 # # 研究室1 - # GP7 件の回答 + # GP7件の回答 # # 研究室1 - # 7 件の回答 + # 7件の回答 m = re.search( r"^研究室\s*[0-9]+[^\n]*\n" r"(?:[^\d\n]*?)" @@ -106,7 +127,7 @@ def parse_google_form_results(text): text = text[m.start():] - # 各研究室ブロックを取得 + # 各研究室ブロックを取得する。 pattern = re.compile( r"^研究室\s*([0-9]+)[^\n]*\n" r"(?:[^\d\n]*?)" @@ -128,41 +149,105 @@ def parse_google_form_results(text): n_answers = int(match.group(2)) body = match.group(3).strip() + # 回答者が0人の研究室もDataFrameに残す。 + # + # 編入生を表す内部値0と区別するため、 + # 回答者なしはNaNで表現する。 if n_answers == 0: + rows.append( + { + "研究室番号": lab_no, + "値": np.nan, + } + ) continue values = extract_values_from_body(body) if len(values) != n_answers: - print(f"警告: 研究室{lab_no}: 回答数={n_answers}, 抽出数={len(values)}") + print( + f"警告: 研究室{lab_no}: " + f"回答数={n_answers}, 抽出数={len(values)}" + ) for value in values: - rows.append({"研究室番号": lab_no, "値": value}) + rows.append( + { + "研究室番号": lab_no, + "値": value, + } + ) - df = pd.DataFrame(rows) + df = pd.DataFrame( + rows, + columns=["研究室番号", "値"], + ) - if not df.empty and (df["値"] % 1 == 0).all(): - df["値"] = df["値"].astype(int) + if df.empty: + return df + + # すべての有効値が整数の場合は、NaNを保持できる + # pandasのnullable整数型Int64に変換する。 + non_na_values = df["値"].dropna() + + if ( + not non_na_values.empty + and (non_na_values % 1 == 0).all() + ): + df["値"] = df["値"].astype("Int64") return df def summarize_by_lab(df): - """研究室ごとの在校生数・編入生数・GP平均・標準偏差を計算する。""" + """ + 研究室ごとの在校生数・編入生数・GP平均・標準偏差を計算する。 + + - NaNは回答者0人を表すため、人数には含めない + - 値が10より大きい回答を在校生として扱う + - 値が10以下の回答を編入生として扱う + """ + + required_columns = { + "研究室番号", + "値", + } + + missing_columns = required_columns - set(df.columns) + + if missing_columns: + raise KeyError( + "必須列がありません: " + + ", ".join(sorted(missing_columns)) + ) rows = [] for lab_no in sorted(df["研究室番号"].unique()): - df_lab = df.loc[df["研究室番号"] == lab_no].copy() + df_lab = df.loc[ + df["研究室番号"] == lab_no + ].copy() - # 編入生は 0 として入れてある。 - df_regular = df_lab.loc[df_lab["値"] > 10] + # 回答者0人を表すNaN行を除外する。 + df_answers = df_lab.loc[ + df_lab["値"].notna() + ].copy() + + # 在校生と編入生を分類する。 + df_regular = df_answers.loc[ + df_answers["値"] > 10 + ] + + df_transfer = df_answers.loc[ + df_answers["値"] <= 10 + ] rows.append( { "研究室": lab_no, + "回答者数": df_answers.shape[0], "在校生": df_regular.shape[0], - "編入生": df_lab.shape[0] - df_regular.shape[0], + "編入生": df_transfer.shape[0], "GP mean": df_regular["値"].mean(), "GP std": df_regular["値"].std(), } @@ -172,33 +257,59 @@ def summarize_by_lab(df): 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}" + # NaNは回答者0人の研究室を表すため数えない。 + n_respondents = int(df["値"].notna().sum()) + + print("-" * 66) + print("回答者数:", n_respondents) + + print( + f"{'研究室':>6} " + f"{'回答者':>8} " + f"{'在校生':>8} " + f"{'編入生':>8} " + f"{'GP mean':>10} " + f"{'GP std':>10}" + ) + + print("-" * 66) + + for lab_no, 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"{lab_no:>6} " + f"{int(row['回答者数']):>8} " f"{int(row['在校生']):>8} " f"{int(row['編入生']):>8} " f"{mean:>10} " f"{std:>10}" ) + print("-" * 66) + def apply_plot_style(): """ - 論文図として使いやすい matplotlib の描画設定を適用する。 + 論文図として使いやすいmatplotlibの描画設定を適用する。 仕様: - - seaborn や scienceplots には依存しない。 - - タイトルは付けず、軸ラベルと目盛だけで情報を伝える。 - - フォントサイズ、線幅、余白、保存時 DPI を論文図向けに調整する。 - - カラーマップは既定で tab10 を使う。 + - seabornやscienceplotsには依存しない + - タイトルは付けず、軸ラベルと目盛だけで情報を伝える + - フォントサイズ、線幅、余白、保存時DPIを論文図向けに調整する + - カラーマップは既定でtab10を使う """ import matplotlib.pyplot as plt @@ -209,7 +320,11 @@ def apply_plot_style(): "savefig.dpi": 300, "savefig.bbox": "tight", "font.family": "sans-serif", - "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"], + "font.sans-serif": [ + "Arial", + "Helvetica", + "DejaVu Sans", + ], "font.size": 11, "axes.labelsize": 12, "axes.linewidth": 1.0, @@ -232,14 +347,15 @@ def apply_plot_style(): def filter_plot_data(data, lower=10, upper=None): """ - GP の描画対象データを抽出する。 + GPの描画対象データを抽出する。 Parameters ---------- data : pandas.DataFrame 「研究室番号」と「値」列を持つデータフレーム。 lower : float, default 10 - この値より大きい回答を描画対象にする。既定値では編入生を除く。 + この値より大きい回答を描画対象にする。 + 既定値では編入生と回答者なしのNaNを除く。 upper : float or None, default None 指定した場合、この値未満の回答だけを描画対象にする。 @@ -247,46 +363,76 @@ def filter_plot_data(data, lower=10, upper=None): ------- pandas.DataFrame フィルタ後のデータ。元データは変更しない。 - - Raises - ------ - KeyError - 必須列が存在しない場合。 - ValueError - フィルタ後に描画対象データが残らない場合。 """ - required_columns = {"研究室番号", "値"} - missing_columns = required_columns - set(data.columns) - if missing_columns: - raise KeyError(f"必須列がありません: {', '.join(sorted(missing_columns))}") + required_columns = { + "研究室番号", + "値", + } + + missing_columns = required_columns - set(data.columns) + + if missing_columns: + raise KeyError( + "必須列がありません: " + + ", ".join(sorted(missing_columns)) + ) + + # NaNは比較結果がFalseになるため、ここで自動的に除外される。 + df = data.loc[ + data["値"] > lower, + ["研究室番号", "値"], + ].copy() - df = data.loc[data["値"] > lower, ["研究室番号", "値"]].copy() if upper is not None: - df = df.loc[df["値"] < upper].copy() + df = df.loc[ + df["値"] < upper + ].copy() if df.empty: - raise ValueError("描画対象のデータがありません。lower/upper を確認してください。") + raise ValueError( + "描画対象のデータがありません。" + "lower/upperを確認してください。" + ) + + # matplotlib/numpyで扱いやすい通常のfloat型に変換する。 + df["値"] = df["値"].astype(float) return df def make_histogram_bins(values, bin_width): """ - GP ヒストグラム用のビン境界を作成する。 + GPヒストグラム用のビン境界を作成する。 仕様: - - 最小値側は bin_width の倍数に切り下げる。 - - 最大値側は bin_width の倍数に切り上げ、最大値が最後のビンに入るようにする。 - - bin_width は正の数でなければならない。 + - 最小値側はbin_widthの倍数に切り下げる + - 最大値側はbin_widthの倍数に切り上げる + - 最大値が最後のビンに入るようにする + - bin_widthは正の数でなければならない """ if bin_width <= 0: - raise ValueError("bin_width は正の数を指定してください。") + 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) + 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"): @@ -294,31 +440,51 @@ 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.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 分布を箱ひげ図と実測点で描画する。 + 研究室別GP分布を箱ひげ図と実測点で描画する。 仕様: - - 箱ひげ図は外れ値を非表示にし、実測点を重ねて全回答の分布を見せる。 - - 実測点には固定乱数の jitter を与え、同じ値の点の重なりを避ける。 - - x 軸のカテゴリ順は labs の順序に従う。 + - 箱ひげ図は外れ値を非表示にする + - 実測点を重ねて全回答の分布を見せる + - 実測点には固定乱数のjitterを与える + - x軸のカテゴリ順はlabsの順序に従う """ - positions = np.arange(1, len(labs) + 1) + positions = np.arange( + 1, + len(labs) + 1, + ) + grouped_values = [ - df.loc[df["研究室番号"] == lab, "値"].to_numpy(dtype=float) for lab in labs + df.loc[ + df["研究室番号"] == lab, + "値", + ].to_numpy(dtype=float) + for lab in labs ] box = ax.boxplot( @@ -327,17 +493,38 @@ def draw_lab_boxplot(ax, df, labs, color, rng): 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}, + 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)) + 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, @@ -348,53 +535,98 @@ def draw_lab_boxplot(ax, df, labs, color, rng): zorder=3, ) - ax.set_xlim(0.4, len(labs) + 0.6) + ax.set_xlim( + 0.4, + len(labs) + 0.6, + ) + ax.set_xticks(positions) - ax.set_xticklabels([str(lab) for lab in labs]) + + 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): +def annotate_target_values( + ax, + target_values, + color, +): """ - ヒストグラム上に対象研究室の GP を縦線と数値で示す。 + ヒストグラム上に対象研究室のGPを縦線と数値で示す。 仕様: - - 同じ GP 値は 1 本の線にまとめる。 - - ラベルは軸上端の内側に置き、棒や軸ラベルとの重なりを避ける。 - - 近い値が複数ある場合はラベル高さを段階的にずらす。 + - 同じ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}" + unique_values = ( + pd.Series(target_values) + .value_counts() + .sort_index() + ) + + for i, (value, count) in enumerate( + unique_values.items() + ): + ax.axvline( + value, + color=color, + linewidth=1.3, + alpha=0.85, + linestyle="--", + ) + + if count == 1: + label = f"{value:g}" + else: + label = f"{value:g} x{count}" + ax.text( value, 0.96 - 0.11 * (i % 3), label, transform=ax.get_xaxis_transform(), - ha="center", - va="top", + horizontalalignment="center", + verticalalignment="top", rotation=90, color=color, fontsize=9, - bbox={"facecolor": "white", "edgecolor": "none", - "alpha": 0.75, "pad": 1.2}, + bbox={ + "facecolor": "white", + "edgecolor": "none", + "alpha": 0.75, + "pad": 1.2, + }, ) -def draw_gp_histogram(ax, df, target_values, bin_width, color, target_color): +def draw_gp_histogram( + ax, + df, + target_values, + bin_width, + color, + target_color, +): """ - 全研究室の GP ヒストグラムを描画する。 + 全研究室のGPヒストグラムを描画する。 仕様: - - bin_width ごとの頻度を表示する。 - - 対象研究室の GP は点ではなく縦線で示し、全体分布内の位置を読みやすくする。 - - 研究室別箱ひげ図と同じ主色を使い、対象研究室は tab10 の別色で強調する。 + - bin_widthごとの頻度を表示する + - 対象研究室のGPは縦線で示す + - 研究室別箱ひげ図と同じ主色を使う """ - bins = make_histogram_bins(df["値"], bin_width) + bins = make_histogram_bins( + df["値"], + bin_width, + ) + ax.hist( df["値"], bins=bins, @@ -405,74 +637,122 @@ def draw_gp_histogram(ax, df, target_values, bin_width, color, target_color): ) if len(target_values) > 0: - annotate_target_values(ax, target_values, target_color) + 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): +def plot_data( + data, + lower=10, + upper=None, + target_lab=None, + bin_width=10, +): """ - 研究室配属希望調査の GP 分布を描画する。 + 研究室配属希望調査のGP分布を描画する。 Parameters ---------- data : pandas.DataFrame 「研究室番号」と「値」列を持つデータフレーム。 lower : float, default 10 - 描画対象に含める GP の下限。値が lower より大きい回答だけを描く。 + 値がlowerより大きい回答だけを描く。 upper : float or None, default None - 描画対象に含める GP の上限。None の場合は上限を設けない。 - target_lab : int, default 10 - ヒストグラム上で GP 値を縦線表示する研究室番号。 + GPの上限。Noneの場合は上限を設けない。 + target_lab : int, default None + GP値を縦線表示する研究室番号。 bin_width : float, default 10 ヒストグラムのビン幅。 + Returns ------- - tuple[matplotlib.figure.Figure, numpy.ndarray] - 生成した Figure と 2 個の Axes。 + tuple + 生成したFigureと2個のAxes。 仕様: - - matplotlib と pandas/numpy のみで描画する。 - - 図タイトルは付けない。 - - 上段に研究室別の箱ひげ図と個別点、下段に全体 GP ヒストグラムを描く。 - - 編入生は既定で除外するため、内部値 0 など lower 以下の値は描画しない。 - - 色は基本的に tab10 を使用する。 + - matplotlibとpandas/numpyのみで描画する + - 図タイトルは付けない + - 上段に研究室別箱ひげ図と個別点を描く + - 下段に全体GPヒストグラムを描く + - 編入生および回答者なしのNaNは描画しない """ 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) + df = filter_plot_data( + data.copy(), + 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) + + rng = np.random.default_rng( + 20260709 + ) fig, axs = plt.subplots( 2, 1, figsize=(8.0, 6.2), - gridspec_kw={"height_ratios": [1.1, 1]}, + 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]) + draw_lab_boxplot( + axs[0], + df, + labs, + colors[0], + rng, + ) - describe_text = df["値"].describe() - describe_text = describe_text.to_string(float_format=lambda x: f"{x:.1f}") - # 下段ヒストグラムの右上に describe() を表示 + draw_gp_histogram( + axs[1], + df, + target_values, + bin_width, + colors[0], + colors[3], + ) + + describe_text = ( + df["値"] + .describe() + .to_string( + float_format=lambda x: f"{x:.1f}" + ) + ) + + # 下段ヒストグラムの左上にdescribe()を表示する。 axs[1].text( - 0.01, 0.99, # Axes 内の右上 + 0.01, + 0.99, describe_text, transform=axs[1].transAxes, - ha="left", va="top", fontsize=8, - fontfamily="monospace", # 桁を揃える + horizontalalignment="left", + verticalalignment="top", + fontsize=8, + fontfamily="monospace", bbox={ "facecolor": "white", "edgecolor": "gray", @@ -481,53 +761,115 @@ def plot_data(data, lower=10, upper=None, target_lab=10, bin_width=10): }, zorder=1, ) + for ax in axs: style_axis(ax) plt.show() + return fig, axs +def make_output_filename( + input_filename, + extension, +): + """ + 入力ファイル名の拡張子を変更した出力ファイル名を作る。 + """ + + if "." in input_filename: + base = input_filename.rsplit(".", 1)[0] + else: + base = input_filename + + return base + extension + + def main(): parser = argparse.ArgumentParser( - description="Google Forms のコピペ結果から研究室別GPを集計する" + description=( + "Google Formsのコピペ結果から" + "研究室別GPを集計する" + ) ) + parser.add_argument( - "googleform_str", help="Google Forms からコピーしたテキストファイル" + "googleform_str", + help=( + "Google Formsからコピーした" + "テキストファイル" + ), ) + parser.add_argument( - "-o", "--output", help="Excel 出力ファイル名。指定しない場合は出力しない" + "-o", + "--output", + help=( + "Excel出力ファイル名。" + "指定しない場合は出力しない" + ), ) parser.add_argument( "-p", "--plot", action="store_true", - help="各種plotを作成するかどうか。指定しない場合は作成しない。", + help=( + "各種plotを作成する。" + "指定しない場合は作成しない" + ), ) args = parser.parse_args() - with open(args.googleform_str, encoding="utf-8") as f: + 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) + 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.") + + plot_filename = make_output_filename( + args.googleform_str, + ".png", + ) + + fig.savefig( + plot_filename, + dpi=300, + ) + + print( + f"{plot_filename} 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") + df.to_excel( + writer, + sheet_name="raw", + index=False, + ) - print(f"{args.output} was created.") + df_r.to_excel( + writer, + sheet_name="summary", + ) + + print( + f"{args.output} was created." + ) if __name__ == "__main__":