アンケートの複数回答や商品タグのように、1つのセルに複数の値が入っていて、要素ごとに集計できないことがあります。
セルの値がリスト形式なら、pandasのexplode()を使うと、リストの各要素を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点に注目してください。
学びたいテーマの各要素が別の行になっています。回答者IDと年代は、展開された各行へ繰り返されています。- 左端のインデックスは
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行になり、Python、pandas、可視化、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 |
Pythonとpandasが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()で年代別に集計したりできます。必要に応じて欠損処理や表記ゆれの修正を行い、棒グラフなどの可視化へ進みましょう。
関連記事
- pandas melt()の使い方|横持ちデータを縦持ちに変換する方法:セル内ではなく、複数の列を縦持ちへ変換したい場合
- pandas reset_index()の使い方:展開後のインデックスを後から整理したい場合
- pandas value_counts()の使い方:展開した要素の件数・割合を数えたい場合
- pandas groupby・aggの使い方:年代などの属性別に集計したい場合
- pandas fillna()の使い方:展開後の欠損値を目的に合わせて補完したい場合
- pandas str.strip()の使い方:文字列要素の前後空白を削除したい場合
- Matplotlib 棒グラフ入門:集計した件数をグラフで比較したい場合
explode()で文字列が1文字ずつ分かれないのはなぜですか?
通常の文字列はlist-likeな複数要素として展開されず、そのまま残るためです。カンマ区切りなどの文字列なら、先にstr.split()でリスト化してからexplode()を実行します。
複数列を同時に展開するとValueErrorになるのはなぜですか?
同時に展開する列のリスト長が、同じ行の中で一致していない可能性があります。各列へmap(len)を適用して要素数を確認し、元データ上の対応関係に従って長さをそろえるか、同時展開をやめてください。
展開後にインデックスが重複するのは問題ですか?
初期設定では元インデックスを保持するため、正常な動作です。元行を追跡するならそのまま使い、連番が必要ならignore_index=Trueを指定します
空リストとNaNは展開後にどうなりますか?
空リストはNaNを持つ1行になり、もともとのNaNもそのまま残ります。表示が似ていても元データでの意味は異なる場合があるため、削除や補完の前に意味を確認してください。
explode()とmelt()はどう使い分けますか?
セル内のリスト要素を行へ分けるならexplode()、複数の列を「項目名の列」と「値の列」へまとめるならmelt()を使います
展開後の行数が正しいか、どう確認しますか?
欠損や空リストがない単純なデータなら、展開前の各リストの長さを合計し、展開後の行数と比較します。欠損や空リストがある場合は、それらが1行として残る仕様を考慮し、有効値の件数も併せて確認してください。
コメント