supports the output format for FY2026.

This commit is contained in:
2026-07-09 07:50:45 +09:00
parent b431e453ad
commit 0cac303159
2 changed files with 337 additions and 166 deletions
+170 -48
View File
@@ -4,49 +4,48 @@
* 概要 * 概要
Google Forms で実施した3年生の研究室配属希望調査について、フォーム結果 Google Forms で実施した3年生の研究室配属希望調査について、結果画面から
画面からコピーしたテキストを解析し、研究室ごとの希望者数および GPA の コピーしたテキストを解析し、研究室ごとの希望者数 GPA の統計量を集計する
統計量を集計する Python スクリプトです。 Python スクリプトです。
Google Forms の管理者権限がなくCSVファイルを直接取得できない場合でも、 Google Forms の管理者権限がなく CSV を直接取得できない場合でも、結果画面の
結果画面に表示されたテキストをコピーしてファイルに保存することで解析で テキストをコピーしてファイルに保存すれば解析できます。
きます。
主な処理内容は以下のとおりです。 主な処理内容:
- Google Forms の結果テキストから研究室番号を抽出 - Google Forms の結果テキストから研究室番号を抽出
- 各研究室の回答値を抽出 - 各研究室の回答値を抽出
- 在校生と編入生を分類 - 在校生と編入生を分類
- 研究室ごとの希望者数を集計 - 研究室ごとの希望者数を集計
- 在校生 GPA の平均値を計算 - 在校生 GPA の平均値と標準偏差を計算
- 在校生 GPA の標準偏差を計算 - 必要に応じて元データと集計結果を Excel に保存
- 元データを Excel ファイルとして保存
* 必要な環境 * 必要な環境
Python 3 が必要です。 Python 3 と以下のパッケージが必要です。
使用する Python パッケージは以下です。
- numpy - numpy
- pandas - pandas
- openpyxl - openpyxl
必要に応じて以下のようにインストールします。
Conda を使う場合:
#+begin_src sh #+begin_src sh
condat install numpy pandas openpyxl conda install numpy pandas openpyxl
#+end_src #+end_src
* 入力データ * 入力データ
Google Forms のアンケート結果画面に表示されたテキストをコピーし、テキ Google Forms の結果画面に表示されたテキストをコピーし、テキストファイル
ストファイルとして保存します。 として保存します。
例:
例えば、
#+begin_example #+begin_example
googleform.txt googleform.txt
#+end_example #+end_example
というファイル名で保存します。入力データには、以下のような研究室ごとの 研究室ごとの回答は、例えば以下の形式を想定しています。
回答結果が含まれていることを想定しています。
#+begin_example #+begin_example
研究室10(極限環境材料科学研究室) 研究室10(極限環境材料科学研究室)
@@ -60,8 +59,78 @@ googleform.txt
359 359
#+end_example #+end_example
また、Google Forms の表示形式によっては、 Google Forms の表示によっては、回答数の前に =GP= が付く場合にも対応します。
同じ値が複数回出現する場合に以下のような
#+begin_example
研究室10
GP7 件の回答
236
377.5
292
1.8
385
350
#+end_example
* 回答値の形式
** 1行1回答の形式
通常は各回答値が1行ずつ並びます。
#+begin_example
研究室1
GP7 件の回答
260
181
395
340
396
380
365
#+end_example
小数値にも対応します。
#+begin_example
348.3
377.5
403.51
#+end_example
値の後ろに注記がある場合も、行頭の数値を読み取ります。
#+begin_example
365(修正済)
#+end_example
この場合は =365= として処理します。
** 編入生
回答が =編入生= の場合は、内部的に 0 として扱います。
#+begin_example
研究室2
GP6 件の回答
編入生
217
248
350
342
360
#+end_example
この例では、
- 在校生: 5人
- 編入生: 1人
として集計します。
** 値 / カウント形式
同じ値が複数回出現する場合、Google Forms の表示によっては
「値 / カウント」形式になることがあります。 「値 / カウント」形式になることがあります。
#+begin_example #+begin_example
@@ -70,12 +139,11 @@ googleform.txt
350 2 350 2
370 1 370 1
380 1 380 1
385 1
400 1
401 1
#+end_example #+end_example
この場合、カウント数に応じて値を展開します。例えば、 この場合、カウント数に応じて値を展開します。
例えば、
#+begin_example #+begin_example
300 2 300 2
@@ -93,69 +161,123 @@ googleform.txt
として処理されます。 として処理されます。
Google Forms のグラフ表示からコピーした際に、表の前に連結した数字列が
含まれることがあります。
#+begin_example
19423023625427427734435036037001231 (8.3%)...
値 カウント
194 1
230 1
236 1
254 1
274 1
277 1
344 1
350 1
360 3
370 1
#+end_example
この場合は「値 / カウント」表を優先して使用し、表より前のグラフ由来の
文字列は無視します。
* 実行方法 * 実行方法
#+begin_src sh #+begin_src sh
./analyze_lab_assignment_survey.py googleform.txt ./analyze_lab_assignment_survey.py googleform.txt
#+end_src #+end_src
* 出力 * 出力
実行すると、研究室ごとの集計結果が標準出力に表示されます。
出力例: 実行すると、研究室ごとの集計結果を標準出力に表示します。
#+begin_example #+begin_example
----------------------------------------------------
回答者数: 98
研究室 在校生 編入生 GPA mean GPA std 研究室 在校生 編入生 GPA mean GPA std
---------------------------------------------------- ----------------------------------------------------
1 9 0 359.6 38.4 1 7 0 345.1 50.7
2 6 0 269.3 66.3 2 5 1 303.4 63.0
3 8 1 358.7 42.7 3 7 0 351.6 69.0
6 3 0 343.7 58.5 6 7 0 354.0 32.9
7 4 0 301.8 97.1
8 13 0 372.2 72.3
9 8 0 319.1 77.6
10 7 0 327.4 42.6
12 2 1 190.9 -
#+end_example #+end_example
=GPA std= は標本標準偏差です。対象が1人の場合は =-= と表示します。
* 在校生と編入生の判定 * 在校生と編入生の判定
このスクリプトでは、回答値について 解析時には、回答値について
#+begin_src python #+begin_src python
df_[""] > 10 df_lab[""] > 10
#+end_src #+end_src
を満たす回答を在校生の GPA データとして扱います。 を満たす回答を在校生の GPA データとして扱います。
したがって、 したがって、
- 値 > 10 : 在校生の GPA - 値 > 10 : 在校生
- 値 <= 10 : 編入生 - 値 <= 10 : 編入生
として分類します。 として分類します。
現在のスクリプトでは、文字列 =編入生= を内部的に =0= として保存するため、
この条件で編入生として分類されます。
* GPA の統計量 * GPA の統計量
研究室ごとに在校生のみを抽出しGPA の平均値と標準偏差を計算します。 研究室ごとに在校生のみを抽出しGPA の平均値と標準偏差を計算します。
平均値 平均値:
#+begin_src python #+begin_src python
df__[""].mean() df_regular[""].mean()
#+end_src #+end_src
標準偏差 標準偏差:
#+begin_src python #+begin_src python
df__[""].std() df_regular[""].std()
#+end_src #+end_src
=pandas.Series.std()= を使用するため、標準偏差は標本標準偏差
=ddof=1=)です。
* Excel 出力
=-o= または =--output= を指定すると、解析結果を Excel ファイルに保存します。
#+begin_src sh
./analyze_lab_assignment_survey.py googleform.txt -o result.xlsx
#+end_src
Excel ファイルには以下のシートを作成します。
- =raw= : 抽出した研究室番号と回答値
- =summary= : 研究室ごとの集計結果
* 対応している入力形式
以下の形式に対応します。
- =研究室1= などの研究室番号
- 全角数字を含む研究室番号
- =7 件の回答=
- =GP7 件の回答=
- 整数値
- 小数値
- =365(修正済)= のような注記付き数値
- =編入生=
- 「値 / カウント」表
- Google Forms のグラフ由来の連結数字列
* 注意事項 * 注意事項
- 入力テキストは Google Forms の表示形式に依存します。 - 入力テキストは Google Forms の表示形式に依存します。
- Google Forms 側の HTML やコピー形式が変更された場合、正規表現の修正が必要になる可能性があります。 - Google Forms コピー形式が変わった場合、正規表現の修正が必要になる可能性があります。
- 編入生の判定は「値が10以下」という運用上の仮定に基づいています。 - 編入生の判定は「値が10以下」という運用上の仮定に基づます。
- GPA が実際に10以下となる別形式のデータを使用する場合は、判定条件を変更してください。 - GPA が実際に10以下となるデータ形式では判定条件を変更してください。
- 回答値の誤入力は自動修正しません。 - 回答値の誤入力は自動修正しません。
- 研究室番号が存在しても回答数が0件の場合、その研究室は出力から除外されます。 - 回答数と抽出数が一致しない場合は警告を表示します。
- 回答数が0件の研究室は出力しません。
+156 -107
View File
@@ -1,9 +1,79 @@
#!/usr/bin/env python #!/usr/bin/env python
import numpy as np import argparse
import re import re
import numpy as np
import pandas as pd 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):
"""
研究室ごとの回答本文から GPA 値を取り出す。
- 「値 カウント」表がある場合は表を優先
- 表がない場合は 1 行 1 回答として読む
- 「編入生」は GPA としては読まない
- 「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): def parse_google_form_results(text):
""" """
Google Forms のアンケート結果をコピペしたテキストから、 Google Forms のアンケート結果をコピペしたテキストから、
@@ -11,21 +81,24 @@ def parse_google_form_results(text):
研究室番号 | 値 研究室番号 | 値
の DataFrame を生成する。 の DataFrame を生成する。
Parameters
----------
text : str
Google Forms 結果ページからコピーしたテキスト
Returns
-------
pandas.DataFrame
""" """
# 「第一希希望研究室...」の集計部分は除外したいので、 text = z2a(text)
# 最初の個別研究室ブロックから開始する
# 「研究室第1希望」などの集計部分を除外し、
# 最初の個別研究室ブロックから開始する。
#
# 以下の両方に対応:
#
# 研究室1
# GP7 件の回答
#
# 研究室1
# 7 件の回答
m = re.search( m = re.search(
r'^研究室\s*[0-9-]+.*?\n[0-9-]+\s*件の回答', r'^研究室\s*[0-9]+[^\n]*\n'
r'(?:[^\d\n]*?)'
r'[0-9]+\s*件の回答',
text, text,
flags=re.MULTILINE flags=re.MULTILINE
) )
@@ -37,80 +110,31 @@ def parse_google_form_results(text):
# 各研究室ブロックを取得 # 各研究室ブロックを取得
pattern = re.compile( pattern = re.compile(
r'^研究室\s*([0-9-]+).*?\n' r'^研究室\s*([0-9]+)[^\n]*\n'
r'([0-9-]+)\s*件の回答\s*\n' r'(?:[^\d\n]*?)'
r'([0-9]+)\s*件の回答\s*\n'
r'(.*?)' r'(.*?)'
r'(?=^研究室\s*[0-9-]+.*?\n[0-9-]+\s*件の回答|\Z)', r'(?='
r'^研究室\s*[0-9]+[^\n]*\n'
r'(?:[^\d\n]*?)'
r'[0-9]+\s*件の回答'
r'|\Z'
r')',
flags=re.MULTILINE | re.DOTALL flags=re.MULTILINE | re.DOTALL
) )
rows = [] rows = []
for match in pattern.finditer(text): for match in pattern.finditer(text):
lab_no_str = match.group(1) lab_no = int(match.group(1))
n_answers_str = match.group(2) n_answers = int(match.group(2))
body = match.group(3).strip() body = match.group(3).strip()
# 全角数字対応
trans = str.maketrans("0123456789", "0123456789")
lab_no = int(lab_no_str.translate(trans))
n_answers = int(n_answers_str.translate(trans))
# 回答数 0 はスキップ
if n_answers == 0: if n_answers == 0:
continue continue
# -------------------------------------------------- values = extract_values_from_body(body)
# Case 1:
# 「値 カウント」表がある場合
#
# 例:
# 値 カウント
# 300 2
# 350 2
# --------------------------------------------------
if re.search(r'^値\s+カウント\s*$', body, flags=re.MULTILINE):
table_part = re.split(
r'^値\s+カウント\s*$',
body,
maxsplit=1,
flags=re.MULTILINE
)[1]
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)
# --------------------------------------------------
# Case 2:
# 回答値が1行ずつ並んでいる場合
#
# 例:
# 210
# 333
# 300
# --------------------------------------------------
else:
values = []
for line in body.splitlines():
line = line.strip()
if re.fullmatch(r'-?\d+(?:\.\d+)?', line):
values.append(float(line))
# 回答数との整合性チェック
if len(values) != n_answers: if len(values) != n_answers:
print( print(
f"警告: 研究室{lab_no}: " f"警告: 研究室{lab_no}: "
@@ -125,61 +149,86 @@ def parse_google_form_results(text):
df = pd.DataFrame(rows) df = pd.DataFrame(rows)
# 整数値だけなら見やすくする if not df.empty and (df[""] % 1 == 0).all():
if not df.empty:
if (df[""] % 1 == 0).all():
df[""] = df[""].astype(int) df[""] = df[""].astype(int)
return df return df
if __name__ == "__main__": def summarize_by_lab(df):
import argparse """研究室ごとの在校生数・編入生数・GPA平均・標準偏差を計算する。"""
par = argparse.ArgumentParser(description="test") rows = []
par.add_argument('googleform_str')
args = par.parse_args()
# コピペしたテキストをファイルに保存しておく for lab_no in sorted(df["研究室番号"].unique()):
with open(args.googleform_str, encoding="utf-8") as f: df_lab = df.loc[df["研究室番号"] == lab_no].copy()
text = f.read()
df = parse_google_form_results(text) # 編入生は 0 として入れてある。
df_r = pd.DataFrame() df_regular = df_lab.loc[df_lab[""] > 10]
for n in np.unique(df["研究室番号"]):
df_ = df.loc[df["研究室番号"] == n, :]
df__ = df_.loc[df_[""] > 10, :]
ave, std = df__.mean(), df__.std()
df_r.loc[n, "在校生"] = df_.shape[0]
df_r.loc[n, "編入生"] = df_.shape[0] - df__.shape[0]
df_r.loc[n, "GPA mean"] = df__[""].mean()
df_r.loc[n, "GPA std"] = df__[""].std()
df_r[["在校生", "編入生"]] = df_r[["在校生", "編入生"]].astype(int)
# 表示用
df_show = df_r.copy() rows.append({
df_show.index.name = "研究室" "研究室": lab_no,
df_show["GPA mean"] = df_show["GPA mean"].round(1) "在校生": df_regular.shape[0],
df_show["GPA std"] = df_show["GPA std"].round(1) "編入生": df_lab.shape[0] - df_regular.shape[0],
"GPA mean": df_regular[""].mean(),
"GPA std": df_regular[""].std(),
})
return pd.DataFrame(rows).set_index("研究室")
def print_summary(df, df_r):
print("-" * 52) print("-" * 52)
print("回答者数:", df.shape[0]) print("回答者数:", df.shape[0])
print(f"{'研究室':>6} " print(
f"{'研究室':>6} "
f"{'在校生':>6} " f"{'在校生':>6} "
f"{'編入生':>6} " f"{'編入生':>6} "
f"{'GPA mean':>10} " f"{'GPA mean':>10} "
f"{'GPA std':>10}" f"{'GPA std':>10}"
) )
print("-" * 52) print("-" * 52)
for n, row in df_r.iterrows(): for n, row in df_r.iterrows():
mean = f"{row['GPA mean']:.1f}" mean = "-" if pd.isna(row["GPA mean"]) else f"{row['GPA mean']:.1f}"
std = "-" if pd.isna(row["GPA std"]) else f"{row['GPA std']:.1f}" std = "-" if pd.isna(row["GPA std"]) else f"{row['GPA std']:.1f}"
print( print(
f"{n:>6} " f"{n:>6} "
f"{int(row['在校生']):>8} " f"{int(row['在校生']):>8} "
f"{int(row['編入生']):>8} " f"{int(row['編入生']):>8} "
f"{mean:>10} " f"{mean:>10} "
f"{std:>10}") f"{std:>10}"
)
# outfile = __file__.replace(".py", ".xlsx")
# df.to_excel(outfile) def main():
# print(f"{outfile} was created.") parser = argparse.ArgumentParser(
description="Google Forms のコピペ結果から研究室別 GPA を集計する"
)
parser.add_argument("googleform_str", help="Google Forms からコピーしたテキストファイル")
parser.add_argument(
"-o", "--output",
help="Excel 出力ファイル名。指定しない場合は出力しない"
)
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)
print_summary(df, df_r)
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()