value_counts(normalize=True)の割合は、どの行を分母にしたかで変わります。 欠損を除いた回答者の中での割合と、未回答を含む全体での割合は別の数字です。まず件数を数え、欠損の扱いと集計対象を確認してから、割合を読みます。

対象は、CSVやExcelのアンケートをpandasで集計し始めた人です。列を選んでメソッドを呼ぶ操作が分かれば進められます。ここでは架空の研修参加形式10行を使います。実在の受講者数や、スクールの参加実績ではありません。

公式資料と例の確認日は2026年11月2日です。コードはpandas 2.2.3と3.1.0で確認し、上から順に実行できる形にしています。ローカルの架空データだけを扱い、ファイルの上書きや外部への送信は行いません。

value_countsの件数と割合を、欠損の扱いから読み直す

最初に「何を1件と数えるか」を決める

value_counts()は、値ごとの出現回数を見るためのメソッドです。今回は1行を1件の回答として扱います。「オンライン」という文字の出現回数を、人数や申込件数と呼べるかは元データの作り方によります。同じ人が複数回答しているなら、行数と人数は一致しません。

集計前に、表の名前、1行の意味、対象期間、使う列を短く記録します。「研修希望・11月集計・1回答1行・参加形式」という4点があれば、後で別の集計表を開いても何を数えたか分かります。期間外の回答を除いた場合は、その条件も残してください。

今回の10行には、欠損1件、空文字1件、末尾に空白のある値1件を混ぜています。原因を一度に隠さず、整形前と整形後の両方を観察する教材です。初めから欠損を埋めたり、重複を消したりせず、まず元の状態を保存した変数を用意します。

import pandas as pd

raw = pd.Series(
    ["online", "online", "onsite", "online", pd.NA,
     "onsite", "online", "", "onsite", "online "],
    index=[f"R{i:02d}" for i in range(1, 11)],
    dtype="string",
    name="format",
)
print("全行", len(raw), "非欠損", raw.count())
assert len(raw) == 10
assert raw.count() == 9

ここで非欠損が9件になるのは、空文字がまだ欠損ではないからです。「値が見えない行が2行ある」という目視の印象だけでは、pandasが何件を欠損と認識したかまでは分かりません。元のIDを残すと、後から空欄の行を確認できます。

編集部作成の10行。表示用の説明と実際の値を区別
ID入力値確認点
R01・R02・R04・R07online同じ値が4件
R03・R06・R09onsite同じ値が3件
R05pd.NA欠損として扱う1件
R08空文字この時点では欠損ではない
R10onlineの末尾に空白そのままなら別の値

件数が合わないときはdropnaと空文字を別々に見る

公式のSeries.value_countsでは、欠損を除く設定が既定です。元表10行に対して、引数なしの件数合計は9件になります。dropna=Falseを付けると、今回の欠損も含めて10件を数えられます。

ただし、dropna=Falseを付けただけで表記ゆれまで直るわけではありません。onlineと末尾空白付きのonline は別の値です。空文字も、そのまま一つの値として結果に出ます。「合計10件になった」という確認と、「目的のカテゴリへ整理できた」という確認は分けます。

before = raw.value_counts(dropna=False)
print(before)
assert raw.value_counts().sum() == 9
assert before.sum() == 10
assert before.loc["online"] == 4
assert before.loc["online "] == 1
assert before.loc[""] == 1
print(raw[raw.isna() | raw.eq("").fillna(False)])

この結果で、欠損はR05、空文字はR08だと確認します。空白だけの入力や文字列の"NA"を使うデータでは、読み込み時の指定によって状態が変わることもあります。ここでは明示的に作ったSeriesを使うため、CSV読み込みの変換と混ぜていません。

文字列が欠損かどうかを、表示の見た目だけで判断しないことが大切です。公式の欠損データの説明も参照し、isna()で調べます。pd.NA、空文字、空白文字をすべて同じ意味に変えるかは、集計のルールとして決めてください。

例えば、自由記述欄の空文字を「回答なし」とするのは自然な場合があります。一方、商品コードの前後空白や、大文字と小文字の違いが識別に必要な場合は、機械的に整形すると別の商品をまとめるおそれがあります。今回の参加形式という用途を離れて、そのまま流用しないでください。

整形ルールを決めてから、元の行と突き合わせる

この教材では、参加形式の前後の空白を除き、空文字を未回答として扱います。末尾空白付きのR10はオンラインへ、空文字のR08は欠損へ変わります。R05は元から欠損です。整形後はオンライン5件、会場参加3件、未回答2件になります。

clean = raw.str.strip()
clean = clean.mask(clean.eq(""), pd.NA)

audit = pd.DataFrame({"raw": raw, "clean": clean})
print(audit)
assert clean.count() == 8
assert clean.isna().sum() == 2
assert clean.value_counts().to_dict() == {"online": 5, "onsite": 3}
assert clean.index.equals(raw.index)

整形後の表だけを保存すると、何を変えたかが見えなくなります。練習段階ではrawとcleanを並べ、元のIDが維持されていることを確認します。R10が別の行へずれたり、R08が消えたりしていないかも、ここで確かめられます。

「空欄を減らすこと」が目的ではありません。欠損をonlineで埋めればオンラインが増えますが、それは回答者が選んだ結果ではありません。未回答を残したまま、割合の分母に含めるかどうかを決める方が、表の意味を説明しやすくなります。

また、回答を整形する処理と、対象者を絞る処理は別に残します。例えば会場参加だけを選んでから集計すれば、オンラインの割合は表に出ません。表示前に何を除いたのかを確認できるよう、条件抽出は別の変数に分けると追いやすくなります。

ここまでの確認を他の人に渡すなら、「空白除去」「空文字を欠損へ」「行は削除しない」の3点で十分です。操作の名前だけでなく、オンライン4件が5件に変わった理由と、有効回答9件が8件に変わった理由まで説明できる状態を目指します。

normalizeの62.5%と50%は、別の分母を表す

normalize=Trueを指定すると、件数に代わって相対頻度が返ります。整形後の列で既定の欠損除外を使うと、有効回答8件が分母です。オンラインは5÷8で0.625、会場参加は3÷8で0.375になります。百分率表示なら、それぞれ62.5%と37.5%です。

欠損を含めて正規化すれば、今回の分母は全10件になります。オンラインは50%、会場参加は30%、欠損が20%です。こちらは「全回答行のうち」という説明になります。「オンライン希望は何割?」という質問だけでは、どちらを使いたいか決まりません。

import numpy as np

among_answers = clean.value_counts(normalize=True)
among_all = clean.value_counts(normalize=True, dropna=False)
print("回答者内", among_answers.mul(100))
print("全10行内", among_all.mul(100))
assert np.isclose(among_answers["online"], 5 / 8)
assert np.isclose(among_all["online"], 5 / 10)
assert np.isclose(among_answers.sum(), 1)
assert np.isclose(among_all.sum(), 1)

どちらの合計も1になります。そのため、「合計が1だから分母も意図どおり」とは言えません。オンラインの5件が同じでも、未回答を含めるかどうかで割合は変わります。合計の検算に加えて、分母の件数そのものを書き残します。

例えば研修の参加形式の希望をまとめるなら、「有効回答8件中、オンライン希望5件」と書けば62.5%の意味が伝わります。回答状況まで報告するなら、「全10件中、オンライン5件、会場3件、未回答2件」という形が適します。相手の質問に合わせて見出しを選んでください。

二つの割合を同じ表に並べ、空欄の意味を残す

報告先が分母を取り違えそうなら、件数・有効回答内の割合・全体内の割合を同じ表にします。列名を単に「割合」とせず、valid_pctとall_pctのように区別します。日本語の資料では「有効回答8件内」「全10件内」と書いても構いません。

table = pd.DataFrame({"count": clean.value_counts(dropna=False)})
table["all_pct"] = table["count"] / len(clean) * 100
table["valid_pct"] = table["count"] / clean.count() * 100
table.loc[table.index.isna(), "valid_pct"] = np.nan
print(table)
assert table["count"].sum() == 10
assert np.isclose(table["all_pct"].sum(), 100)
assert np.isclose(table["valid_pct"].sum(), 100)

未回答行の有効回答内割合は空欄にしています。有効回答だけを分母にした表で、未回答へ割合を付けると意味が混ざるためです。この空欄を0%に変えると、「未回答は存在しなかった」という読み方を招く場合があります。件数2件を残し、対象外という注記を添えましょう。

同じオンライン5件でも、分母を変えると割合が変わる
項目件数有効回答8件内全10件内
オンライン562.5%50%
会場参加337.5%30%
未回答2対象外20%

上位表示・複数列・保存時にも、同じ分母を保つ

上位だけ表示しても、割合を勝手に作り直さない

カテゴリが多い場合、集計後にhead()で上位だけ表示することがあります。上位1件にオンラインだけを残しても、元の有効回答8件は変わりません。今回の上位表示の割合は62.5%のままで、表に表示された項目だけの合計を1へ戻す必要はありません。

top1 = among_answers.head(1)
print(top1.mul(100))
assert top1.index.tolist() == ["online"]
assert np.isclose(top1.sum(), 0.625)

上位だけを取り出した後に、その合計で割り直すと、今回のオンラインは表示対象内で100%になります。これは別の分母を使った表です。「上位カテゴリだけの内訳」を意図したのであれば説明できますが、「回答者全体での割合」という見出しのままでは不一致になります。

資料に上位だけを載せるときは、「上位1項目のみ、分母は有効回答8件」と添えます。残りがあることを示したいなら、その他の合計も別に出します。上位の表示件数と、計算対象の回答件数を同じ数字だと思わないことがポイントです。

丸めについても、計算用の値は小数のまま残し、最後に表示用の桁へ整えます。カテゴリが多い表では、丸めた割合の合計が100から少しずれる場合があります。元の比率が合っているかと、表示の丸め差なのかを分けて確認すると、不要な数値調整を避けられます。

さらに、件数順の先頭をカテゴリの識別子として使わないようにします。今回はオンラインが最多ですが、別の日に会場参加が増えれば先頭は変わります。後続の処理で「先頭行はオンライン」と決めると、集計の数字が正しくても、読み取る対象を間違えます。

0件を表示したい項目は、欠損と区別して追加する

今回の選択肢にremoteという項目を追加して、回答がなかったことも示したいとします。集計結果にない項目を表示順へ合わせるには、Series.reindexを使う方法があります。この例では、指定した選択肢に存在しない件数を0で補います。

order = ["online", "onsite", "remote"]
selected = clean.value_counts().reindex(order, fill_value=0)
print(selected)
assert selected.tolist() == [5, 3, 0]
assert selected.sum() == 8

ここでの0件は、「今回数えた有効回答に、その値はなかった」という意味です。調査していない、取り込めていない、データの一部が失われた、といった状態を0へ変えてよいわけではありません。集計対象を確認できていることが前提です。

この表の合計が8件なのは、未回答2件を含めていないからです。0件項目を追加しても、未回答の行が復活するわけではありません。全10件の表として渡すなら、未回答の件数を別に付けるか、前節の全体内割合の表を使います。

指定した表示順にない新しい値が入った場合にも注意します。例えばhybridが現れたのに上の3項目だけへ並べ替えれば、表から外れます。想定外の値を見つけたら、いきなり「その他」にまとめず、元の回答と選択肢の変更有無を確認してください。

unexpected = set(clean.dropna().unique()) - set(order)
print("想定外", unexpected)
assert not unexpected

この確認は、0件表示の追加によって元の回答を見失わないためのものです。表示順の表と、実際に存在する値の集合は別に持ち、件数が減っていないかを最後に突き合わせます。

もう一つ確認したいのが、有効回答そのものが0件の表です。全員未回答のデータでは、有効回答を分母にした割合を、この例と同じように意味付けることはできません。空の結果を見て「各選択肢が0%だった」と報告するのではなく、「有効回答0件のため回答者内割合は算出対象外」と説明します。全体件数が0件の場合も、対象者がいなかったのか、抽出条件で全件が外れたのかを先に確認してください。

この違いを練習するなら、回答が2件ある小さい表、全員未回答の表、行のない表を別々に作り、全体・有効・未回答の三つの件数を紙へ書いてみます。どの状態も見た目の表は小さくなりますが、報告の意味は異なります。空の出力を自動的に同じ0の表へ揃える前に、どの状態だったかを区別できる形にします。

SeriesとDataFrameでは、数えている単位が変わる

1列のvalue_counts()と、複数列を持つDataFrameのvalue_counts()では対象が異なります。公式のDataFrame.value_countsでは、列の値の組合せを数えます。参加形式だけの内訳と、参加形式・部署の組合せは別の集計です。

dept = pd.Series(
    ["A", "A", "B", "A", "A", "B", "B", "A", pd.NA, "B"],
    index=clean.index, dtype="string",
)
pair = pd.DataFrame({"format": clean, "dept": dept})
print(pair.value_counts())
assert pair["format"].value_counts().sum() == 8
assert pair.value_counts().sum() == 7

今回、部署が欠損のR09は、参加形式だけなら有効回答です。しかし、2列とも値がある組合せを数えるときには除外され、合計7件になります。8件と7件の違いを、集計処理の故障と決める前に、どの列を対象にしたかを確認します。

ID列を含んだ表全体をそのまま数えると、IDの異なる行は別の組合せです。参加形式の内訳を知りたいだけなら、対象のSeriesを選びます。複数列での集計が必要なら、表の見出しも「部署と参加形式の組合せ」に変えてください。

部署ごとの割合を知りたい場合には、各部署の分母を別に考える必要があります。全体を一度正規化した結果を、部署内割合と呼ぶことはできません。今回の疑問を解決した後で、部署別集計の対象列と欠損条件を設計し直すと、論点を分けて学べます。

保存前に、件数・対象外・割合をセットで照合する

保存前の確認では、元10件、有効8件、未回答2件、オンライン5件、会場3件を一枚の確認メモにまとめます。割合だけを見て「それらしい」と判断せず、少ない件数の原表から手で計算し直します。5÷8と5÷10の違いを説明できれば、次の表でも分母を意識しやすくなります。

確認メモには、元ファイル名の代わりに今回の教材名、整形ルール、対象列、分母、確認日を残します。実務で使う場合も、個人情報のある原表をそのまま配布する必要はありません。どの条件で数えたかを、受け取る人が確認できる形にすることが目的です。

Excelへ貼り付ける場合は、0.625という小数を百分率として表示するのか、62.5という数値に%の見出しを付けるのかも決めます。62.5にさらに百分率の書式を付けると、想定した表示と異なる場合があります。受け渡し先で値と書式を一緒に確認しましょう。

CSVへ出す場合は、集計結果のインデックスがカテゴリ名であることを意識します。カテゴリを列として保存するか、インデックス付きの表として保存するかを決め、読み戻したときにカテゴリ名と件数が対応しているかを見ます。本稿の例は保存操作を含めず、確認すべき項目を示しています。

同じ検算を毎回すべて手作業で繰り返す必要はありません。小さい原表で意味を理解してから、対象件数や想定外カテゴリをチェックするコードを残すと、後日の変更を見つけやすくなります。合計が一致しないときに、どの段階まで戻ればよいかも分かります。

仕上げの練習として、同じ整形後の10行を、R01〜R04の4行と、R05〜R10の6行に分けてみましょう。前半はオンライン3件・会場1件で未回答なし、後半はオンライン2件・会場2件・未回答2件です。この二つを別々の集計として扱っても、元の10行へ戻したときのオンライン5件は変わりません。

同じ原表を4行と6行へ分けた確認課題
範囲オンライン件数有効回答内その範囲の全行内
前半4行33÷4=75%3÷4=75%
後半6行22÷4=50%2÷6≒33.3%
元の10行55÷8=62.5%5÷10=50%

前半と後半の全行内割合を、75%と約33.3%だからという理由で単純平均すると、元の全10行の50%にはなりません。二つの範囲の件数が4行と6行で異なるためです。集計表を合体するときは、割合を平均する前に、元の件数と分母を足して計算し直します。この教材では、3件と2件を足して5件、4行と6行を足して10行です。

一方、有効回答内割合は前半も後半も分母が4件なので、今回に限れば単純平均でも同じ62.5%になります。たまたま一致した結果だけを覚えると、次のデータで分母が変わったときに困ります。「割合の平均でよいか」ではなく、「同じ意味の分母を持つ件数から組み直せるか」を確認する練習にしてください。部署別・月別の集計を後でまとめる場面にもつながります。

独学で詰まったら、質問と再現例を小さくする

質問するときは「割合がおかしい」だけでなく、元の10行、整形ルール、使った式、期待する分母、実際の結果をセットにします。今回なら「未回答を含む10件を分母に50%を出したいが、有効回答8件を分母に62.5%になった」と説明できます。

学習の最初は、normalizeとdropnaを一つずつ変えて結果を比較します。次に空文字の整形、最後に上位表示と保存という順に進めると、何が数字を変えたかを追えます。複数の引数と複雑な前処理を一度に変えると、原因を説明しづらくなります。

コードが動いた後は、同じ例を自分の言葉で説明してみてください。「欠損を数える指定」と「分母を決める目的」が区別できているか、回答者と全体のどちらを尋ねられているか、という2点を確認します。暗記する構文の数より、表の見出しと数字を対応させる練習が役立ちます。

講座や質問対応を比較する場合も、集計を実行できるかだけでなく、元の行・欠損・件数・割合をどう確認するかまで相談できるかを聞きます。教材にpandasという名前があるだけで、持ち込み課題の添削や業務データの相談まで含まれるとは限りません。受講前に対応範囲と費用を確認してください。

Pythonを基礎から学び直す質問先の候補として、PythonWinnerがあります。公式にはPythonの基礎やAI・機械学習の学習コースが案内されています。今回の集計課題なら「小さい原表で、欠損と分母を説明する練習をしたい」と相談すると、目的を伝えやすくなります。持ち込みコードの添削が対象か、pandasのどの範囲を扱うか、受講総額と質問方法を申込前に確認してください。確認日:2026年11月2日。

広告:【PythonWinner】

よくある質問

Q. dropna=Falseなら空文字も欠損になりますか?
A.

なりません。今回のstring型の例では、空文字はそのまま一つの値です。空文字を未回答と扱うルールを決めてから、別の処理でpd.NAへ変えています。

Q. normalize=Trueの結果が0.625です。62.5ではないのですか?
A.

相対頻度は0〜1の小数です。百分率の数値へ直すなら100を掛けます。Excelなどへ渡す場合は、小数の値と百分率の表示書式を混同しないでください。

Q. 割合の合計が1なら、集計は正しいと言えますか?
A.

それだけでは判断できません。欠損を含む10件でも、有効回答8件でも、今回の正規化結果は合計1です。分母の件数、対象期間、整形ルールを確認してください。

Q. 上位だけ載せた表の合計が100%になりません。
A.

上位以外を表示していなければ自然です。元の全体を分母にした割合か、表示項目内で計算し直した割合かを明記します。見た目を揃えるためだけの割り直しは避けます。

Q. 結果にない選択肢を0件で出せますか?
A.

今回のように集計対象を確認できていれば、表示したい項目へreindexし、存在しない項目を0で補う方法があります。未取得や未調査の状態まで0件へ変えないようにします。

Q. 複数回答のアンケートにも、そのまま使えますか?
A.

一つのセルに複数の選択肢がある場合は、まず何を1件と数えるかを決めます。回答者数と選択肢の延べ件数は異なります。分割や縦展開を行った後も、元の回答IDを残して照合してください。