519 lines
15 KiB
Python
Executable File
519 lines
15 KiB
Python
Executable File
#!/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])
|
|
|
|
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()
|