Pythonで選挙データを分析してみよう!④〜埼玉投票結果 比例代表情報 読み込み編〜

このシリーズでは、2025/07/21に行われた参院選挙結果を分析するためのプログラムを、一つずつ紐解いて解説していきます。
ついに第4弾です。これまでも手ごわいデータを相手にしてきましたが、今回はラスボス級の挑戦になります。

テーマは、比例代表の投票結果をまとめた cleanup_voting_result_by_proportional_representation.py。このスクリプトが立ち向かうのは、複数の、それぞれが独自ルールの複雑なフォーマットを持つシートが同居するというExcelファイルです。

作成したコードは Github に公開しているので、興味があれば、見てみてください。
※コードは、予告なく、改変/削除されることがあります。

今回の敵は「フォーマットの集合体」

このスクリプトが処理するExcelファイルには、「名簿登載者別得票調べ」や「得票総数の開票区別政党等別一覧」など、複数のシートが含まれています。そして、そのどれもが一筋縄ではいきません。

プログラムで読み込むとこんな感じです。

例1: 名簿登載者の得票総数の政党別一覧

届出番号,1.0,政党等の名称,日本共産党,届出番号,2.0,政党等の名称,日本維新の会,...
整理番号,名簿登載者名,,得票数,整理番号,名簿登載者名,,得票数,...
1.0,あかみね 政賢,,100.0,1.0,あおしま 健太,,200.0,...
2.0,井上 さとし,,150.0,2.0,足立 やすし,,250.0,...

政党ごとにブロックが作られ、それが横に延々と続いていくフォーマットです。ぱっと見ただけでは、どの候補者がどの政党に属しているのか、プログラムで判断するのは非常に困難です。

例2: 得票総数の開票区別政党等別一覧

届出番号,1.0,,,2.0,,,3.0,,
,日本共産党,,,日本維新の会,,,無所属連合,,
開票区名,得票総数,"政党等の得票総数",名簿登載者の得票総数,...
県 計,189666.101,176600.0,13066.101,161901.048,...
さいたま市西区,8226.999,7682.0,544.999,7590.0,...

こちらも政党が横に並んでいますが、ヘッダーが複数行にまたがっており、1つの政党が複数の列を使っています。

このように、シートごとに全く異なるルールで作られた「横長データ」の集合体。これが今回のお題です。

攻略法は「各個撃破」!

これだけルールが違うと、一つの賢いやり方で全てを解決するのは不可能です。そこで、今回のスクリプトでは**「シートごとに専用の処理関数を用意し、各個撃破する」**という戦略をとっています。

def transform_candidate_list_to_vertical(df, sheet_name):
    """
    データを横並びから縦並びに変換する関数(複数シートタイプに対応)
    """
    print(f"\n--- {sheet_name} を縦並び形式に変換中 ---")
    
    # 「名簿登載者」という名前のシートなら、専用の関数を呼ぶ
    if "名簿登載者" in sheet_name:
        return transform_candidate_data(df, sheet_name)
    
    # 「得票総数の開票区別政党等別一覧」なら、また別の専用関数を呼ぶ
    elif "得票総数の開票区別政党等別一覧" in sheet_name:
        return transform_voting_results_by_district(df, sheet_name)
    
    # ... 以下、シートごとに処理を分岐 ...

メインの処理(main関数)から呼び出されるこの関数が、司令塔の役割を果たします。シート名をチェックして、そのシートのフォーマットを解読できる専用の関数に処理を振り分けているわけですね。

深掘り解説:難解フォーマットの攻略法

では、各個撃破の具体的な中身を見てみましょう。

その1:政党ブロックが横に並ぶ『名簿登載者の得票総数の政党別一覧』

このシートは、政党ごとに候補者リストのブロックが作られ、横に並んでいます。

def transform_candidate_data(df, sheet_name):
    # ...
    transformed_data = [] # 最終的に縦長データを入れるための空のリスト
    current_parties = {}  # 今どの政党ブロックを処理しているか記憶する辞書
    
    # 1行ずつループ
    for index, row in df.iterrows():
        # ...

        # 「届出番号」という単語が含まれる行を「政党情報行」とみなす
        if "届出" in ' '.join(map(str, row.values)):
            # その行から政党情報を抜き出す。
            # 例: {0: {'届出番号': '1.0', '政党名': '日本共産党'}, 4: {'届出番号': '2.0', '政党名': '日本維新の会'}, ...}
            # キーは政党ブロックが始まる列のインデックス
            current_parties = {}
            for i in range(0, len(row), 4): # 4列ごとに1つの政党ブロック
                party_num = row.iloc[i+1]
                party_name = row.iloc[i+3]
                if pd.notna(party_num) and pd.notna(party_name):
                    current_parties[i] = {"届出番号": party_num, "政党名": party_name}
            continue # 政党情報行自体の処理はここまで

        # 候補者データ行かどうかの判定
        # 行の中に、数字に見えるセル(得票数や整理番号)があるかどうかで判断
         if any("整理" in str(cell) and "番号" in str(cell) for cell in row):
                continue  # ヘッダー行はスキップ
        
        if current_parties and any(str(cell).replace(".", "").replace(",", "").isdigit() for cell in row if str(cell).strip() != "" and str(cell) != "nan"):
            # 記憶しておいた政党ブロックの情報を使って処理
            for party_col_start, party_info in current_parties.items():
                try:
                    # 政党の開始列を基準に、各情報を取得
                    candidate_num = row.iloc[party_col_start]
                    candidate_name = row.iloc[party_col_start + 1]
                    candidate_votes = row.iloc[party_col_start + 3]
                    
                    # 候補者名が空でなく、ヘッダーでもないことを確認
                    if pd.notna(candidate_name) and "名簿登載者名" not in str(candidate_name):
                        transformed_data.append({
                            "届出番号": party_info["届出番号"],
                            "政党名": party_info["政党名"],
                            "整理番号": candidate_num,
                            "名簿登載者名": candidate_name,
                            "得票数": candidate_votes
                        })
                except IndexError:
                    # 行の列数が足りない場合のエラーを回避
                    continue
    
    return pd.DataFrame(transformed_data)

この処理のミソは、状態(ステート)を持つことです。

  1. まず、政党の情報が書かれた行を探し、その情報を current_parties という辞書に記憶します。「今は〇〇党のブロックを見ていて、それは△列目から始まっている」という状態ですね。
  2. 次の行に進んだとき、その行が候補者データ行かどうかを「数字に見えるデータがあるか」で判定します。ヘッダー行にはあまり数字が出てこない、という特徴を利用した賢い方法です。
  3. データ行だと判断できれば、記憶しておいた current_parties の情報を参照し、「今は〇〇党のブロックの中だから、この行の△列目にあるのは〇〇党の候補者の情報のはずだ」と判断して、データを正しく紐付けて抽出できるわけです。

このように、前の行の情報を記憶しながら処理を進めることで、複雑にブロック化されたデータも正確に読み解くことができます。

その2:複数行ヘッダーを持つ『得票総数の開票区別政党等別一覧』

次はこちらのシートです。政党名が書かれたヘッダーが複数行に分かれており、1つの政党が複数の列を占有しています。

def transform_voting_results_by_district(df, sheet_name):
    # (実際のコードでは、この関数はさらに extract_all_missing_district_data_from_excel を呼び出します)
    
    # 正確な16政党のリストをあらかじめ定義
    all_parties = [
        '日本共産党', '日本維新の会', '無所属連合', '日本保守党', '立憲民主党',
        '参政党', '国民民主党', 'チームみらい', '日本誠真会', '社会民主党',
        'れいわ新選組', '日本改革党', '自由民主党', '再生の道', '公明党', 'NHK党'
    ]
    
    transformed_data = []
    
    # 1. 政党ヘッダー行を動的に検出
    party_sections = []
    for i in range(len(df)):
        row_data = [str(cell).strip() for cell in df.iloc[i]]
        parties_in_row = []
        for j, cell in enumerate(row_data):
            if cell in all_parties:
                parties_in_row.append((j, cell))
        
        if parties_in_row:
            party_sections.append({
                'header_row': i,
                'parties': parties_in_row
            })

    # 2. 各セクションからデータを抽出
    for section_idx, section in enumerate(party_sections):
        data_start = section['header_row']
        data_end = party_sections[section_idx + 1]['header_row'] if section_idx + 1 < len(party_sections) else len(df)
        
        while current_row < data_end and current_row < len(df):
            row_data = [str(cell).strip() for cell in df.iloc[current_row]]
            # 空行や無効行をスキップ
            if not any(cell and cell != 'nan' for cell in row_data):
                current_row += 1
                continue
                
            # 市区町村名を取得(A列)
            district_name = row_data[0] if row_data[0] and row_data[0] != 'nan' else None
                
            # 無効な区名をスキップ
            if (not district_name or '計' in district_name or 
                district_name in ['政党等名', '届出番号', '開票区名'] or
                len(district_name) > 20):
                current_row += 1
                continue


            # 3. ヘッダー情報を使って、各政党の得票数を取得
            for col_idx, party_name in section['parties']:
                # 得票数を取得(政党名の列の右側1-3列を確認して探すなど、より複雑なロジック)
                vote_count = None
                for vote_col in range(col_idx, min(col_idx + 4, len(row_data))):
                    cell_value = row_data[vote_col]
                    if cell_value and cell_value != 'nan':
                        try:
                            clean_value = cell_value.replace(',', '').replace(' ', '')
                            vote_count = int(float(clean_value))
                            if vote_count >= 0:
                                break
                        except (ValueError, TypeError):
                            continue
                
                if vote_count is not None:
                    transformed_data.append({
                        "開票区": district_name,
                        "政党等名": party_name,
                        "得票数": vote_count
                    })
    
    # 4. 不足データを補完するための最終手段
    # ここまでの処理で取りこぼしたデータがないかチェックし、
    # あれば再度Excelファイルを直接読み込んで補完する関数を呼び出す
    if has_incomplete_data(transformed_data):
        additional_data = extract_all_missing_district_data_from_excel()
        # ... 取得した追加データをマージする処理 ...
    
    return pd.DataFrame(transformed_data)

こちらの攻略法は、事前の偵察と、念のための再捜索という二段構えが鍵です。

  1. 偵察: 本格的な処理の前に、まずシート全体を偵察し、どこに「政党ヘッダー」があるのか、その位置(行と列)を全てリストアップしておきます。
  2. 各個撃破: 次に、データが書かれている行(市区町村の行)を一つずつ処理し、偵察で得たヘッダー情報を元にデータを紐づけていきます。
  3. 再捜索: しかし、複雑なフォーマットではどうしてもデータの取りこぼしが発生しがちです。そこで、ここまでの処理で全市区町村・全政党のデータが揃っているかを確認します。もし不足があれば、もう一度Excelファイルを素の状態から読み込み、異なるアプローチで不足データをピンポイントで探しに行く extract_all_missing_district_data_from_excel という最終手段が発動します。

この「念には念を入れる」アプローチによって、非常に高い精度でデータを抽出できるわけですね。

まとめ

今回は、複数の複雑なフォーマットが混在する、最強の敵との戦いを見てきました。

  • フォーマットが複数あるなら、無理に一つの方法でやろうとせず、それぞれに専用の処理を用意する「各個撃破」が有効。
  • ブロック構造を持つデータには、今どのブロックを処理しているかという「状態」を記憶しながら読み進めるアプローチが効く。
  • ヘッダー構造が複雑なデータには、まず全体の構造を「偵察」し、さらに処理漏れを補完する「再捜索」を行う二段構えのアプローチが有効。

一見すると、とてつもなく複雑でどこから手をつけていいか分からないデータでも、このようにルールを一つずつ見つけて、それをプログラムに落とし込んでいくことで、必ず攻略できます。まさにデータ分析の醍醐味と言えるかもしれませんね。