diff --git a/Readme.org b/Readme.org index 0821aea..59d314b 100644 --- a/Readme.org +++ b/Readme.org @@ -4,49 +4,48 @@ * 概要 -Google Forms で実施した3年生の研究室配属希望調査について、フォーム結果 -画面からコピーしたテキストを解析し、研究室ごとの希望者数および GPA の -統計量を集計する Python スクリプトです。 +Google Forms で実施した3年生の研究室配属希望調査について、結果画面から +コピーしたテキストを解析し、研究室ごとの希望者数と GPA の統計量を集計する +Python スクリプトです。 -Google Forms の管理者権限がなくCSVファイルを直接取得できない場合でも、 -結果画面に表示されたテキストをコピーしてファイルに保存することで解析で -きます。 +Google Forms の管理者権限がなく CSV を直接取得できない場合でも、結果画面の +テキストをコピーしてファイルに保存すれば解析できます。 -主な処理内容は以下のとおりです。 +主な処理内容: - Google Forms の結果テキストから研究室番号を抽出 - 各研究室の回答値を抽出 - 在校生と編入生を分類 - 研究室ごとの希望者数を集計 -- 在校生 GPA の平均値を計算 -- 在校生 GPA の標準偏差を計算 -- 元データを Excel ファイルとして保存 +- 在校生 GPA の平均値と標準偏差を計算 +- 必要に応じて元データと集計結果を Excel に保存 * 必要な環境 -Python 3 が必要です。 -使用する Python パッケージは以下です。 +Python 3 と以下のパッケージが必要です。 + - numpy - pandas - openpyxl -必要に応じて以下のようにインストールします。 + +Conda を使う場合: #+begin_src sh -condat install numpy pandas openpyxl +conda install numpy pandas openpyxl #+end_src * 入力データ -Google Forms のアンケート結果画面に表示されたテキストをコピーし、テキ -ストファイルとして保存します。 +Google Forms の結果画面に表示されたテキストをコピーし、テキストファイル +として保存します。 + +例: -例えば、 #+begin_example googleform.txt #+end_example -というファイル名で保存します。入力データには、以下のような研究室ごとの -回答結果が含まれていることを想定しています。 +研究室ごとの回答は、例えば以下の形式を想定しています。 #+begin_example 研究室10(極限環境材料科学研究室) @@ -60,8 +59,78 @@ googleform.txt 359 #+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 @@ -70,12 +139,11 @@ googleform.txt 350 2 370 1 380 1 -385 1 -400 1 -401 1 #+end_example -この場合、カウント数に応じて値を展開します。例えば、 +この場合、カウント数に応じて値を展開します。 + +例えば、 #+begin_example 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 ./analyze_lab_assignment_survey.py googleform.txt #+end_src * 出力 -実行すると、研究室ごとの集計結果が標準出力に表示されます。 -出力例: +実行すると、研究室ごとの集計結果を標準出力に表示します。 #+begin_example - 研究室 在校生 編入生 GPA mean GPA std ---------------------------------------------------- - 1 9 0 359.6 38.4 - 2 6 0 269.3 66.3 - 3 8 1 358.7 42.7 - 6 3 0 343.7 58.5 - 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 - +回答者数: 98 + 研究室 在校生 編入生 GPA mean GPA std +---------------------------------------------------- + 1 7 0 345.1 50.7 + 2 5 1 303.4 63.0 + 3 7 0 351.6 69.0 + 6 7 0 354.0 32.9 #+end_example +=GPA std= は標本標準偏差です。対象が1人の場合は =-= と表示します。 * 在校生と編入生の判定 -このスクリプトでは、回答値について +解析時には、回答値について #+begin_src python -df_["値"] > 10 +df_lab["値"] > 10 #+end_src を満たす回答を在校生の GPA データとして扱います。 したがって、 -- 値 > 10 : 在校生の GPA +- 値 > 10 : 在校生 - 値 <= 10 : 編入生 として分類します。 +現在のスクリプトでは、文字列 =編入生= を内部的に =0= として保存するため、 +この条件で編入生として分類されます。 + * GPA の統計量 -研究室ごとに、在校生のみを抽出して GPA の平均値と標準偏差を計算します。 +研究室ごとに在校生のみを抽出し、GPA の平均値と標準偏差を計算します。 -平均値: +平均値: #+begin_src python -df__["値"].mean() +df_regular["値"].mean() #+end_src -標準偏差: +標準偏差: #+begin_src python -df__["値"].std() +df_regular["値"].std() #+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 側の HTML やコピー形式が変更された場合、正規表現の修正が必要になる可能性があります。 -- 編入生の判定は「値が10以下」という運用上の仮定に基づいています。 -- GPA が実際に10以下となる別形式のデータを使用する場合は、判定条件を変更してください。 +- Google Forms のコピー形式が変わった場合、正規表現の修正が必要になる可能性があります。 +- 編入生の判定は「値が10以下」という運用上の仮定に基づきます。 +- GPA が実際に10以下となるデータ形式では判定条件を変更してください。 - 回答値の誤入力は自動修正しません。 -- 研究室番号が存在しても回答数が0件の場合、その研究室は出力から除外されます。 +- 回答数と抽出数が一致しない場合は警告を表示します。 +- 回答数が0件の研究室は出力しません。 diff --git a/analyze_lab_placement_survey.py b/analyze_lab_placement_survey.py index 5327d81..2990258 100755 --- a/analyze_lab_placement_survey.py +++ b/analyze_lab_placement_survey.py @@ -1,9 +1,79 @@ #!/usr/bin/env python -import numpy as np +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): + """ + 研究室ごとの回答本文から 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): """ Google Forms のアンケート結果をコピペしたテキストから、 @@ -11,21 +81,24 @@ def parse_google_form_results(text): 研究室番号 | 値 の DataFrame を生成する。 - - Parameters - ---------- - text : str - Google Forms 結果ページからコピーしたテキスト - - Returns - ------- - pandas.DataFrame """ - # 「第一希希望研究室...」の集計部分は除外したいので、 - # 最初の個別研究室ブロックから開始する + text = z2a(text) + + # 「研究室第1希望」などの集計部分を除外し、 + # 最初の個別研究室ブロックから開始する。 + # + # 以下の両方に対応: + # + # 研究室1 + # GP7 件の回答 + # + # 研究室1 + # 7 件の回答 m = re.search( - r'^研究室\s*[0-90-9]+.*?\n[0-90-9]+\s*件の回答', + r'^研究室\s*[0-9]+[^\n]*\n' + r'(?:[^\d\n]*?)' + r'[0-9]+\s*件の回答', text, flags=re.MULTILINE ) @@ -37,80 +110,31 @@ def parse_google_form_results(text): # 各研究室ブロックを取得 pattern = re.compile( - r'^研究室\s*([0-90-9]+).*?\n' - r'([0-90-9]+)\s*件の回答\s*\n' + r'^研究室\s*([0-9]+)[^\n]*\n' + r'(?:[^\d\n]*?)' + r'([0-9]+)\s*件の回答\s*\n' r'(.*?)' - r'(?=^研究室\s*[0-90-9]+.*?\n[0-90-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 ) rows = [] for match in pattern.finditer(text): - lab_no_str = match.group(1) - n_answers_str = match.group(2) + lab_no = int(match.group(1)) + n_answers = int(match.group(2)) 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: continue - # -------------------------------------------------- - # 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] + values = extract_values_from_body(body) - 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: print( f"警告: 研究室{lab_no}: " @@ -125,61 +149,86 @@ def parse_google_form_results(text): df = pd.DataFrame(rows) - # 整数値だけなら見やすくする - if not df.empty: - if (df["値"] % 1 == 0).all(): - df["値"] = df["値"].astype(int) + if not df.empty and (df["値"] % 1 == 0).all(): + df["値"] = df["値"].astype(int) return df -if __name__ == "__main__": - import argparse +def summarize_by_lab(df): + """研究室ごとの在校生数・編入生数・GPA平均・標準偏差を計算する。""" - par = argparse.ArgumentParser(description="test") - par.add_argument('googleform_str') - args = par.parse_args() + rows = [] - # コピペしたテキストをファイルに保存しておく - with open(args.googleform_str, encoding="utf-8") as f: - text = f.read() + for lab_no in sorted(df["研究室番号"].unique()): + df_lab = df.loc[df["研究室番号"] == lab_no].copy() - df = parse_google_form_results(text) - df_r = pd.DataFrame() - 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() - df_show.index.name = "研究室" - df_show["GPA mean"] = df_show["GPA mean"].round(1) - df_show["GPA std"] = df_show["GPA std"].round(1) - print("-" * 52) + # 編入生は 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], + "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("回答者数:", df.shape[0]) - print(f"{'研究室':>6} " + print( + f"{'研究室':>6} " f"{'在校生':>6} " f"{'編入生':>6} " f"{'GPA mean':>10} " f"{'GPA std':>10}" ) print("-" * 52) - for n, row in df_r.iterrows(): - mean = f"{row['GPA mean']:.1f}" - std = "-" if pd.isna(row["GPA std"]) else f"{row['GPA std']:.1f}" - print( - f"{n:>6} " - f"{int(row['在校生']):>8} " - f"{int(row['編入生']):>8} " - f"{mean:>10} " - f"{std:>10}") - # outfile = __file__.replace(".py", ".xlsx") - # df.to_excel(outfile) - # print(f"{outfile} was created.") + for n, row in df_r.iterrows(): + 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}" + + print( + f"{n:>6} " + f"{int(row['在校生']):>8} " + f"{int(row['編入生']):>8} " + f"{mean:>10} " + f"{std:>10}" + ) + + +def main(): + 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()