結論

クロス集計とは、2つ以上の項目を縦と横に掛け合わせ、交わるマスごとに人数や割合を数える集計です。「全体で何%か」を見る単純集計では見えない、グループごとの違いが分かります。読むときは、割合が「行で100%」か「列で100%」かを最初に確かめます。

この記事でわかること

  • クロス集計表の見方(表側・表頭・N・n)と、行%・列%で結論が逆になる実例
  • pandas・SQL(SQLite・PostgreSQL)・R で同じ600件を集計した実物と、100万件での速さ
  • 複数回答・無回答・小さい n の扱い、シンプソンのパラドックス、よくあるエラー原文

2026年10月29日更新|pandas 3.0.5・SQLite 3.45.1・PostgreSQL 16.15・R 4.3.3で実行確認

この記事で実際に動かしたもの:架空のアンケート600件(年代・端末・満足度・利用時間・使っている機能)を作り、pandas の crosstab・pivot_table、SQLite と PostgreSQL の SQL、R の table・xtabs で同じクロス集計表を出しました。

クロス集計は、アンケートの分析、Web のアクセス解析、売上データの切り分けなど、データを「グループで比べる」場面のほとんどで最初に使う集計です。全体の流れを先に図にします。

flowchart TD
    A[元データ<br>1行=1人の回答] --> B[単純集計<br>項目ごとの人数・割合]
    B --> C[クロス集計<br>2項目を掛け合わせる]
    C --> D{割合の向きを決める}
    D -->|グループ同士を比べる| E[行%<br>各行が100%]
    D -->|中身の構成を見る| F[列%<br>各列が100%]
    E --> G[n数と検定で確かめる]
    F --> G

よく調べられることの答えを先にまとめます。

知りたいこと 答え 詳しい節
クロス集計とは 2つ以上の項目を掛け合わせ、交わるマスごとに数える集計 クロス集計とは
単純集計との違い 単純集計は1項目だけ。クロス集計はグループ別の違いが分かる クロス集計とは
クロス集計表の見方 左の列が表側、上の行が表頭。N(全体)と n(各グループ)を見る クロス集計表の見方
クロス集計の読み方 比べたいグループを表側に置き、行%(横に足して100%)で読む 行%と列%
クロス集計表の作り方 集計計画→データ整備→件数表→割合→確認の5手順 クロス集計表の作り方
Python(pandas)で作る pd.crosstab(行, 列, margins=True, normalize="index") pandasでクロス集計
SQL で作る SUM(CASE WHEN … THEN 1 ELSE 0 END) を列ごとに書く SQLでクロス集計
R で作る table() か xtabs()、割合は prop.table() Rでクロス集計
差が偶然か確かめたい カイ二乗検定。期待度数が5未満のマスが多いと近似が崩れる n数とカイ二乗検定

確認環境:Ubuntu 24.04.5 LTS(2 CPU・メモリ 8GB)。データは Python 3.13.18 と NumPy 2.5.3 の乱数(シード 209)で作った架空のアンケート600件と、速さを測るための100万件です。pandas 3.0.5(SciPy 1.18.1)、SQLite 3.45.1 の sqlite3 コマンド(Ubuntu の .deb を作業場所に展開)、PostgreSQL 16.15(自分で initdb したクラスターを 127.0.0.1 の専用ポートで起動。tablefunc 拡張を使用)、R 4.3.3(r-base-core の .deb を作業場所に展開)で同じ表を作り、出力をそのまま載せています。時間は同じマシンで5回測った中央値です。pandas の引数の意味は pandas の公式リファレンス、PostgreSQL の crosstab の仕様は PostgreSQL 16 の公式ドキュメント、R の関数は R に付属のヘルプで確かめました。Excel・スプレッドシートでの作り方は扱いません。図は mermaid 11.16.0 で描画を確かめています(確認日 2026年10月29日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「クロス集計とは?作り方と読み方|pandas・SQL・R実行例つき」

クロス集計とは(単純集計との違い)

この節では、クロス集計の意味と、単純集計と比べて何が分かるようになるのかが分かります。

クロス集計は、ある項目(例:年代)でデータをグループに分け、別の項目(例:使っている端末)の人数や割合をグループごとに数える集計です。 縦と横の項目が交わる(クロスする)マスに数字が入るので、この名前で呼ばれます。統計学では「分割表(contingency table)」、英語では cross tabulation(略して crosstab)と呼びます。

まず、同じ600件のデータで「単純集計」を出します。単純集計は、1つの項目について、選択肢ごとの人数と割合を数えるだけの集計です。

import pandas as pd
df = pd.read_csv("survey.csv")
print(df["端末"].value_counts())
print(df["端末"].value_counts(normalize=True).round(3))
端末
スマホ      363
PC       181
タブレット     56
Name: count, dtype: int64
端末
スマホ      0.605
PC       0.302
タブレット    0.093
Name: proportion, dtype: float64

単純集計から分かるのは「全体ではスマホが60.5%」ということだけです。次に、年代と端末を掛け合わせたクロス集計表を出します。

端末      PC  スマホ  タブレット   合計
年代
20代     26  166     14  206
30代     57  117     14  188
40代     65   64     16  145
50代以上   33   16     12   61
合計     181  363     56  600

20代は206人中166人(80.6%)がスマホなのに、50代以上は61人中16人(26.2%)しかいません。 全体の「60.5%」は、年代による大きな差を平均してしまった数字だったことが、クロス集計で初めて見えます。

単純集計

1つの項目を、選択肢ごとに数える

全体の傾向(スマホ60.5%)が分かる

分析の最初に、データの全体像と入力ミスを確かめるために使う

クロス集計

2つ以上の項目を掛け合わせて数える

グループごとの違い(20代80.6%・50代以上26.2%)が分かる

「誰が・どこで違うのか」を知りたいときに使う

似た言葉との違い

クロス集計のまわりには、似た言葉がいくつもあります。指しているものは次のとおりです。

言葉 指しているもの 主に使う場面
クロス集計 項目を掛け合わせて数える作業 アンケート・マーケティング
クロス集計表・クロス表 その結果の表 報告書・調査レポート
分割表 同じ表の統計学での呼び名 統計学・検定
ピボット(pivot) 縦に長いデータを横に広げる変形の操作。クロス集計の作り方の1つ pandas・SQL・BI ツール
多重クロス集計 3つ以上の項目を掛け合わせたクロス集計 属性で細かく切るとき
クロス分析 クロス集計表を読み、差の理由まで考える分析 マーケティング

属性クロスと設問間クロス

アンケートのクロス集計は、掛け合わせる項目で2種類に分けて呼ばれます。年代・性別・地域などの回答者の属性と設問を掛けるのが「属性クロス」、設問と設問(例:端末×満足度)を掛けるのが「設問間クロス」です。作り方は同じで、どちらを表側(左)に置くかが読み方を決めます。

クロス集計表の見方(表側・表頭・N・n)

この節では、クロス集計表のどこに何が書いてあるのかと、最初に見るべき数字が分かります。

クロス集計表は、左の列(表側:ひょうそく)にグループ、上の行(表頭:ひょうとう)に答えの選択肢を置くのが基本の形です。 先ほどの表なら、表側が年代、表頭が端末です。

1

表側(行)

比べたいグループ。年代・地域・施策 A/B など。原因側に近い項目を置く。

例:20代・30代・40代・50代以上

2

表頭(列)

答えの選択肢。端末・満足度・購入したかどうか など、結果側の項目を置く。

例:スマホ・PC・タブレット

3

セル(マス)

表側と表頭が交わる人数。20代×スマホなら166人。

割合を添えると読みやすい

4

N と n

N は全体の人数(600)。n は各グループの人数(20代は206、50代以上は61)。

n が小さい行は割合がぶれる

表を受け取ったら、次の順に見ると読み違いが減ります。

  • 1N と、各行の n を見る合計がデータの件数と合うか。今回は満足度の表だけ597人で、3人分の無回答が落ちていた
  • 2割合の分母を確かめる横に足して100%(行%)か、縦に足して100%(列%)か、全体で100%か
  • 3全体の行と比べる「全体」より高い・低いマスが、そのグループの特徴
  • 4差が偶然でないかを考えるn が小さい行の数ポイントの差は、誤差の範囲のことが多い

クロス集計の読み方:行%と列%で結論が変わる

この節では、同じクロス集計表でも、割合の取り方で結論が正反対になる例と、どちらで読むかの決め方が分かります。

クロス集計の割合には「行%(横%)」「列%(縦%)」「全体%」の3種類があり、分母が違います。 pandas では normalize に "index"(行ごと)・"columns"(列ごと)・"all"(全体)を渡すと切り替えられます。同じ600件で3つを出しました。

t = pd.crosstab(df["年代"], df["端末"], normalize="index") * 100   # 行%
t = pd.crosstab(df["年代"], df["端末"], normalize="columns") * 100 # 列%
t = pd.crosstab(df["年代"], df["端末"], normalize="all") * 100     # 全体%
年代 PC(行%) スマホ(行%) タブレット(行%) タブレット(列%)
20代(n=206) 12.6 80.6 6.8 25.0
30代(n=188) 30.3 62.2 7.4 25.0
40代(n=145) 44.8 44.1 11.0 28.6
50代以上(n=61) 54.1 26.2 19.7 21.4

右端のタブレットの列%だけを見ると、「タブレット利用者の25.0%が20代、21.4%が50代以上。若い人にも同じくらい使われている」と読めます。ところが行%では、タブレットを使う割合は20代6.8%に対して50代以上19.7%で、年代が上がるほど高くなっています。 列%で20代の割合が大きいのは、そもそも回答者に20代が多い(206人)からです。

年代で端末の使い方が違うかを知りたい

避ける列%(タブレット利用者のうち何%が20代か)で比べ、「20代にも人気」と結論する
→
代わりに年代を表側に置き、行%(各年代のうち何%がタブレットか)で比べる

列%は、表側のグループの人数の多さに引っぱられる。グループの人数がそろっていない調査ほど差が大きい

どちらで読むかは、次の問いで決まります。

知りたいこと 使う割合 今回の例
グループごとに答えが違うか 行%(表側にグループ) 50代以上のうち PC は54.1%
ある答えの人はどんな人たちか 列% スマホ利用者のうち20代が45.7%
組み合わせが全体のどれくらいか 全体% 20代×スマホは全体の27.7%

迷ったら「比べたいグループを表側に置き、行%で読む」と決めておくと読み違いが起きにくくなります。 報告書の表には、必ず「n=」と「行%」「列%」の別を書き添えます。

クロス集計表の作り方(集計計画から確認まで)

この節では、道具に関係なく共通する、クロス集計表を作る手順が分かります。

クロス集計は、集計を始める前に「何と何を掛けるか」を決めておくと、表を作りすぎて迷子になりません。 どの道具でも、次の5手順で進めます。

1

集計計画を書く

知りたい問い(例:年代で端末が違うか)ごとに、表側と表頭の項目、使う割合(行%)を1行ずつ書き出す。思いつきで全項目を掛け合わせない。

問い→表側・表頭・割合

2

元データを1行1回答にそろえる

1行が1人(1件)、1列が1項目の形にする。表記ゆれ(「スマートフォン」と「スマホ」)、空欄、数値の区分(年齢→年代)をここで直す。

縦長のローデータ

3

件数の表を作る

まず割合ではなく人数で表を出し、合計がデータの件数と合うかを見る。合わなければ無回答や欠損が落ちている。

margins(合計)を付ける

4

割合を付ける

手順1で決めた向き(行%か列%)で割合を出す。選択肢の並び(満足→普通→不満)を意味の順に直す。

分母と並びを決める

5

n と差の大きさを確かめる

n が小さい行に印を付け、差が誤差の範囲か、必要ならカイ二乗検定で確かめる。表に n と割合の向きを書き添える。

読み違いを防ぐ

手順2の「1行1回答」の形を、データの世界では「縦持ち(ロング形式)」と呼び、クロス集計表のように項目を横に並べた形を「横持ち(ワイド形式)」と呼びます。クロス集計は、縦持ちのデータを横持ちの表に変える操作と言い換えられます。SQL での縦持ち・横持ちの変換そのものは、関連記事「SQLで縦持ち⇔横持ち変換|MySQL・PostgreSQL別コピペ例と対応表」で扱っています。

今回使った600件のデータは、次の形です(先頭3行)。満足度の空欄が3件、使っている機能は複数回答で、機能ごとに 0/1 の列になっています。

id,年代,端末,満足度,利用分,機能_検索,機能_お気に入り,機能_通知,機能_共有
1,20代,スマホ,普通,45,1,1,0,0
2,30代,スマホ,普通,40,0,0,0,0

pandasでクロス集計する(crosstab・pivot_table)

この節では、Python の pandas でクロス集計表を作る書き方と、合計・割合・並び順・平均値の集計までが実行結果で分かります。

pandas でクロス集計表を作るときは、pd.crosstab() がいちばん短く書けます。 人数を数えるなら、行にする列と列にする列を渡すだけです。

pd.crosstab(df["年代"], df["端末"], margins=True, margins_name="合計")

結果は「クロス集計とは」の節の表と同じです。margins=True で行と列の合計が付き、margins_name でその名前(既定は All)を変えられます。

選択肢の並び順を直す

端末×満足度を何も指定せずに出すと、列が「不満・普通・満足」の順になりました。pandas は文字列を文字コードの順に並べるためです。

満足度    不満   普通   満足
端末
PC     46   72   62
スマホ    54  126  182
タブレット  21   21   13

意味の順に並べるには、列を順序付きのカテゴリ型(Categorical)に変えてから集計します。

sat = pd.CategoricalDtype(["満足", "普通", "不満"], ordered=True)
dev = pd.CategoricalDtype(["スマホ", "PC", "タブレット"])
d2 = df.assign(満足度=df["満足度"].astype(sat), 端末=df["端末"].astype(dev))
pd.crosstab(d2["端末"], d2["満足度"], margins=True, margins_name="合計")
満足度     満足   普通   不満   合計
端末
スマホ    182  126   54  362
PC      62   72   46  180
タブレット   13   21   21   55
合計     257  219  121  597

合計が600ではなく597になっています。 crosstab は、どちらかの項目が空(NaN)の行を黙って除くためです。無回答も数えたいときは、先に文字で埋めます。

pd.crosstab(df["端末"], df["満足度"].fillna("無回答"), margins=True, margins_name="合計")
満足度     不満   普通   満足  無回答   合計
端末
PC      46   72   62    1  181
スマホ     54  126  182    1  363
タブレット   21   21   13    1   56
合計     121  219  257    3  600

0件の選択肢も行として残す

選択肢に「10代」を用意したのに回答が0件だった、という場合、crosstab の既定では10代の行が消えます。公式リファレンスにも、既定の dropna=True では入力に現れなかったカテゴリが除かれる、と書かれています。dropna=False を付けると0の行が残りました。

a = df["年代"].astype(pd.CategoricalDtype(["20代", "30代", "40代", "50代以上", "10代"]))
pd.crosstab(a, df["端末"], dropna=False)
端末     PC  スマホ  タブレット
年代
20代    26  166     14
30代    57  117     14
40代    65   64     16
50代以上  33   16     12
10代     0    0      0

人数以外を集計する(平均・中央値)

クロス集計のマスに入れるのは人数だけではありません。pivot_table を使うと、年代×端末ごとの平均利用時間のような表も作れます。

df.pivot_table(index="年代", columns="端末", values="利用分", aggfunc="mean").round(1)
pd.crosstab(df["年代"], df["端末"], values=df["利用分"], aggfunc="median")
端末       PC   スマホ  タブレット
年代
20代    38.7  30.0   30.6
30代    39.0  31.4   26.2
40代    40.1  26.9   33.0
50代以上  43.9  35.8   18.1

crosstab でも values と aggfunc を組で渡せば同じことができます(片方だけだとエラー)。平均は、50代以上×タブレットのように12人しかいないマスでは、1人の長時間利用で大きく動く点に注意します。

3つ以上の項目を掛ける(多重クロス集計)

行か列にリストを渡すと、多重クロス集計になります。

pd.crosstab([d2["年代"], d2["端末"]], d2["満足度"])
満足度        満足  普通  不満
年代  端末
20代 スマホ    77  59  30
    PC     11  11   4
    タブレット   2   7   4
30代 スマホ    63  36  17
    PC     18  24  15
    タブレット   3   4   7

項目を1つ増やすたびにマスの数は掛け算で増え、1マスあたりの人数は減ります。 20代×タブレットは13人に分かれ、満足は2人です。多重クロス集計は、n が足りるかを先に見てから使います。pandas の操作全般は、関連記事「pandasの使い方 逆引き81選|pandas 3.0の実行結果つき」で扱っています。

SQLでクロス集計する(CASE・FILTER・crosstab)

この節では、データベースの中でクロス集計表を作る SQL の書き方を、SQLite と PostgreSQL の実行結果で分かります。

SQL には「クロス集計」という1つの命令はなく、GROUP BY と CASE 式を組み合わせて書くのが、どのデータベースでも通じる方法です。 まず GROUP BY だけで集計すると、縦に長い結果になります。

SELECT 年代, 端末, count(*) AS 人数
FROM survey GROUP BY 年代, 端末 ORDER BY 年代, 端末;
年代   端末     人数
---  -----  ---
20代  PC     26
20代  スマホ    166
20代  タブレット  14
30代  PC     57
30代  スマホ    117

これを横に広げるには、表頭にしたい選択肢ごとに SUM(CASE WHEN … THEN 1 ELSE 0 END) の列を書きます。合計の行は UNION ALL で足しました(SQLite 3.45.1)。

SELECT 年代,
  SUM(CASE WHEN 端末 = 'スマホ'     THEN 1 ELSE 0 END) AS スマホ,
  SUM(CASE WHEN 端末 = 'PC'         THEN 1 ELSE 0 END) AS PC,
  SUM(CASE WHEN 端末 = 'タブレット' THEN 1 ELSE 0 END) AS タブレット,
  count(*) AS 合計
FROM survey GROUP BY 年代
UNION ALL
SELECT '合計', SUM(端末 = 'スマホ'), SUM(端末 = 'PC'), SUM(端末 = 'タブレット'), count(*)
FROM survey;
年代     スマホ  PC   タブレット  合計
-----  ---  ---  -----  ---
20代    166  26   14     206
30代    117  57   14     188
40代    64   65   16     145
50代以上  16   33   12     61
合計     363  181  56     600

SQLite と MySQL では比較の結果が 1/0 になるので、2つ目の SELECT のように SUM(端末 = 'スマホ') と短く書けます。行%は、同じ式を count(*) で割ります。

SELECT 年代,
  round(100.0 * SUM(端末 = 'スマホ') / count(*), 1) AS スマホ率,
  round(100.0 * SUM(端末 = 'PC') / count(*), 1) AS PC率,
  round(100.0 * SUM(端末 = 'タブレット') / count(*), 1) AS タブレット率
FROM survey GROUP BY 年代;
年代     スマホ率  PC率   タブレット率
-----  ----  ----  ------
20代    80.6  12.6  6.8
30代    62.2  30.3  7.4
40代    44.1  44.8  11.0
50代以上  26.2  54.1  19.7  

pandas の行%と小数第1位まで一致しました。100.0 と小数で書くのは、100 * と整数どうしで割ると小数点以下が切り捨てられるデータベースがあるためです。

PostgreSQL:FILTER 句と ROLLUP

PostgreSQL では、CASE の代わりに集約関数の FILTER 句が使え、合計の行は GROUP BY ROLLUP で作れます。

SELECT coalesce(年代, '合計') AS 年代,
  count(*) FILTER (WHERE 端末 = 'スマホ')     AS スマホ,
  count(*) FILTER (WHERE 端末 = 'PC')         AS "PC",
  count(*) FILTER (WHERE 端末 = 'タブレット') AS タブレット,
  count(*) AS 合計
FROM survey GROUP BY ROLLUP(年代) ORDER BY 年代;
   年代   | スマホ | PC  | タブレット | 合計
----------+--------+-----+------------+------
 20代     |    166 |  26 |         14 |  206
 30代     |    117 |  57 |         14 |  188
 40代     |     64 |  65 |         16 |  145
 50代以上 |     16 |  33 |         12 |   61
 合計     |    363 | 181 |         56 |  600

行%と列%を両方出すなら、ウィンドウ関数で分母を変えるのが簡単です。PARTITION BY 年代 なら行%、PARTITION BY 端末 なら列%になります。

SELECT 年代, 端末, count(*) AS n,
  round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY 年代), 1) AS 行パーセント,
  round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY 端末), 1) AS 列パーセント
FROM survey GROUP BY 年代, 端末 ORDER BY 年代, 端末;
 年代 |    端末    |  n  | 行パーセント | 列パーセント
------+------------+-----+--------------+--------------
 20代 | PC         |  26 |         12.6 |         14.4
 20代 | スマホ     | 166 |         80.6 |         45.7
 20代 | タブレット |  14 |          6.8 |         25.0
 30代 | PC         |  57 |         30.3 |         31.5
 30代 | スマホ     | 117 |         62.2 |         32.2
 30代 | タブレット |  14 |          7.4 |         25.0

ウィンドウ関数の書き方は、関連記事「SQLウィンドウ関数の使い方|累計・前月比・上位N件を実行結果で解説」で扱っています。

PostgreSQL:tablefunc の crosstab 関数

PostgreSQL には、拡張機能 tablefunc に crosstab という関数があります。使うときは、列にする選択肢を2つ目の引数で必ず指定する2引数版にします。

CREATE EXTENSION tablefunc;
SELECT * FROM crosstab(
  $q$SELECT 年代, 端末, count(*) FROM survey GROUP BY 1, 2 ORDER BY 1, 2$q$,
  $q$VALUES ('スマホ'), ('PC'), ('タブレット')$q$
) AS ct(年代 text, スマホ bigint, "PC" bigint, タブレット bigint);
   年代   | スマホ | PC | タブレット
----------+--------+----+------------
 20代     |    166 | 26 |         14
 30代     |    117 | 57 |         14
 40代     |     64 | 65 |         16
 50代以上 |     16 | 33 |         12

1引数版は、値を左の列から順に詰めるだけで、2列目(端末)を見ません。公式ドキュメントにも「グループ内の値をすべて同じに扱い、最初の空いている列に入れる」と書かれています。50代以上×PC の行を除いたデータで試すと、50代以上の行だけ値が左にずれ、スマホの16人が PC の列に、タブレットの12人がスマホの列に入りました。 エラーにならないので気づきにくい失敗です。

   年代   | c1 | c2  | c3
----------+----+-----+----
 20代     | 26 | 166 | 14
 30代     | 57 | 117 | 14
 40代     | 65 |  64 | 16
 50代以上 | 16 |  12 |   

CASE 式の書き方の種類は関連記事「SQL CASE式の使い方|用途別コピペ集とDB比較・実行結果つき」で、GROUP BY と集計関数は関連記事「SQL GROUP BY HAVING 使い方|集計関数一覧とDB比較・実行結果つき」で扱っています。

Rでクロス集計する(table・xtabs・prop.table)

この節では、統計の言語 R でクロス集計表を作り、割合と合計を付けるまでが実行結果で分かります。

R では、標準の table() に2つの列を渡すだけでクロス集計表になります。 パッケージの追加は要りません。

df <- read.csv("survey.csv", na.strings = "")
t <- table(年代 = df$年代, 端末 = df$端末)
addmargins(t)                          # 合計を付ける
round(prop.table(t, margin = 1) * 100, 1)  # 行%
round(prop.table(t, margin = 2) * 100, 1)  # 列%
          端末
年代      PC スマホ タブレット Sum
  20代      26    166         14 206
  30代      57    117         14 188
  40代      65     64         16 145
  50代以上  33     16         12  61
  Sum      181    363         56 600
          端末
年代       PC スマホ タブレット
  20代     12.6   80.6        6.8
  30代     30.3   62.2        7.4
  40代     44.8   44.1       11.0
  50代以上 54.1   26.2       19.7

prop.table の margin = 1 が行%、margin = 2 が列%です。pandas・SQL と同じ数字になりました。table(年代 = …) のように名前を付けると、表の左上に項目名が出ます。

無回答と並び順

table() も既定では NA(無回答)を数えません。useNA = "ifany" を付けると、NA の列が現れます。並び順は factor() の levels で決めます。

df$満足度 <- factor(df$満足度, levels = c("満足", "普通", "不満"))
df$端末 <- factor(df$端末, levels = c("スマホ", "PC", "タブレット"))
table(df$端末, df$満足度, useNA = "ifany")

             満足 普通 不満 <NA>
  スマホ      182  126   54    1
  PC           62   72   46    1
  タブレット   13   21   21    1

CSV を読むときの na.strings = "" は、空欄を NA として読ませる指定です。付けないと空の文字列 "" が1つの選択肢として数えられます。

xtabs と ftable

xtabs() は式(~ 年代 + 端末)で書く版で、左辺に数値の列を置くとマスごとの合計になります。3つ以上を掛けた表は ftable() で平らに表示できます。

xtabs(利用分 ~ 年代 + 端末, data = df)
ftable(xtabs(~ 年代 + 端末 + 満足度, data = df))
          端末
年代     スマホ   PC タブレット
  20代       4976 1005        429
  30代       3676 2222        367
  40代       1723 2605        528
  50代以上    573 1448        217
                    満足度 満足 普通 不満
年代     端末
20代     スマホ              77   59   30
         PC                  11   11    4
         タブレット           2    7    4

pandas・SQL・Rの対応表と、100万件での速さ

この節では、3つの道具で同じことをする書き方の対応と、データが大きくなったときの速さの違いが分かります。

やりたいこと pandas SQL R
件数の表 pd.crosstab(a, b) CASE か FILTER を列ごとに table(a, b)
合計を付ける margins=True UNION ALL か ROLLUP addmargins()
行% normalize="index" 件数 / count(*) prop.table(t, 1)
列% normalize="columns" ウィンドウ関数で分母 prop.table(t, 2)
無回答も数える fillna("無回答") SUM(x IS NULL) useNA="ifany"
平均などを集計 pivot_table(aggfunc=) AVG(CASE …) tapply()
並び順を決める Categorical 列を書く順番 factor(levels=)

速さは、年代4区分×端末3区分の100万件で、同じ件数表を作る時間を測りました(5回の中央値)。

31mspandas(カテゴリ型+value_counts)文字列のままだと157ms
33msR(factor+table)文字列のままだと98ms
157msPostgreSQL 16(FILTER)SQLite 3.45 の CASE は521ms
方法(100万件) 文字列のまま カテゴリ型
pandas crosstab 296.3ms 93.4ms
pandas pivot_table(aggfunc="size") 199.9ms 40.8ms
pandas groupby().size().unstack() 174.1ms 38.5ms
pandas value_counts().unstack() 157.0ms 30.9ms
R table() 98ms 33ms

どの道具でも、選択肢をカテゴリ型(R は factor)にすると3〜5倍速くなりました。 pandas の crosstab は書きやすさと引き換えに4つの中でいちばん遅く、何百回も回す処理なら groupby か value_counts に書き換える価値があります。600件のアンケートなら、どれでも一瞬です。データベースの中にデータがあるなら、全件を取り出さずに SQL で集計し、結果の小さな表だけを持ち出すほうが速く、メモリも使いません。

複数回答・無回答のクロス集計(分母の決め方)

この節では、「当てはまるものをすべて選ぶ」設問と無回答を、クロス集計でどう数えるかが分かります。

単一回答(SA:1つだけ選ぶ)の設問は、行%を横に足すと100%になります。複数回答(MA:いくつでも選ぶ)の設問は、分母を「回答者数」にするか「回答数」にするかで数字が変わり、回答者数を分母にすると合計は100%を超えます。 使っている機能(4つから複数選択)を年代と掛けました。

f = ["機能_検索", "機能_お気に入り", "機能_通知", "機能_共有"]
g = df.groupby("年代")[f]
cnt = g.sum()
n = g.size()
print(cnt.div(n, axis=0) * 100)                 # 回答者ベース
print(cnt.div(cnt.sum(axis=1), axis=0) * 100)   # 回答数ベース
年代 お気に入り(回答者ベース) お気に入り(回答数ベース) 4機能の合計(回答者ベース)
20代(n=206) 51.9% 27.0% 192.7%
30代(n=188) 58.0% 29.8% 194.7%
40代(n=145) 57.9% 28.6% 202.8%
50代以上(n=61) 65.6% 32.0% 204.9%

回答者ベースは「20代の51.9%がお気に入りを使っている」と読め、人に関する問いに答えられます。回答数ベースは「20代が選んだ機能のうち27.0%がお気に入り」で、選ばれた機能の構成比です。「〇〇を使っている人の割合」を知りたいなら回答者ベースが基本で、表には「複数回答のため合計は100%を超える」と書き添えます。

複数回答の 0/1 の列を縦持ちに直すと、crosstab で回答数の表を作れます。合計が回答者数(600)ではなく回答数(1,182)になる点が、単一回答との違いです。

long = df.melt(id_vars=["id", "年代"], value_vars=f, var_name="機能", value_name="使う").query("使う == 1")
pd.crosstab(long["年代"], long["機能"], margins=True)
機能     機能_お気に入り  機能_共有  機能_検索  機能_通知   All
年代
20代         107     45    168     77   397
30代         109     33    152     72   366
40代          84     31    124     55   294
50代以上        40     14     49     22   125
All         340    123    493    226  1182

無回答の扱いを決めておく

無回答は、道具ごとに扱いが違いました。pandas の crosstab と R の table は黙って除き、SQLite の CSV 取り込みでは空欄が NULL ではなく空の文字列として入りました。

t     count(*)
----  --------
text  3       

上は、SQLite に .import した直後に typeof(満足度) を数えた結果です。空欄3件が型 text(空文字)で入っていて、満足度 IS NULL では見つかりません。UPDATE survey SET 満足度 = NULL WHERE 満足度 = '' で直してから集計すると、無回答を正しく分けられました。

端末     満足   普通   不満  無回答  回答者  全員
-----  ---  ---  --  ---  ---  ---
PC     62   72   46  1    180  181
スマホ    182  126  54  1    362  363
タブレット  13   21   21  1    55   56 

分母に無回答を含めるか(全員)、除くか(回答者)は、集計計画の段階で決め、表に書きます。

n数が小さいセルの読み方とカイ二乗検定

この節では、人数の少ないグループの割合がどれくらいぶれるのかと、差が偶然かどうかを確かめる方法が分かります。

クロス集計表の割合は、n が小さい行ほど誤差が大きくなります。 割合が50%付近のとき、95%の確からしさで見込まれる誤差の幅(±)を、n ごとに計算しました(正規近似:1.96×√(0.25÷n))。

n(そのグループの人数) 誤差の目安(±ポイント) 今回の表では
30 ±17.9 多重クロスの1マスによくある大きさ
61 ±12.5 50代以上
100 ±9.8
206 ±6.8 20代
400 ±4.9
1,000 ±3.1

たとえば50代以上のタブレット利用率19.7%(12/61)は、同じ計算で9.7%〜29.6%の幅を持ちます。20代の6.8%(14/206)は3.4%〜10.2%です。2つの幅は重ならないので年代の差はあると言えそうですが、30代7.4%と40代11.0%の差(3.6ポイント)は誤差の範囲に収まります。 多くの調査会社が n=30 未満の行に「参考値」の印を付けるのは、このぶれが大きいためです。

カイ二乗検定は「差が偶然か」を一言で答える

表全体として、年代と端末に関係があるかは、カイ二乗検定で確かめられます。「年代と端末は無関係」と仮定したときの期待度数と、実際の人数のずれが偶然で起きる確率(p値)を出す検定です。 SciPy と R で同じ値になりました。

from scipy.stats import chi2_contingency
r = chi2_contingency(pd.crosstab(df["年代"], df["端末"]))
print(f"chi2={r.statistic:.2f} dof={r.dof} p={r.pvalue:.3g}")
chi2=84.59 dof=6 p=4.01e-16
	Pearson's Chi-squared test

data:  t
X-squared = 84.593, df = 6, p-value = 4.009e-16

p値はきわめて小さく、偶然とは考えにくい差です。どのマスが偏っているかは R の chisq.test(t)$stdres(調整済み残差)で見られ、20代×スマホが +7.28、50代以上×スマホが -5.78 でした(絶対値がおよそ2を超えるマスが目立つ偏り)。一方、50代以上の61人だけで端末×満足度を検定すると、R は次の警告を出しました。

X-squared = 2.5778, df = 4, p-value = 0.6308

Warning message:
In chisq.test(small) : Chi-squared approximation may be incorrect

期待度数が5未満のマスが多く、近似が当てにならないという警告です。p値の意味と誤解は、関連記事「p値とは|意味・有意水準・よくある誤解をPythonで実演」で扱っています。

クロス集計の読み違い(シンプソンのパラドックス・相関と因果)

この節では、正しく集計したのに結論を誤る典型と、それを防ぐ見方が分かります。

全体のクロス集計と、別の項目で分けたクロス集計とで、結論が逆になることがあります。これをシンプソンのパラドックスと呼びます。 架空の施策 A・B の購入率で再現しました(700件)。

### 全体(行%)
購入     0     1
施策
A   22.0  78.0
B   17.4  82.6
### 端末別(行%)
購入         0     1
端末  施策
PC  A    6.9  93.1
    B   13.3  86.7
スマホ A   27.0  73.0
    B   31.2  68.8

全体では B の購入率(82.6%)が A(78.0%)より高いのに、PC でもスマホでも A のほうが高くなっています。人数を見ると理由が分かります。

購入        0    1  All
端末  施策
PC  A     6   81   87
    B    36  234  270
スマホ A    71  192  263
    B    25   55   80

B は購入率の高い PC の利用者に多く配られ(270人)、A は購入率の低いスマホの利用者に多く配られていました(263人)。 全体の差は施策の差ではなく、配った相手の端末の差でした。

1第3の項目で分けて見る

結論を出す前に、端末・年代など結果に効きそうな項目で分けたクロス集計も出し、向きが変わらないかを見る。

2グループの構成をそろえる

比べるグループで端末や年代の構成が違うなら、それ自体を表にして示す。施策なら、配る相手を無作為に分ける。

3関係は原因ではない

「50代以上は PC が多い」は関係を示すだけ。年齢が理由か、仕事の環境が理由かは、クロス集計だけでは決められない。

クロス集計のよくあるエラーとつまずき

この節では、クロス集計表を作るときに実際に出たエラーと、黙って起きる失敗の直し方が分かります。

症状・エラー原文 原因 直し方
ValueError: Index contains duplicate entries, cannot reshape pandas の pivot(集計しない変形)に、同じ年代×端末の行が複数ある 集計するなら pivot_table か crosstab を使う
ValueError: Length of values (500) does not match length of index (600) crosstab に長さの違う配列を渡した(片方だけ絞り込んだ) 絞り込みは DataFrame に対して行い、同じ df から2列を渡す
KeyError: '年齢' 列名の誤り(実際は「年代」) df.columns で列名を確かめる。全角・半角スペースにも注意
R:all arguments must have the same length table() に長さの違うベクトルを渡した 同じデータフレームの列どうしで渡す
R:invalid multibyte character in parser ロケールが UTF-8 でない環境で日本語の列名を含むスクリプトを読んだ LANG=C.UTF-8 などの UTF-8 ロケールで起動する
PostgreSQL:function crosstab(unknown) does not exist tablefunc 拡張を入れていないデータベースで呼んだ CREATE EXTENSION tablefunc;(権限が要る)か、FILTER で書く
PostgreSQL:return and sql tuple descriptions are incompatible AS ct(…) の列の型が、元の SQL の結果と合わない count(*) の列は bigint と書く
PostgreSQL:Query-specified return tuple has 3 columns but crosstab returns 4. AS ct(…) の列の数が、選択肢の数+1と合わない 選択肢の数だけ列を書く
合計が件数と合わない(600のはずが597) 無回答(NaN・NA)が黙って除かれた fillna・useNA で無回答を数え、分母を決める
SQL で 0 のはずのマスが空(NULL) CASE WHEN … THEN 1 END と ELSE を書かず、該当0件のマスが NULL になった ELSE 0 を書くか、count(*) FILTER を使う
選択肢の並びが「不満・普通・満足」 文字コードの順に並んだ Categorical・factor(levels=) で順番を決める

最後の2つと、PostgreSQL の1引数版 crosstab で値がずれる失敗は、エラーにならずに間違った表ができます。表を作ったら、合計の行と列が元データの件数と合うかを必ず照らし合わせます。

判断表:どの集計・どの道具を使うか

この節では、手元のデータと目的から、集計の形と道具を選ぶ順番が分かります。

次の順に当てはまるものを選ぶと、迷わずに決められます。

  1. 1まず全体像を知りたい → 単純集計選択肢ごとの人数と割合、無回答の数、入力ミスを確かめる
  2. 2グループで違いを比べたい → 2項目のクロス集計(行%)比べたいグループを表側に置く
  3. 3差が偶然か確かめたい → カイ二乗検定と残差期待度数5未満のマスが多いときは区分をまとめる
  4. 4別の項目が効いていないか気になる → 多重クロス集計1マスの n が30を切るなら、区分をまとめるか参考値にする
  5. 5人数ではなく平均や合計を見たい → pivot_table・xtabs少人数のマスの平均は外れ値で動く
状況 向く道具 理由
CSV で受け取り、Python で分析を続ける pandas の crosstab 1行で合計・割合まで出せる。グラフや検定にそのままつなげる
データがデータベースにあり、件数が多い SQL(CASE・FILTER) 全件を取り出さずに集計し、小さな結果だけ持ち出せる
定期レポートとして毎月同じ表を出す SQL のビュー か pandas のスクリプト 手で作り直さず、同じ手順を繰り返せる
検定や統計モデルまで進める R の table・chisq.test 標準の機能だけで検定と残差まで出る
選択肢が増減する(列を決め打ちできない) pandas・R SQL の CASE は選択肢ごとに列を書く必要がある

クロス集計のよくある質問

この節では、クロス集計について検索されることの多い疑問に、短く答えます。

Q. クロス集計と単純集計の違いは何ですか?
単純集計は1つの項目を選択肢ごとに数える集計、クロス集計は2つ以上の項目を掛け合わせてグループ別に数える集計です。今回の例では、単純集計で「スマホ60.5%」、クロス集計で「20代80.6%・50代以上26.2%」が分かりました。

Q. 表側と表頭はどちらに何を置けばよいですか?
比べたいグループ(年代・地域・施策など、原因側に近い項目)を表側(左)、答えの選択肢(結果側)を表頭(上)に置き、行%で読むのが基本です。

Q. 行%と列%はどちらを使えばよいですか?
グループごとに答えが違うかを知りたいなら行%です。ある答えを選んだ人がどんな人たちかを知りたいときだけ列%を使います。グループの人数がそろっていないと、列%は人数の多いグループに引っぱられます。

Q. クロス集計とピボットテーブルは同じですか?
ピボットは、縦に長いデータを横に広げて集計表にする操作の名前で、クロス集計表を作る方法の1つです。pandas の pivot_table も、クロス集計表を作る関数として使えます。

Q. pandas の crosstab と pivot_table はどう使い分けますか?
人数や割合の表なら crosstab が短く書け、normalize で行%・列%も出せます。平均や合計など、別の数値列を集計したいときは pivot_table が読みやすくなります。100万件では pivot_table のほうが速く測れました。

Q. 何人いればクロス集計してよいですか?
決まった下限はありませんが、1行(グループ)の n が30を切ると、割合の誤差は±18ポイント近くになります。n=30未満の行は参考値として扱い、区分をまとめることを検討します。

Q. 複数回答の設問はどうクロス集計しますか?
選択肢ごとに「選んだ=1」の列を作り、グループごとの回答者数で割ります。合計は100%を超えるので、表に「複数回答」と書き添えます。

Q. 5段階評価の設問はそのままクロス集計しますか?
そのまま5列で出すと差が読みにくいときは、「とても満足+やや満足」のように上位2つを合わせた割合(トップ2ボックス)を1列にして比べる方法がよく使われます。合わせ方は集計計画で決めておきます。

Q. カイ二乗検定で有意なら、原因が分かったことになりますか?
なりません。分かるのは「2つの項目に関係がありそうだ」ということまでです。別の項目が両方に効いている可能性は、多重クロス集計やシンプソンのパラドックスの節のように分けて確かめます。

Q. 3つ以上の項目を掛けてもよいですか?
掛けられますが、マスの数が掛け算で増え、1マスの人数が急に減ります。今回の600件でも、年代×端末×満足度で2人しかいないマスが出ました。

まとめ

  • クロス集計とは、2つ以上の項目を掛け合わせ、グループごとの人数や割合を数える集計で、単純集計の平均に隠れた違いが見える。
  • 比べたいグループを表側に置き、行%で読む。 列%は人数の多いグループに引っぱられ、今回のタブレットの例では結論が逆になった。
  • pandas は pd.crosstab、SQL は SUM(CASE …) や FILTER、R は table+prop.table で、同じ表が同じ数字で作れた。
  • 無回答は黙って除かれ(600→597)、SQLite では空欄が空文字で入った。合計がデータの件数と合うかを必ず照らし合わせる。
  • n が小さいマスの差は誤差の範囲のことが多い。カイ二乗検定と、第3の項目で分けたクロス集計で確かめてから結論を出す。

参考・出典

確認日はいずれも 2026年10月29日です。

  • pandas「pandas.crosstab」:https://pandas.pydata.org/docs/reference/api/pandas.crosstab.html
  • pandas「pandas.pivot_table」:https://pandas.pydata.org/docs/reference/api/pandas.pivot_table.html
  • pandas User Guide「Reshaping and pivot tables」:https://pandas.pydata.org/docs/user_guide/reshaping.html
  • SciPy「scipy.stats.chi2_contingency」:https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.chi2_contingency.html
  • PostgreSQL 16「F.43. tablefunc — functions that return tables (crosstab and others)」:https://www.postgresql.org/docs/16/tablefunc.html
  • PostgreSQL 16「4.2.7. Aggregate Expressions」(FILTER 句):https://www.postgresql.org/docs/16/sql-expressions.html#SYNTAX-AGGREGATES
  • PostgreSQL 16「7.2.4. GROUPING SETS, CUBE, and ROLLUP」:https://www.postgresql.org/docs/16/queries-table-expressions.html#QUERIES-GROUPING-SETS
  • SQLite「Command Line Shell For SQLite」(.import):https://sqlite.org/cli.html
  • R Documentation「table: Cross Tabulation and Table Creation」:https://stat.ethz.ch/R-manual/R-devel/library/base/html/table.html
  • R Documentation「prop.table / proportions」:https://stat.ethz.ch/R-manual/R-devel/library/base/html/proportions.html
  • R Documentation「chisq.test: Pearson's Chi-squared Test for Count Data」:https://stat.ethz.ch/R-manual/R-devel/library/stats/html/chisq.test.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「クロス集計とは?作り方と読み方|pandas・SQL・R実行例つき」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ
参加受付中

AI駆動開発、 無料セミナー