pandas explode()の使い方|リスト列を複数行に展開する方法

アンケートの複数回答や商品タグのように、1つのセルに複数の値が入っていて、要素ごとに集計できないことがあります。

セルの値がリスト形式なら、pandasexplode()を使うと、リストの各要素を1行ずつに展開できます。

df.explode("列名", ignore_index=True)

ただし、カンマ区切りの文字列はそのままでは分割されません。また、展開後は行数が増え、指定していない列の値も繰り返されます。この記事では、コードを動かすだけでなく、行数・インデックス・空リスト・欠損値を確認し、集計結果が正しいか検算する方法まで説明します。

この記事でわかること

  • explode()でリスト形式の列を1要素1行へ展開する方法
  • ignore_index=Trueを使う場面
  • explode()str.split()melt()・横方向展開の選び方
  • 複数列を同時に展開する条件
  • 空リスト、NaN、通常の文字列がどう扱われるか
  • ValueErrorや想定外の行数が生じたときの確認・修正方法
  • 展開後にvalue_counts()で件数を集計し、合計を検算する方法

explode()は、データ確認後、集計や可視化へ進む前にデータの形を整える処理です。

最初に確認:何をどの方向へ分けたいか

似た処理でも、入力データと目的によって使う方法が変わります。

方法 入力の状態 求める結果 選ぶ場面 注意点
explode() セル内がリスト・タプルなど 各要素を縦方向の行へ展開 複数回答、タグ、商品一覧 行数が増え、元インデックスが繰り返される
str.split()explode() セル内がカンマなどで区切られた文字列 文字列を分割してから行へ展開 "Python,SQL"のような値 区切り文字と前後空白を確認する
melt() 複数の列に値が分かれている 列名と値を縦持ちへ変換 月別列、科目別列など セル内のリストを分ける処理ではない

今回の目的は「セル内の複数回答を、回答1件につき1行へ変える」ことなので、explode()を選びます。なお、リスト要素を横方向の別列へ広げたい場合はapply(pd.Series)などを使いますが、この記事では深入りしません。

基本例:複数回答のリストを1要素1行へ展開する

実行前のデータを確認する

回答者ID、年代、学びたいテーマを持つアンケートデータを使います。学びたいテーマ列には、文字列ではなくPythonのリストが入っています。

import pandas as pd
import numpy as np

survey = pd.DataFrame({
    "回答者ID": [101, 102, 103],
    "年代": ["20代", "30代", "20代"],
    "学びたいテーマ": [
        ["Python", "pandas"],
        ["pandas", "可視化", "SQL"],
        ["Python"]
    ]
})

survey
回答者ID 年代 学びたいテーマ
0 101 20代 [Python, pandas]
1 102 30代 [pandas, 可視化, SQL]
2 103 20代 [Python]

実行前は3行です。学びたいテーマ列のリストには、それぞれ2個、3個、1個の要素があります。合計は6個なので、すべてを展開すると6行になるはずです。

回答者ID 年代 学びたいテーマ
101 20代 [Python, pandas]
102 30代 [pandas, 可視化, SQL]
103 20代 [Python]

explode()を実行する

まずは元インデックスを残して展開します。explode()は元のDataFrameを自動で上書きしないため、結果を新しい変数へ代入します。

survey_long = survey.explode("学びたいテーマ")
survey_long
回答者ID 年代 学びたいテーマ
0 101 20代 Python
0 101 20代 pandas
1 102 30代 pandas
1 102 30代 可視化
1 102 30代 SQL
2 103 20代 Python

出力では、次の3点に注目してください。

  1. 学びたいテーマの各要素が別の行になっています。
  2. 回答者ID年代は、展開された各行へ繰り返されています。
  3. 左端のインデックスは0, 0, 1, 1, 1, 2となり、元の行番号が保持されています。

インデックスの重複は、重複回答が発生したという意味ではありません。同じ元レコードから展開された行であることを示しています。

ignore_index=Trueで連番にする

元の行番号を残す必要がなく、展開後の行へ0から連番を付けたい場合は、ignore_index=Trueを指定します。

survey_long = survey.explode("学びたいテーマ", ignore_index=True)
survey_long
回答者ID 年代 学びたいテーマ
0 101 20代 Python
1 101 20代 pandas
2 102 30代 pandas
3 102 30代 可視化
4 102 30代 SQL
5 103 20代 Python

学びたいテーマの内容は同じですが、インデックスが0から5までの連番になりました。

指定 展開後のインデックス 選ぶ場面
ignore_index=False(初期値) 元のインデックスを繰り返す どの元行から展開されたか追跡したい
ignore_index=True 0から連番を振り直す 展開後の表を新しいデータとして扱いたい

すでにignore_index=Falseで展開した結果なら、reset_index(drop=True)でも後から連番へ直せます。

展開前後の行数を検算する

コードがエラーなく動いても、期待した件数になっているとは限りません。リスト内の要素数を合計し、展開後の行数と比較します。

expected_rows = survey["学びたいテーマ"].map(len).sum()
actual_rows = len(survey_long)

print(f"展開前の行数: {len(survey)}")
print(f"リスト内の要素数合計: {expected_rows}")
print(f"展開後の行数: {actual_rows}")
print(f"件数は一致: {expected_rows == actual_rows}")
展開前の行数: 3
リスト内の要素数合計: 6
展開後の行数: 6
件数は一致: True

要素数合計と展開後の行数は、どちらも6です。これで、今回のデータでは回答を欠落・重複させず展開できたと確認できます。

ただし、空リストやNaNを含む場合は、単純な要素数合計と展開後行数が一致しないことがあります。後半で、その理由を確認します。

カンマ区切りの文字列は、先にstr.split()で分割する

explode()が展開するのは、リストやタプルなどのlist-likeな値です。"Python, pandas"のような通常の文字列を指定しても、カンマや1文字ごとには分かれません。

次のデータで違いを確認します。

survey_text = pd.DataFrame({
    "回答者ID": [201, 202],
    "学びたいテーマ": ["Python, pandas", "可視化, SQL"]
})

not_split = survey_text.explode("学びたいテーマ", ignore_index=True)
not_split
回答者ID 学びたいテーマ
0 201 Python, pandas
1 202 可視化, SQL

行数は2行のままで、文字列は分割されていません。これはエラーではなく、explode()の仕様どおりの結果です。

確認方法と修正方法

  • 現象explode()を実行しても行が増えない
  • 原因:セルの値がリストではなく1つの文字列
  • 確認方法:対象列の値と型を確認する
  • 修正方法str.split()でリスト化し、前後空白を除いてから展開する
print("変換前の値:", survey_text.loc[0, "学びたいテーマ"])
print("変換前の型:", type(survey_text.loc[0, "学びたいテーマ"]).__name__)

survey_text_fixed = survey_text.assign(
    学びたいテーマ=survey_text["学びたいテーマ"].str.split(",")
)

survey_text_fixed["学びたいテーマ"] = survey_text_fixed["学びたいテーマ"].map(
    lambda items: [item.strip() for item in items]
)

text_long = survey_text_fixed.explode("学びたいテーマ", ignore_index=True)
text_long
変換前の値: Python, pandas
変換前の型: str
回答者ID 学びたいテーマ
0 201 Python
1 201 pandas
2 202 可視化
3 202 SQL

修正後は4行になり、Pythonpandas可視化SQLが1行ずつに分かれました。str.split(",")だけでは区切り後の空白が残るため、各要素へstrip()を適用しています。

自分のデータでは、カンマが半角か全角か、セミコロンやパイプで区切られていないかも確認してください。

複数列を同時に展開する

複数列の各要素に対応関係がある場合は、列名のリストを渡して同時に展開できます。次の例では、1番目のテーマと1番目の習熟度、2番目どうしが対応しています。

skills = pd.DataFrame({
    "回答者ID": [301, 302],
    "学びたいテーマ": [["Python", "pandas"], ["SQL", "可視化"]],
    "習熟度": [["入門", "初級"], ["初級", "入門"]]
})

skills_long = skills.explode(
    ["学びたいテーマ", "習熟度"],
    ignore_index=True
)
skills_long
回答者ID 学びたいテーマ 習熟度
0 301 Python 入門
1 301 pandas 初級
2 302 SQL 初級
3 302 可視化 入門

結果は4行になり、Python―入門pandas―初級のように対応関係を保って展開されます。

複数列では、行ごとの要素数をそろえる

同時に展開する列は、各行でリストの長さが一致している必要があります。長さが違うとValueError: columns must have matching element countsが発生します。

skills_error = pd.DataFrame({
    "回答者ID": [401, 402],
    "学びたいテーマ": [["Python", "pandas"], ["SQL", "可視化"]],
    "習熟度": [["入門"], ["初級", "入門"]]
})

length_check = skills_error.assign(
    テーマ数=skills_error["学びたいテーマ"].map(len),
    習熟度数=skills_error["習熟度"].map(len)
)
length_check[["回答者ID", "テーマ数", "習熟度数"]]
回答者ID テーマ数 習熟度数
0 401 2 1
1 402 2 2

回答者ID401だけ、テーマ数が2、習熟度数が1です。この状態で2列を同時に展開すると、どの習熟度をどのテーマへ対応させるか決められません。

ここでは、元データを確認した結果、pandasの習熟度が未回答だったと分かったものとして、意味を勝手に補わずpd.NAを追加します。

skills_fixed = skills_error.copy()
skills_fixed.at[0, "習熟度"] = ["入門", pd.NA]

fixed_long = skills_fixed.explode(
    ["学びたいテーマ", "習熟度"],
    ignore_index=True
)
fixed_long
回答者ID 学びたいテーマ 習熟度
0 401 Python 入門
1 401 pandas <NA>
2 402 SQL 初級
3 402 可視化 入門

修正後は4行へ展開でき、pandasに対応する習熟度は欠損値として残りました。

重要なのは、エラーを消すためだけに値を複製しないことです。対応関係を元データで確認できない場合は、同時展開を中止するか、欠損として明示してください。

空リスト・NaN・通常の文字列の挙動を確認する

セルの状態によって、展開後の結果が変わります。代表的な入力を1つの表で確認します。

mixed = pd.DataFrame({
    "状態": ["リスト", "空リスト", "NaN", "通常の文字列", "数値"],
    "値": [["Python", "pandas"], [], np.nan, "SQL", 100]
})

mixed_long = mixed.explode("値", ignore_index=True)
mixed_long
状態
0 リスト Python
1 リスト pandas
2 空リスト NaN
3 NaN NaN
4 通常の文字列 SQL
5 数値 100
入力 展開後の考え方 確認上の注意
要素を持つリスト 要素数だけ行へ展開 指定外列は各行へ複製される
空リスト NaNを持つ1行になる 「回答なし」か「空の選択肢」か、元データの意味を確認する
NaN そのまま1行残る 欠損を削除・補完するかは分析目的で決める
通常の文字列 そのまま残る 区切り文字列なら先にstr.split()する
数値などのスカラー そのまま残る リストとスカラーの混在が意図どおりか確認する

空リストとNaNは展開後の表示が似ていますが、元データでの意味が同じとは限りません。処理前に業務上の定義を確認しましょう。

展開後にvalue_counts()で件数を集計する

最初のアンケートデータに戻り、テーマ別の回答件数を数えます。

theme_counts = (
    survey_long["学びたいテーマ"]
    .value_counts()
    .rename_axis("学びたいテーマ")
    .reset_index(name="回答件数")
)

theme_counts
学びたいテーマ 回答件数
0 Python 2
1 pandas 2
2 可視化 1
3 SQL 1

Pythonpandasが2件、可視化SQLが1件です。回答件数の合計は6件になり、展開後の有効なテーマ数と一致するはずです。

count_total = theme_counts["回答件数"].sum()
valid_total = survey_long["学びたいテーマ"].notna().sum()

print(f"集計表の回答件数合計: {count_total}")
print(f"展開後の有効テーマ数: {valid_total}")
print(f"集計結果は一致: {count_total == valid_total}")
集計表の回答件数合計: 6
展開後の有効テーマ数: 6
集計結果は一致: True

両方とも6件なので、展開から集計までの件数は一致しています。

ここで注意したいのは、展開後のlen(survey_long)が回答者数ではないことです。回答者数を求めるなら回答者ID.nunique()、選択されたテーマの総数を求めるなら展開後の有効行数を使います。何を1件と数えるかを先に決めることが大切です。

自分のデータへ置き換えるときのチェックリスト

  • df.columnsで対象列名が正しいか
  • 対象セルがリスト・タプル等か、区切り文字列か
  • 文字列なら区切り記号と前後空白が何か
  • 空リスト、NaN、空文字がそれぞれ何を意味するか
  • 複数列を展開する場合、各行で要素数と対応順が一致するか
  • 元インデックスを残す必要があるか
  • 展開後に数えたい単位が、元レコード数か要素数か
  • 展開前の要素数合計と、展開・欠損処理後の件数が整合するか

対象列の型が混在していそうなら、実行前に値と型の組み合わせを確認します。次の例では、リストだけでなく、文字列・欠損値・数値が混ざっているため、そのままexplode()してもすべての行を同じようには展開できないと分かります。

type_check_data = pd.DataFrame({
    "元の値": [["Python", "pandas"], "SQL, 可視化", np.nan, 100]
})

type_check = type_check_data.assign(
    型=type_check_data["元の値"].map(lambda value: type(value).__name__)
)

type_check
元の値
0 [Python, pandas] list
1 SQL, 可視化 str
2 NaN float
3 100 int

まとめ

  • セル内のリストを1要素1行へ展開するにはexplode()を使います。
  • 区切り文字列は、先にstr.split()でリスト化します。
  • ignore_index=Trueなら、展開後のインデックスを0から振り直せます。
  • 指定外の列は繰り返されるため、展開後の行数を回答者数と混同しないようにします。
  • 複数列を同時に展開するには、各行でリストの長さと要素の対応をそろえます。
  • 空リスト、NaN、通常の文字列、スカラーでは挙動が異なるため、元データの型と意味を確認します。
  • 最後に要素数や集計値を照合し、「コードが動いた」だけでなく「正しく展開できた」ことを検算します。

展開後は、value_counts()で全体件数を確認したり、groupby()agg()で年代別に集計したりできます。必要に応じて欠損処理や表記ゆれの修正を行い、棒グラフなどの可視化へ進みましょう。

関連記事

▲ ページトップへ戻る

explode()で文字列が1文字ずつ分かれないのはなぜですか?

通常の文字列はlist-likeな複数要素として展開されず、そのまま残るためです。カンマ区切りなどの文字列なら、先にstr.split()でリスト化してからexplode()を実行します。

複数列を同時に展開するとValueErrorになるのはなぜですか?

同時に展開する列のリスト長が、同じ行の中で一致していない可能性があります。各列へmap(len)を適用して要素数を確認し、元データ上の対応関係に従って長さをそろえるか、同時展開をやめてください。

展開後にインデックスが重複するのは問題ですか?

初期設定では元インデックスを保持するため、正常な動作です。元行を追跡するならそのまま使い、連番が必要ならignore_index=Trueを指定します

空リストとNaNは展開後にどうなりますか?

空リストはNaNを持つ1行になり、もともとのNaNもそのまま残ります。表示が似ていても元データでの意味は異なる場合があるため、削除や補完の前に意味を確認してください。

explode()とmelt()はどう使い分けますか?

セル内のリスト要素を行へ分けるならexplode()、複数の列を「項目名の列」と「値の列」へまとめるならmelt()を使います

展開後の行数が正しいか、どう確認しますか?

欠損や空リストがない単純なデータなら、展開前の各リストの長さを合計し、展開後の行数と比較します。欠損や空リストがある場合は、それらが1行として残る仕様を考慮し、有効値の件数も併せて確認してください。

コメント

タイトルとURLをコピーしました