🐕

pythonを使ってアンケート生データを処理してみた

に公開

対象読者

  • Excel は触れるけど Python はこれから
  • クロス集計を毎回手で作るのがつらい

前提

今回、業者に頼んでアンケートを取ったんですが、アンケート解析をもっとしたい!
けど、納品まで終わってしまった・・・
自分でクロス分析とかしたいけど、手作業だと時間がかかる・・・
と言うのを解決するために、作ってみました。
どのようなデータかは割愛しますが、今回のものをカスタマイズすれば、業務の自動化ができると思います。

セル0,1 準備: Colab ノートブックを5セルに分ける

cell0
# 0️⃣ Colab: 必要ライブラリ
!pip install --quiet openpyxl==3.1.2 pandas ipywidgets
cell1
# 1️⃣ Excel をアップロード(複数可)
from google.colab import files
uploaded = files.upload()

以降は

セル 役割
2 parse_format / build_raw 共通関数
3 アップロードしたファイルに raw シートを作る
4 made_cross(クロス表を付与)
5 ドロップダウン GUI & 連続実行

という構成で進めます。


セル2 ~投入されたFormatシートを読み解き、人間が読みやすいrawシートをつくる関数を設定するセル~

役割 どのシートを参照 このセルを終えると出来ること
1️⃣ parse_format() Format 「質問コード → 質問タイプ」を辞書で取得。選択肢範囲やタイトル行番号も同時にメモする。
2️⃣ build_raw() data / Format 回答データを raw シートにコピーしつつ、セルに数式を自動挿入。Excel を開くだけで可読値が表示される。

cell2_full
from openpyxl.utils import get_column_letter

def parse_format(ws):
    need = {"Column","Question","Type","CtgNo","Title"}
    hdr  = {h.value.strip(): c                       # <①>
            for c,h in enumerate(ws[1],1)
            if (h.value or "").strip() in need}
    miss = need - hdr.keys()
    if miss:
        raise ValueError(f"Format に欠落: {miss}")

    Q,T,CNO,TTL = hdr["Question"],hdr["Type"],hdr["CtgNo"],hdr["Title"]
    type_map,s_ranges,ml_titles,title_map = {},{},{},{}
    cur=None
    for r in range(2, ws.max_row+1):                 # <②>
        q = ws.cell(r,Q).value
        t = (ws.cell(r,T).value or "").strip()
        if q:                                        # 質問コードの行
            cur=t or cur
            type_map[q]=cur
            title_map[q]=ws.cell(r,TTL).value
            if cur in ("S","SL"):                    # 選択肢範囲を覚える
                s=r+1
                while s<=ws.max_row and ws.cell(s,CNO).value not in ("",None):
                    s+=1
                s_ranges[q]=(r+1,s-1)
            if cur in ("ML","M"):                    # タイトル行
                ml_titles[q]=r
        elif cur in ("ML","M") and ws.cell(r,Q).value:  # サブ質問行
            sub=ws.cell(r,Q).value
            type_map[sub]=cur
            title_map[sub]=ws.cell(r,TTL).value
            ml_titles[sub]=r
    return type_map,s_ranges,ml_titles,title_map,hdr


def build_raw(wb, tmap, srg, mlt, hdr,
              data=\"data\", fmt=\"Format\", raw=\"raw\"):
    ws_d, ws_f = wb[data], wb[fmt]
    ws_r = wb[raw] if raw in wb.sheetnames else wb.create_sheet(raw)
    ws_r.delete_rows(1, ws_r.max_row)               # ← 再実行時にゴミを消す

    c_cno = get_column_letter(hdr[\"CtgNo\"])
    c_ttl = get_column_letter(hdr[\"Title\"])

    # ① ヘッダーコピー ----------------------------------------
    for c,cell in enumerate(ws_d[1],1):
        ws_r.cell(1,c,cell.value)

    # ② 回答行ループ -----------------------------------------
    for r in range(2, ws_d.max_row+1):
        for c in range(1, ws_d.max_column+1):
            key  = ws_r.cell(1,c).value            # 列ヘッダー = Question
            colL = get_column_letter(c)
            dref = f\"{data}!{colL}{r}\"
            typ  = tmap.get(key,\"\")              # 質問タイプ

            # ③ タイプ別に数式を生成 ----------------------------
            if typ in (\"S\",\"SL\"):
                if key in srg:                     # VLOOKUP 範囲がある
                    s,e = srg[key]
                    rng = f\"{fmt}!${c_cno}${s}:${c_ttl}${e}\"
                    formula = f\"=IFERROR(VLOOKUP({dref},{rng},2,FALSE),\\\"\\\")\"
                else:
                    formula = f\"={dref}\"        # 範囲が無ければ値丸コピー

            elif typ==\"N\":                       # 数値はそのまま
                formula = f\"={dref}\"

            elif typ in (\"X\",\"XL\"):            # 自由入力:空白ケア
                formula = f\"=IF({dref}=\\\"\\\",\\\"\\\",{dref})\"

            elif typ in (\"ML\",\"M\"):            # 複数 / 単一フラグ
                row = mlt.get(key)
                tref= f\"{fmt}!${c_ttl}${row}\" if row else \"\\\"\\\"\"
                formula = f\"=IF({dref}=1,{tref},\\\"\\\")\"

            else:                                  # 不明タイプ→セル参照
                formula = f\"={dref}\"

            ws_r.cell(r,c,formula)

parse_format() を用いて、Formatシートを読み解く

処理 例え話
<①> ヘッダー検出 1 行目だけを眺めColumn / Question / … が各何列目かを拾う。 「見出しが何列目にあるかの地図」を先につくる。
<②> ループ 2 行目から最終行まで Format をスキャン。 「質問行」を見つけたら タイプ を覚える→次の処理で利用。
type_map {\"AQ3_1\":\"ML\", \"AQ6\":\"S\", …} 「質問コードを渡すとタイプが返る辞書」。後で raw 生成・cross 集計の核になる。
s_ranges { \"AQ6\": (行12, 行20) } S/SL 質問は選択肢を Format!E:F から VLOOKUP したい。範囲を動的に拾うための辞書。
ml_titles { \"AQ3_1[5]\": 35 } ML/M は “タイトル行番号” が必要(1→○○ と変換)。
title_map { \"AQ3_1[5]\":\"電子チケット…\" } サブ質問コード→ラベル文字列。GUI、cross 表の列名に再利用。

💡 イメージしやすいポイント

  • Format シートは「辞書生成工場」
    いきなり計算せず “情報を抜き出すだけ” に徹する。
  • だから 1 回呼ぶだけで 後続セルが何回でも使い回せる。

build_raw()関数で数字の“生データ”を“人間が読見やすい”形へ

ステップ 参照シート 説明 見える成果
ヘッダーコピー data 1 行目をそのまま raw に転写 raw の 1 行目 = 元データの列名
行ループ data 2 行目〜最終行を走査 行数は増えず処理量はデータ量に比例
列ループ data / Format 質問タイプを見て 数式 を組み立て raw のセルは値ではなく数式
VLOOKUP Format!E:F S/SL→回答番号→ラベルに変換 Excel で開くと “1 → 男性” のように可読値
IF で空白処理 X/XL タイプは “空文字なら空セル” データ入力忘れでも余分な 0 が出ない
IF(dref=1, label, "") ML/M フラグ型回答をラベルに展開 1 行に “1” があればラベル文字列

ポイント

  • raw シート のセルは 計算結果ではなく数式 なので、
    データ修正があっても Excel 側で再計算すれば常に最新。
  • 生データを壊さず、新しいシートで加工する安全運転。

今回の事例における“タイプ別の数式”

タイプ 数式テンプレート 出力例 (Excel で見える値)
S / SL VLOOKUP() 1覚えていない
N =data!B5 23 (数値そのまま)
X / XL IF(空なら空, それ以外は値) 空欄 → 空欄 / abcabc
ML / M IF(flag=1, ラベル, \"\") 1電子チケット発行可

結果

  • Format シート → “辞書の山” : あらゆる変換計算の基礎データ
  • data → raw : 見やすさ・参照のしやすさを両立
  • 以降のセルでは raw辞書 を武器に「クロス分析」「GUI」などを自由に積み上げるだけ

Python 初学者は「辞書を作る=情報抽出専門の関数」「数式を書く=変換専門の関数」と分けて書くと、後から機能を足しても迷子になりません。

セル 3 ~セル2で理解したものをrawシート付きとして出力するセル〜

ゴール:アップロードした Excel をすべて
① Format を解析 → ② raw シートを追加 → ③ メモリに保持
するところまで自動化します。

このセルでやること 参照シート 使う関数 出力
1. ファイルを開く load_workbook() wb (Workbook オブジェクト)
2. Format を辞書化 Format parse_format() (セル 2で定義) tmap / srg / mlt / hdr
3. raw シートを作る data / Format build_raw() (セル 2) raw が付いた Workbook
4. メモリに保存 wb_objects[ファイル名] = wb
5. _raw.xlsx を保存 wb.save() 確認用ファイル

cell3_full
from pathlib import Path
from openpyxl import load_workbook

wb_objects = {}                       # 後で cross を作るために保持

for fn in uploaded.keys():            # アップロードしたファイルを順に処理
    wb = load_workbook(fn, data_only=False)          # ❶

    # ❷ Format を解析して 4つの辞書 + hdr を得る
    tmap, srg, mlt, ttl, hdr = parse_format(wb["Format"])

    # ❸ data → raw を生成
    build_raw(wb, tmap, srg, mlt, hdr)

    wb_objects[fn] = wb               # ❹

    # ❺ ファイル名_raw.xlsx で保存
    out = Path(fn).with_stem(Path(fn).stem + "_raw.xlsx")
    wb.save(out)
    # files.download(out)             # 必要ならコメント解除して即DL

print("raw シート生成完了")

ステップごとの超ていねい解説

load_workbook(fn, data_only=False)

キーワード 意味
fn アップロードしたファイル名(uploaded 辞書のキー)
data_only=False 数式を消さずに読み込む オプション。raw シートでは 数式そのもの を書きたいので必須。

イメージ:Excel を「編集モード」で開く感じ。
Python からセルオブジェクトを自由に書き換えられる。

parse_format(wb["Format"])

セル 2 で作った「Format 辞書工場」を呼び出し。

  • 何を返す?

    • tmap : 質問コード → タイプ (S/ML/M…)
    • srg : S/SL 質問 → (開始行, 終了行)
    • mlt : ML/M サブ質問 → タイトル行番号
    • ttl : 質問コード → タイトル文字列(GUI で欲しい)
    • hdr : ヘッダー名 → 列番号
  • なぜ必要?build_raw() が “どのセルにどの数式を入れるか” を決める材料。

build_raw(wb, …)

  • 入力:今読み込んだ wb と ❷ で作った辞書類

  • 処理

    1. data シートから 1 行目ヘッダーを raw にコピー
    2. 2 行目以降をループ→列ごとにタイプを判定→数式をセルに書き込む
  • 結果raw シートが Workbook に追加される。

ここで Excel はまだ保存されていない → メモリ上の wb が更新されただけ。

wb_objects[fn] = wb

  • 目的:あとでセル 5(GUI)からクロス集計を作るために メモリ上の Workbook を保持
  • メリット:再度ファイルを開き直さなくて済む → 速度&メモリ節約。

_raw.xlsx を保存

  • なぜ別名?

    • 元データを壊さずに「raw 付き版」を手元に残すため。
  • files.download(out) をコメントアウトした理由

    • 試行錯誤中は Colab の VM に置いておくだけで十分。
    • すぐダウンロードしたい場合はコメントを外せばブラウザにリンクが出る。

やったこと

  1. Format シートを解析 → 辞書が出来る
    └─「質問タイプ」「選択肢行範囲」が Python に取り込まれた
  2. data → raw 変換
    └─ raw シートには 数式入りで見やすい回答テーブル が生成
  3. Workbook を保持
    └─ 次のセルでクロス表を作るとき「再ロード不要」で高速

💡 ポイント:セル 3 は「準備セル」。ここで raw 作成と辞書生成 を一気に終わらせ、後続セルは「クロス表ロジック」に集中できる構成になっています。

セル4 ~dataシートをクロス集計表に変換するセル~

やること 参照するシート 主な関数 生成物
1. 質問メタを再取得 Format parse_format() tmap / srg / …
2. 選んだ 2 質問の「選択肢リスト」を作る Format / data explode() 行ラベル・列ラベル
3. COUNTIFS 数式を raw から貼る data made_cross() 本体 件数セル
4. 行合計・行内%・列合計を入れる cross(作成中) 同上 自動再計算セル
5. 複数表を書く場合は 3 行空ける cross 同上 2つ目以降の表配置

前提: セル 3 でメモリ内に保存しておいた
wb_objects["ファイル名"](raw シート付き Workbook)を利用します。
“どの質問ペアでクロス集計するか” は セル 5 の GUI で選択します。


cell4_full
import re, pandas as pd
from openpyxl.utils import get_column_letter
from openpyxl import load_workbook

def made_cross(wb, q_row, q_col,
               sheet_data="data", sheet_fmt="Format", sheet_cross="cross"):
    """raw → cross シートを生成し、件数・行%・行合計・列合計を挿入。
       すでに cross シートがあれば 3 行空けて追記する。"""

    # 0) シートを取得 --------------------------------------
    wsF, wsD = wb[sheet_fmt], wb[sheet_data]

    # 1) Format 解析 ---------------------------------------
    tmap, srg, mlt, ttl, hdr = parse_format(wsF)

    def explode(q):
        """質問コード q から
           - mode: 'single' or 'multi'
           - opts: ラベル一覧
           - vals: COUNTIFS で比較する値
           - cols: data シート上の列記号
        を返すユーティリティ"""
        typ = tmap[q]

        if typ in ("S", "SL"):               # ▼単一選択
            s, e = srg[q]
            opts, vals = [], []
            for r in range(s, e+1):
                opts.append(wsF.cell(r, hdr["Title"]).value)
                vals.append(wsF.cell(r, hdr["CtgNo"]).value)
            colL = get_column_letter(
                [i+1 for i, v in enumerate(wsD[1]) if v.value == q][0]
            )
            return {"mode":"single", "opts":opts, "vals":vals,
                    "cols":[colL]*len(opts)}

        elif typ in ("M", "ML"):             # ▼複数選択(MA)
            subs = [k for k in tmap if re.match(fr"^{re.escape(q)}\\[\\d+\\]$", k)]
            opts = [ttl[s] for s in subs]    # サブ質問のラベル
            cols = [get_column_letter(
                [i+1 for i, v in enumerate(wsD[1]) if v.value == s][0]) for s in subs]
            return {"mode":"multi", "opts":opts, "vals":[1]*len(opts), "cols":cols}
        else:
            raise ValueError(f"{q} はクロス対象外 (type={typ})")

    R, C = explode(q_row), explode(q_col)    # 行 / 列のメタ
    sr, er = 2, wsD.max_row                 # data! の行範囲

    # 2) cross シートの開始行 -------------------------------
    if sheet_cross in wb.sheetnames:
        wsX = wb[sheet_cross]
        start = wsX.max_row + 4             # 3 行空けて追記
    else:
        wsX = wb.create_sheet(sheet_cross)
        start = 1

    # 3) 見出し行 ------------------------------------------
    cur = start
    wsX.cell(cur, 1, f"{q_row} × {q_col}(COUNTIFS)")
    for j, h in enumerate(C["opts"], 2):
        wsX.cell(cur, j, h)
    total_col = len(C["opts"]) + 2
    wsX.cell(cur, total_col, "合計")
    cur += 1

    first_data_row = cur

    # 4) 件数セル・行合計・行内% ----------------------------
    for r_opt, r_val, r_col in zip(R["opts"], R["vals"], R["cols"]):
        wsX.cell(cur, 1, r_opt)
        rng1 = f"{sheet_data}!${r_col}${sr}:${r_col}${er}"

        for c_idx, (c_val, c_col) in enumerate(zip(C["vals"], C["cols"]), 2):
            rng2 = f"{sheet_data}!${c_col}${sr}:${c_col}${er}"
            wsX.cell(cur, c_idx,
                     f"=COUNTIFS({rng1},{r_val},{rng2},{c_val})")

        # 行合計
        left  = get_column_letter(2)
        right = get_column_letter(total_col - 1)
        wsX.cell(cur, total_col,
                 f"=SUM({left}{cur}:{right}{cur})")

        # 行内%
        pct_row = cur + 1
        wsX.cell(pct_row, 1, "")
        for c in range(2, total_col):
            L = get_column_letter(c)
            T = get_column_letter(total_col)
            wsX.cell(pct_row, c,
                     f"=IF({T}{cur}=0,\"\",ROUND({L}{cur}/{T}{cur}*100,2)&\"%\")")
        cur += 2                              # (件数行+%行)

    last_data_row = cur - 2                  # 最後の件数行

    # 5) 列合計(件数行だけ合算) ----------------------------
    wsX.cell(cur, 1, "合計")
    for c in range(2, total_col + 1):
        L = get_column_letter(c)
        refs = ",".join(f\"{L}{r}\" for r in range(first_data_row, last_data_row+1, 2))
        wsX.cell(cur, c, f\"=SUM({refs})\")

    return wb

4-2 パーツ別にやさしく分解

コメントタグ 何をしている? 参照シート ポイント
0) wb[sheet_fmt] wb[sheet_data] でシートを変数に。 Format / data wb[\"シート名\"]Worksheet オブジェクト取得。
1) explode()質問コード → 選択肢ラベル一覧 を作る。 Format & data 単一選択 は 1 列固定、複数選択 はサブ列ごとに 1 or 空白。
2) cross シートの開始行を決める。 cross 既存表があれば「最終行 + 4」で 3 行空ける =視認性。
3) 表タイトル + 列見出しを出力。 cross total_col = 列数 + 1 で右端「合計」列を予約。
4) 行ループ → COUNTIFS → 行合計 → 行内%。 data COUNTIFS は「列範囲, 比較値」ペアで複数指定できる関数。
5) 列合計行。奇数行(件数行)だけを SUM() cross % 行 を合算に含めないため、行番号をスキップ=range(...,2)

explode() が返す辞書イメージ

Key single (S/SL) multi (M/ML)
Type "single" "multi"
Title [0円〜, 500円〜, …] サブ質問のタイトル文字列
CtgNo [1, 2, 3, …] [1, 1, 1, …] (フラグ)
Column ['D', 'D', …] (全部同じ列) ['F', 'G', …] (サブ列ごと)

なぜ必要?
COUNTIFS(range1, val1, range2, val2)rangeval
「行×列のマス目ごと」に組み合わせるため。

  • raw シートを 直接参照 しない
    → “最終ユーザーは data シートを書き換えるだけ” で最新化。
  • 行・列合計が付くので 合計の過不足チェック がすぐ出来る。

セル4が後続セルへ渡すもの

渡すもの 使い道 受け取るセル
更新済み wb GUI から保存・DL セル 5 (finish() 内)
cross シート 完成したクロス表 Excel ユーザーが開いて確認

ポイント:セル 4 までで “計算ロジック” は完結。
セル 5 は ユーザー I/O(質問選択&保存) だけに集中できる構成です。

セル5 ~GUIで「質問ペア → クロス表」生成&最終保存できる機能~

ここまでのセルで raw シート生成(セル 3)と
クロス集計ロジック(セル 4)が完成。
セル 5 は “Python が苦手な人” でも触れる
「質問を選ぶドロップダウン+ボタン2つ」 の UI を用意します。

役割 触るオブジェクト 何が起こる?
1. ドロップダウンで質問を選ぶ ipywidgets.Dropdown クリック1回で “縦軸 / 横軸” をセット
2. クロス分析を追加 ボタン run_btn セル 4 の made_cross() を呼び、cross シートに表を追記
3. 終了してダウンロード ボタン fin_btn 加工済み Workbook を保存→ブラウザにダウンロードリンク
4. ステータス表示 widgets.Label 進捗を「クロス分析中…☑完了」などと表示

コード全文

cell5_full
import ipywidgets as widgets
from IPython.display import display
from google.colab import files
import re

# 1️⃣ raw 付き Workbook を1つ選ぶ --------------------------
fn0 = next(iter(wb_objects))     # 1ファイル想定ならこれで十分
wb   = wb_objects[fn0]

# 2️⃣ ドロップダウン用の選択肢を作る -------------------------
tmap, *_ = parse_format(wb["Format"])
valid_t  = ("S", "SL", "M", "ML")
is_parent = lambda q: not re.search(r"\[\\d+\\]$", q)
q_list = sorted([q for q, typ in tmap.items()
                 if typ in valid_t and is_parent(q)])

row_dd = widgets.Dropdown(options=q_list, description="縦軸")
col_dd = widgets.Dropdown(options=q_list, description="横軸")

# 3️⃣ ボタンとステータス ------------------------------------
run_btn = widgets.Button(description="クロス分析を追加")
fin_btn = widgets.Button(description="終了してダウンロード",
                         button_style="danger")
status  = widgets.Label(value="")     # 進捗テキストをここに出す

# 4️⃣ ボタンイベント ----------------------------------------
def do_cross(_):
    status.value = "クロス分析中..."
    made_cross(wb, row_dd.value, col_dd.value)          # ★ セル4 関数
    status.value = f"☑ 完了: {row_dd.value} × {col_dd.value}"

def finish(_):
    status.value = "保存中..."
    out = "with_cross.xlsx"
    wb.save(out)
    files.download(out)                                  # ブラウザにDLリンク
    status.value = "✅ ダウンロードを開始しました"
    run_btn.disabled = True
    fin_btn.disabled = True

run_btn.on_click(do_cross)
fin_btn.on_click(finish)

# 5️⃣ 画面に並べて表示 -------------------------------------
display(row_dd, col_dd, run_btn, fin_btn, status)

ポイント解説

Workbook 選択 (wb_objects)

  • セル 3 で作った wb_objects辞書:
    {"sample.xlsx": Workbookオブジェクト, …}

  • next(iter(wb_objects))最初のキー を取り出し、対応する Workbook を取得。

    • 複数ファイル扱いたい場合は Dropdown でファイル名も選ばせると応用可。

ドロップダウンの元ネタ (q_list)

  • parse_format() で得た tmap から

    • タイプが S/SL/M/ML親質問だけ 抽出
    • [n] が付くサブ質問は GUI に出さない(ユーザーが混乱しないように)。
  • これで「縦軸」「横軸」をワンクリック選択可能。

ボタンのイベント (on_click)

関数 いつ呼ばれる 何をする 関連セル
do_cross() 「クロス分析を追加」を押した瞬間 - status を「クロス分析中…」に更新
- セル 4 の made_cross() を呼び出し、cross シートに表を追記
セル 4
finish() 「終了してダウンロード」 - Workbook を with_cross.xlsx として保存
- files.download() でブラウザにリンク表示
- ボタン2つを無効化

status.value はただの 文字列ラベル
ユーザーに「今処理中/完了」を見せるだけでロジックには無関係。

連続クロス分析に対応

  • ボタンを何度でも押せる → made_cross() 内で 3 行空けて追記 される。
  • 追加するたびに status が更新されるので処理が終わったのがわかる。

ダウンロードは最後に一回だけ

  • raw 生成時点ではファイルを書き出さず、ユーザーが「終了」したら保存

    • 無駄な I/O が減り Colab のストレージ節約。
    • ユーザーが間違えて閉じても、途中保存せずに済む → ゴミファイル激減。

結果として

  1. ドロップダウン選択
    → 変数 row_dd.value, col_dd.value に質問コードが入る
  2. クロス分析を追加
    made_cross() へ質問ペアを渡す
    → cross シート最下部に新しい表が 3 行空いて 追加
  3. 終了してダウンロード
    → いまメモリにある Workbook が with_cross.xlsx として保存
    → ブラウザにリンク → ローカル PC にダウンロード
  4. 結果
    → 1 ファイルに 複数クロス表 が “階段状” に並び、
    行合計・列合計・行内% まで自動計算済み。

初心者でも「ボタン2つでクロス表が量産できる」環境の出来上がりです。

終わりに

初めて業務に役立つpython開発を行いました。楽しかったですが、どのようにしたらpythonが理解しやすいように説明するかが肝でした。

Discussion