Python で記述統計を自動化する 7 つのステップ
KDnuggets は Python を用いて記述統計の計算プロセスを自動化するための具体的な 7 つの手順を解説し、データ分析業務の効率化と再現性の向上に寄与する実用的なガイドを提供している。
キーポイント
自動化のための構造化アプローチ
記述統計の計算を手動で行うのではなく、Python のライブラリを活用して標準化された 7 つの手順でプロセスを構築する手法が提案されている。
データ分析の効率化と再現性
スクリプトによる自動化により、時間のかかる手作業を削減し、同じ結果を常に再現可能にするための基盤作りが可能になる。
実務への即応可能な技術解説
理論的な説明だけでなく、実際のコード例や具体的なライブラリの使い方を示すことで、現場のデータサイエンティストがすぐに適用できる内容となっている。
重要な引用
Automating descriptive statistics allows data scientists to focus on interpretation rather than calculation.
The process involves defining a clear set of steps that can be encoded into a Python script.
影響分析・編集コメントを表示
影響分析
この記事は、データ分析業務における反復作業の自動化という普遍的な課題に対して、Python を活用した具体的な解決策を提示しています。特に、記述統計のような基礎的な処理をスクリプト化することで、データサイエンティストがより高度な分析や意思決定にリソースを割けるようになり、組織全体の分析効率と品質向上に寄与すると考えられます。
編集コメント
AI や機械学習の前段階となるデータ理解の基盤を強化する、非常に実用的な技術記事です。
image**
# イントロダクション
すべての分析は同じように始まります:データセットを読み込み、その中身が実際に何であるかを把握しようとします。行数はいくつ?数値型のカラムはどれか?欠損値はどの程度あるか?何か極端に歪んでいないか?これらの質問に対する答えを、私たちが千回以上も入力してきた同じ df.describe() や df.isna().sum(), df.groupby(...).agg(...) のスニペットをコピー&ペーストして得て、レポートに組み込む際に手動で出力を再フォーマットするという方法で返すことがほとんどです。
それは無駄な労力です。Python エコシステムには、生データフレームから数行のコードだけで整形された共有可能な要約テーブルを作成するツールや、研究論文で主に目にするような「Table 1」の作成に特化したツールが既に存在します。このチュートリアルでは、バラバラのスニペットの山ではなく、繰り返し再利用可能なパイプラインを構築するための 7 つのステップを解説していきます。ここでは Palmer Penguins dataset を通して説明を行います。これは小さく、オープンソースであり、数値型とカテゴリカル型の列が現実的に混合しており、実際の欠損値も含まれ、自然なグループ化変数(種別)も備えています。では、始めましょう。
# 1. 環境のセットアップとデータの読み込み
本チュートリアルで使用するパッケージをインストールしてください:
pip install pandas seaborn skimpy tableone great-tables fg-data-profiling
最後のツールに関する重要な注意点:人気のプロファイリングライブラリは一度ではなく複数回名前を変更されています。当初は pandas-profiling でしたが、2023 年に ydata-profiling に改名され、さらに 2026 年 4 月には fg-data-profiling と再度改名されました。古い ydata-profiling パッケージはまだインストールして実行可能ですが、更新は行われていないため、新しいプロジェクトでは fg-data-profiling を優先して使用すべきです。ステップ 5 では両方のインポートスタイルを解説します。
次にデータをロードしましょう。Seaborn (https://seaborn.pydata.org/) にはビルトインのペンギンデータセットが含まれており、ダウンロードの手間が省けます:
import pandas as pd
import seaborn as sns
df = sns.load_dataset("penguins")
print(df.shape)
print(df.dtypes)
print(df.isna().sum())
出力結果:
(344, 7)
species object
island object
bill_length_mm float64
bill_depth_mm float64
flipper_length_mm float64
body_mass_g float64
sex object
dtype: object
species 0
island 0
bill_length_mm 2
bill_depth_mm 2
flipper_length_mm 2
body_mass_g 2
sex 11
dtype: int64
7 つの列が表示されます。そのうち 3 つはカテゴリカル(species, island, sex)で、残りの 4 つは数値測定値(bill_length_mm, bill_depth_mm, flipper_length_mm, body_mass_g)です。各測定値には欠損値が 2 件ずつあり、sex 列には 11 件の欠損値があります。この詳細は覚えておいてください。後ほど各ツールがこの欠損データをどのように報告するかを確認します。
# 2. df.describe() でベースラインを取得する
**
Pandas の組み込み関数 describe() は明白な出発点であり、その理由も十分にあります。これは瞬時に実行でき、追加のインストールは不要です:
df.describe().round(2)
出力結果:
bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
count 342.00 342.00 342.00 342.00
mean 43.92 17.15 200.92 4201.75
std 5.46 1.97 14.06 801.95
min 32.10 13.10 172.00 2700.00
25% 39.22 15.60 190.00 3550.00
50% 44.45 17.30 197.00 4050.00
75% 48.50 18.70 213.00 4750.00
max 59.60 21.50 231.00 6300.00
これは確かに有用ですが、その盲点を注意深く見る必要があります。デフォルトでは、カテゴリカル列(categorical columns)を完全に無視します。非null(non-null)値の数はカウントされますが、欠損値の割合を直接示すことはありません。また、5 数要約(five-number summary)で止まり、歪度(skewness)や尖度(kurtosis)、分布形状に関する洞察は得られません。素早い確認用としては問題ありませんが、報告書の基盤として使うには不十分です。次のステップでは、Pandas を離れることなくこれらのギャップを埋めることができます。
# 3. include, .agg(), groupby を活用して Pandas の可能性を広げる
外部パッケージに頼る前に、Pandas 単体でどこまでできるかを知っておく価値があります。多くの日常的な作業においては、これだけで十分だからです。
まず、カテゴリカル列を include="all" を指定して同じ要約に統合します:
df.describe(include="all").round(2)
出力結果:
species island bill_length_mm bill_depth_mm flipper_length_mm body_mass_g sex
count 344 344 342.00 342.00 342.00 342.00 333
unique 3 3 NaN NaN NaN NaN 2
top Adelie Biscoe NaN NaN NaN NaN Male
freq 152 168 NaN NaN NaN NaN 168
mean NaN NaN 43.92 17.15 200.92 4201.75 NaN
std NaN NaN 5.46 1.97 14.06 801.95 NaN
min NaN NaN 32.10 13.10 172.00 2700.00 NaN
25% NaN NaN 39.22 15.60 190.00 3550.00 NaN
50% NaN NaN 44.45 17.30 197.00 4050.00 NaN
75% NaN NaN 48.50 18.70 213.00 4750.00 NaN
max NaN NaN 59.60 21.50 231.00 6300.00 NaN
これで、テキスト列についても一意値 (unique)、最頻値 (top)、出現頻度 (freq) が得られ、数値統計量と共に表示されます。また、特定の統計量が適用できないセルには NaN で埋められます。1 つの表にすべての列が含まれます。
次に、.agg() を使用してカスタムの要約を作成し、describe() が省略する歪度や尖度などの統計量を含め、どの統計量が表示されるかを完全に制御できるようにします。さらに、欠損データの割合も追加しましょう:
numeric = df.select_dtypes("number")
summary = numeric.agg(["mean", "median", "std", "skew", "kurt"]).T
summary["missing_pct"] = df[numeric.columns].isna().mean().mul(100).round(1)
summary.round(2)
出力:
mean median std skew kurt missing_pct
bill_length_mm 43.92 44.45 5.46 0.05 -0.88 0.6
bill_depth_mm 17.15 17.30 1.97 -0.14 -0.91 0.6
flipper_length_mm 200.92 197.00 14.06 0.35 -0.98 0.6
body_mass_g 4201.75 4050.00 801.95 0.47 -0.72 0.6
そして三つ目 — これが人々が忘れがちなポイントですが— describe() の前に groupby() をチェーンして、単一行で層別化された要約を取得します:
df.groupby("species")["body_mass_g"].describe().round(1)
出力:
count mean std min 25% 50% 75% max
species
Adelie 151.0 3700.7 458.6 2850.0 3350.0 3700.0 4000.0 4775.0
Chinstrap 68.0 3733.1 384.3 2700.0 3487.5 3700.0 3950.0 4800.0
Gentoo 123.0 5076.0 504.1 3950.0 4700.0 5000.0 5500.0 6300.0
習得すべきパターン:select_dtypes で列を選択し、.agg([...]) で統計量を選び、groupby で層別化を行う。この 3 つの組み合わせは、依存関係を一切必要とせずに、実際のレポート作成ニーズの驚くほど大きな部分をカバーします。残りのステップは、時間の節約、仕上げの追加、「十分だ」という判断が通用しないケースへの対応に関するものです。
# 4. skimpy を使ってより豊富なコンソール要約を得る
describe() よりも詳細な情報が必要だが、手作業で組み立てるのは面倒だという場合、skimpy** が最適な選択肢です。これは、コンソールまたはノートブック上で実行され、すべての列タイプを一度に処理する、強化された describe() のようなものです。Pandas および Polars の両方の DataFrame で動作します。
from skimpy import skim
skim(df)
単一の呼び出しで構造化されたレポートが出力されます:データサマリー(行数と列数)、データタイプ別の内訳、平均値・標準偏差・完全な百分位範囲を含む数値表、各列ごとのインライン ASCII ヒストグラム、そして文字列列用の別テーブル(文字数や最も頻出/最少頻出の値など)が含まれます。欠損データは、期待される場所に、カウントとパーセンテージの両方で報告されます。
出力:
╭──────────────────────────────────────────────── skimpy summary ─────────────────────────────────────────────────╮
│ データサマリー データタイプ │
│ ┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┓ ┏━━━━━━━━━━━━━┳━━━━━━━┓ │
│ ┃ データフレーム ┃ 値 ┃ ┃ カラムタイプ ┃ 数 ┃ │
│ ┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━┩ ┡━━━━━━━━━━━━━╇━━━━━━━┩ │
│ │ 行数 │ 344 │ │ float64 │ 4 │ │
│ │ カラム数 │ 7 │ │ string │ 3 │ │
│ └───────────────────┴────────┘ └─────────────┴───────┘ │
│ 数値 │
│ ┏━━━━━━━━━━━━━━━━━━━━┳━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━┓ │
│ ┃ カラム ┃ NA ┃ NA % ┃ 平均 ┃ sd ┃ p0 ┃ p25 ┃ p50 ┃ p75 ┃ p100 ┃ hist ┃ │
│ ┡━━━━━━━━━━━━━━━━━━━━╇━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━┩ │
│ │ bill_length_mm │ 2 │ 0.5813953488372093 │ 43.92 │ 5.46 │ 32.1 │ 39.23 │ 44.45 │ 48.5 │ 59.6 │ ▃█▆█▃ │ │
│ │ bill_depth_mm │ 2 │ 0.5813953488372093 │ 17.15 │ 1.975 │ 13.1 │ 15.6 │ 17.3 │ 18.7 │ 21.5 │ ▄▅▆█▆▂ │ │
│ │ flipper_length_mm │ 2 │ 0.5813953488372093 │ 200.9 │ 14.06 │ 172 │ 190 │ 197 │ 213 │ 231 │ ▂██▄▆▃ │ │
│ │ body_mass_g │ 2 │ 0.5813953488372093 │ 4202 │ 802 │ 2700 │ 3550 │ 4050 │ 4750 │ 6300 │ ▂█▆▄▃▁ │ │
│ └────────────────────┴────┴────────────────────┴───────┴───────┴──────┴───────┴───────┴──────┴──────┴────────┘ │
│ 文字列 │
│ ┏━━━━━━━━━┳━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┓ │
│ ┃ カラム ┃ NA ┃ NA % ┃ 最短 ┃ 最長 ┃ min ┃ max ┃ chars/row ┃ words/row ┃ total words┃ │
│ ┡━━━━━━━━━╇━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━┩ │
│ │ species │ 0 │ 0 │ Adelie │ Chinstrap │ Adelie │ Gentoo │ 6.59 │ 1.00 │ 344 │ │
│ │ island │ 0 │ 0 │ Dream │ Torgersen │ Biscoe │ Torgersen │ 6.09 │ 1.00 │ 344 │ │
│ │ sex │11 │ 3.19767442 │ Male │ Female │ Female │ Male │ 4.99 │ 0.97 │ 333 │ │
│ └─────────┴────┴────────────┴──────────┴───────────┴────────┴───────────┴────────────┴───────────┴────────────┘ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
インラインヒストグラムは目玉機能です。プロットライブラリを開かずに、各分布の形状を把握できます。対話型の探索には skimpy が適した中間点と言えます:describe() よりもはるかに情報量が多く、完全な HTML レポートよりも軽量です。
# 5. プロファイリングによる完全な対話型レポートの生成
**
分布、相関、相互作用、重複検出、データ品質警告など「完全な」全体像が必要な場合、包括的なプロファイルレポートを生成するのが最適です。これは多くの分析担当者が半日かけて行っていた探索的プロットを置き換えた、たった 1 行のコードです。
メンテナンスされているパッケージを使用する場合:
from data_profiling import ProfileReport # fg-data-profiling
profile = ProfileReport(df, title="Penguins Profiling Report", explorative=True)
profile.to_file("penguins_report.html")
レガシーなパッケージを使用する場合は、インポート行のみが異なります:
import ydata_profiling # legacy ydata-profiling
出力されるのは、概要(サイズ、メモリ使用量、重複行数)、変数ごとの詳細記述(記述統計およびヒストグラム)、複数の係数による相関分析、欠損値分析、そして歪度、高カルディナリティ、定数カラムなどを自動的に警告するアラートを含む、自己完結型の対話型 HTML ファイルです。

生成されたプロファイリングレポートの一部
唯一のトレードオフは速度です:完全なレポートでは多くの計算が行われるため、大規模データセットでは処理が遅くなります。これを解決するための2つの引数があります。最も高価な計算を無効にするには minimal=True を使用し、データの全体像をつかむだけであれば、フレーム全体ではなくサンプルのプロファイルを作成します。
profile = ProfileReport(df.sample(frac=0.5), minimal=True)
また、2 つのデータセットを並べて比較するための .compare() メソッドもあります。これは、トレーニングセットと本番データの間のドリフトや、2 つの期間間の違いを発見する際に非常に役立ちます。
# 6. tableone を使用した実用的な「Table 1」の構築
これまでの内容はすべて *あなた自身* のための探索支援ツールです。tableone** はあなたの読者のためにあります。これは、ほぼすべての臨床研究および定量的研究論文で冒頭に掲載される層別化されたベースライン特性表(したがって「Table 1」と呼ばれる)を生成するもので、査読者が期待する書式と統計情報を提供します。
from tableone import TableOne
data = df.dropna(subset=["sex"])
columns = ["bill_length_mm", "bill_depth_mm",
"flipper_length_mm", "body_mass_g", "island", "sex"]
categorical = ["island", "sex"]
nonnormal = ["body_mass_g"] # 平均 (標準偏差) の代わりに中央値 [IQR] で要約
table1 = TableOne(
data,
columns=columns,
categorical=categorical,
nonnormal=nonnormal,
groupby="species",
pval=True,
smd=True,
)
print(table1.tabulate(tablefmt="github"))
その結果、適切に書式設定された表が得られます。連続変数は平均(標準偏差)、カテゴリカル変数は件数(%)、非正規とフラグ付けされた変数は中央値 [第1四分位数,第3四分位数] として表示され、グループ化変数ごとに層別化されます。さらに、欠損データのカラム、p 値、およびグループ間の標準化平均差 (SMD) も含まれます。
| Missing | Overall | Adelie | Chinstrap | Gentoo | SMD (Adelie,Chinstrap) | SMD (Adelie,Gentoo) | SMD (Chinstrap,Gentoo) | P-Value | ||
|---|---|---|---|---|---|---|---|---|---|---|
| n | 333 | 146 | 68 | 119 | ||||||
| bill_length_mm, mean (SD) | 0 | 44.0 (5.5) | 38.8 (2.7) | 48.8 (3.3) | 47.6 (3.1) | 3.315 | 3.023 | -0.393 | <0.001 | |
| bill_depth_mm, mean (SD) | 0 | 17.2 (2.0) | 18.3 (1.2) | 18.4 (1.1) | 15.0 (1.0) | 0.062 | -3.022 | -3.220 | <0.001 | |
| flipper_length_mm, mean (SD) | 0 | 201.0 (14.0) | 190.1 (6.5) | 195.8 (7.1) | 217.2 (6.6) | 0.837 | 4.140 | 3.119 | <0.001 | |
| body_mass_g, median [Q1,Q3] | 0 | 4050.0 [3550.0,4775.0] | 3700.0 [3362.5,4000.0] | 3700.0 [3487.5,3950.0] | 5050.0 [4700.0,5500.0] | 0.064 | 2.885 | 3.043 | <0.001 | |
| island, n (%) | Biscoe | 163 (48.9) | 44 (30.1) | 0 (0.0) | 119 (100.0) | 1.819 | 2.153 | nan | <0.001 | |
| Dream | 123 (36.9) | 55 (37.7) | 68 (100.0) | 0 (0.0) | ||||||
| Torgersen | 47 (14.1) | 47 (32.2) | 0 (0.0) | 0 (0.0) | ||||||
| sex, n (%) | Female | 165 (49.5) | 73 (50.0) | 34 (50.0) | 58 (48.7) | <0.001 | 0.025 | 0.025 | 0.976 | |
| Male | 168 (50.5) | 73 (50.0) | 34 (50.0) | 61 (51.3) |
真の成果はエクスポートにあります。TableOne オブジェクトは、原稿に必要な形式(LaTeX:Overleaf への貼り付け用、HTML、Markdown、CSV)に直接変換されます。
table1.to_latex("table1.tex")
table1.to_html("table1.html")
table1.to_csv("table1.csv")
パッケージの著者が強調し、私も同様に強調する重要な点があります。自動化された統計解析は、判断力を代替するものではないということです。どの検定を行うか、変数が本当に正規分布に従っているかどうか、欠損値をどう扱うかなどは、出版される前に必ず人間のレビューが必要です。tableone は退屈な作業を取り除くものであり、責任まで取り除くものではありません。
# 7. Great Tables を用いて出版品質のテーブルに仕上げる
**
tableone は研究用のテーブルを特にフォーマットするために設計されています。一方、*それ以外の*要約(ビジネスレポート、スライド、README など)については、Great Tables が、R 言語の gt パッケージと同様に、単なる DataFrame をスタイル付けされたプレゼンテーション用のテーブルに変換します。これは Pandas または Polars のフレームを受け取り、HTML または画像としてレンダリングします。
ステップ 3 で作成したカスタム要約を、さらに見栄え良く整えてみましょう:
from great_tables import GT, md
numeric = df.select_dtypes("number")
stats = (numeric.agg(["mean", "median", "std", "min", "max"]).T
.rename_axis("measurement").reset_index())
stats["missing_pct"] = df[numeric.columns].isna().mean().mul(100).values
table = (
GT(stats, rowname_col="measurement")
.tab_header(title="ペンギンの体測定値",
subtitle="記述統計、パーマー諸島)")
.fmt_number(columns=["mean", "median", "std", "min", "max"], decimals=1)
.fmt_percent(columns="missing_pct", decimals=1, scale_values=False)
.data_color(columns="std", palette="Blues")
.tab_source_note(md("出典:*palmerpenguins* データセット (Horst et al.)"))
)
ここではいくつかの処理が行われています。fmt_number と fmt_percent は表示形式を扱うため、手動で丸めを行う必要がなくなります。data_color は std 列に色グラデートを適用し、最も変動の大きい測定値に視線を集めます。tab_header と tab_source_note は、表を完成させたように見せるためのタイトルと出典情報を追加します。さらに、カラムスパンナーや条件付きスタイル、インラインスパークラインなど、できることはたくさんありますが、これだけでもステークホルダーに提示するのに十分なものが作れます。
結果を使用するには、HTML 文字列としてレンダリングします(どこでも動作し、追加の依存関係は不要):
html = table.as_raw_html()
with open("summary_table.html", "w") as f:
f.write(html)
image**
スタイル付けされた Great Tables の出力
# 結び:一度関数化すれば、毎回使える
自動化の目的は反復可能性にあります。パイプラインをラップして、次のデータセットでも単一の呼び出しで済むようにします:
from great_tables import GT, md
def descriptive_report(df, decimals=1):
numeric = df.select_dtypes("number")
stats = (numeric.agg(["count", "mean", "median", "std", "min", "max"]).T
.rename_axis("variable").reset_index())
stats["missing_%"] = df[numeric.columns].isna().mean().mul(100).values
return (
GT(stats, rowname_col="variable")
.tab_header(title="Descriptive Statistics",
subtitle=f"{len(df):,} rows x {df.shape[1]} columns")
.fmt_integer(columns="count")
.fmt_number(columns=["mean", "median", "std", "min", "max"], decimals=decimals)
.fmt_percent(columns="missing_%", decimals=1, scale_values=False)
.data_color(columns="std", palette="Blues")
.tab_source_note(md("Generated automatically with pandas + Great Tables."))
)
descriptive_report(df) # point it at any DataFrame
That's the difference between a snippet and a tool: you write it once and reuse it on every dataset that lands on your desk.
# Wrapping Up
Descriptive statistics don't have to be a chore you re-implement on every project. The ladder we climbed has a rung for every situation:
- Pandas describe() と .agg(): 依存関係ゼロ、クイックチェックやカスタム要約に最適。
- skimpy: 1 つの呼び出しでヒストグラムと欠損データ割合を含む、より豊富なコンソールサマリーを提供。
- fg-data-profiling: 包括的な探索的データ分析(EDA)の全体像が必要な場合に、完全なインタラクティブ HTML レポートを生成。
- tableone: 研究論文向けに層別化された「Table 1」を作成し、p 値と標準化平均差(SMDs)を含め、LaTeX、HTML、CSV へのワンラインエクスポートが可能。
- Great Tables: 作成したあらゆる要約に対して、洗練された出版品質のスタイリングを提供。
質問に答える最も軽量なツールを選びましょう。5 秒で Sanity Check を行うなら describe() が勝利します。論文用なら tableone と Great Tables がその価値を発揮します。そして、お気に入りの組み合わせを関数としてラップすれば、記述統計を「実行する」のではなく「走らせる」ことができるようになります — これがまさに目指すべき状態であり、脳を使う必要がある分析に時間を割くためです。
Kanwal Mehreen は、データサイエンスと AI と医療の交差点に深い情熱を持つ機械学習エンジニアであり技術ライターです。彼女は「ChatGPT で生産性を最大化する」という電子書籍の共著者でもあります。APAC 地域の Google Generation Scholar 2022 として、多様性と学術的卓越性を提唱しています。また、Teradata Diversity in Tech Scholar、Mitacs Globalink Research Scholar、Harvard WeCode Scholar としても認定されています。Kanwal は変革の熱心な支持者であり、STEM 分野における女性のエンパワーメントを目的とした FEMCodes を設立しました。
原文を表示

**
# Introduction
Every analysis starts the same way: you load a dataset and try to figure out what's actually in it. How many rows? Which columns are numeric? How much is missing? Is anything wildly skewed? Most of us answer those questions by copy-pasting the same df.describe(), df.isna().sum(), and df.groupby(...).agg(...) snippets we've typed a thousand times, then reformatting the output by hand when it's time to drop it into a report.
That's wasted effort. The Python ecosystem now has tools that take you from a raw DataFrame to a formatted, shareable summary table in one or two lines — and others built specifically to produce the kind of "Table 1" that you mostly see in research papers. This tutorial walks you through the 7 steps to build a repeatable pipeline rather than a pile of one-off snippets. We'll use the Palmer Penguins dataset** throughout. It's small, it's open, and it has a realistic mix of numeric and categorical columns, real missing values, and a natural grouping variable (species). So let's get started.
# 1. Setting Up Your Environment and Loading the Data
**
Install the packages we'll use in this tutorial:
pip install pandas seaborn skimpy tableone great-tables fg-data-profilingAn important note on the last one: the popular profiling library has been renamed more than once. It was initially pandas-profiling, became ydata-profiling in 2023, and was renamed again to fg-data-profiling** in April 2026. The older ydata-profiling package still installs and runs, but it no longer receives updates, so new projects should prefer fg-data-profiling. We'll cover both import styles in Step 5.
Now load the data. Seaborn has a built-in penguins dataset, which saves us a download:
import pandas as pd
import seaborn as sns
df = sns.load_dataset("penguins")
print(df.shape)
print(df.dtypes)
print(df.isna().sum())Output:
(344, 7)
species object
island object
bill_length_mm float64
bill_depth_mm float64
flipper_length_mm float64
body_mass_g float64
sex object
dtype: object
species 0
island 0
bill_length_mm 2
bill_depth_mm 2
flipper_length_mm 2
body_mass_g 2
sex 11
dtype: int64You'll see seven columns: three categorical (species, island, sex) and four numeric measurements (bill_length_mm, bill_depth_mm, flipper_length_mm, body_mass_g). The measurements have 2 missing values each, and sex has 11. Hold onto this detail — we will see how each tool reports this missing data.
# 2. Getting the Baseline with df.describe()
**
Pandas' built-in describe() is the obvious starting point, and for good reason. It's instant and requires no additional installation:
df.describe().round(2)Output:
bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
count 342.00 342.00 342.00 342.00
mean 43.92 17.15 200.92 4201.75
std 5.46 1.97 14.06 801.95
min 32.10 13.10 172.00 2700.00
25% 39.22 15.60 190.00 3550.00
50% 44.45 17.30 197.00 4050.00
75% 48.50 18.70 213.00 4750.00
max 59.60 21.50 231.00 6300.00This is genuinely useful, but notice its blind spots. By default it ignores categorical columns entirely. It gives you a count of *non-null* values but never tells you the missing percentage directly. And it stops at the five-number summary — no skewness, no kurtosis, no sense of distribution shape. For a quick gut check it's fine, but as the foundation of a report it leaves gaps. The next step closes some of them without leaving Pandas.
# 3. Pushing Pandas Further with include, .agg(), and groupby
Before reaching for external packages, it's worth knowing how far Pandas alone can take you — because for a lot of everyday work, this is enough.
First, fold the categorical columns into the same summary with include="all":
df.describe(include="all").round(2)Output:
species island bill_length_mm bill_depth_mm flipper_length_mm body_mass_g sex
count 344 344 342.00 342.00 342.00 342.00 333
unique 3 3 NaN NaN NaN NaN 2
top Adelie Biscoe NaN NaN NaN NaN Male
freq 152 168 NaN NaN NaN NaN 168
mean NaN NaN 43.92 17.15 200.92 4201.75 NaN
std NaN NaN 5.46 1.97 14.06 801.95 NaN
min NaN NaN 32.10 13.10 172.00 2700.00 NaN
25% NaN NaN 39.22 15.60 190.00 3550.00 NaN
50% NaN NaN 44.45 17.30 197.00 4050.00 NaN
75% NaN NaN 48.50 18.70 213.00 4750.00 NaN
max NaN NaN 59.60 21.50 231.00 6300.00 NaNNow you get unique, top, and freq for the text columns alongside the numeric stats (with NaN filling the cells where a statistic doesn't apply). One table, every column.
Second, build a custom summary with .agg() so you control exactly which statistics appear — including ones describe() omits, like skewness and kurtosis — and bolt on a missing-data percentage:
numeric = df.select_dtypes("number")
summary = numeric.agg(["mean", "median", "std", "skew", "kurt"]).T
summary["missing_pct"] = df[numeric.columns].isna().mean().mul(100).round(1)
summary.round(2)Output:
mean median std skew kurt missing_pct
bill_length_mm 43.92 44.45 5.46 0.05 -0.88 0.6
bill_depth_mm 17.15 17.30 1.97 -0.14 -0.91 0.6
flipper_length_mm 200.92 197.00 14.06 0.35 -0.98 0.6
body_mass_g 4201.75 4050.00 801.95 0.47 -0.72 0.6Third — and this is the move people forget — chain groupby() in front of describe() to get a stratified summary in a single line:
df.groupby("species")["body_mass_g"].describe().round(1)Output:
count mean std min 25% 50% 75% max
species
Adelie 151.0 3700.7 458.6 2850.0 3350.0 3700.0 4000.0 4775.0
Chinstrap 68.0 3733.1 384.3 2700.0 3487.5 3700.0 3950.0 4800.0
Gentoo 123.0 5076.0 504.1 3950.0 4700.0 5000.0 5500.0 6300.0The pattern to internalize: select_dtypes to choose columns, .agg([...]) to choose statistics, groupby to choose strata. This trio handles a surprising share of real reporting needs with zero dependencies. The remaining steps are about saving time, adding polish, and handling the cases where "good enough" isn't.
# 4. Getting a Richer Console Summary with skimpy
When you want more than describe() but don't want to assemble it by hand, skimpy** is the right option. It is like a supercharged describe() that runs in your console or notebook and handles every column type at once. It works with both Pandas and Polars DataFrames.
from skimpy import skim
skim(df)A single call prints a structured report: a data summary (row and column counts), a breakdown by data type, a numeric table with mean, standard deviation, the full percentile spread, and an inline ASCII histogram per column, plus a separate table for string columns showing things like character counts and most/least frequent values. Missing data is reported as both a count and a percentage, right where you'd expect it.
Output:
╭──────────────────────────────────────────────── skimpy summary ─────────────────────────────────────────────────╮
│ Data Summary Data Types │
│ ┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┓ ┏━━━━━━━━━━━━━┳━━━━━━━┓ │
│ ┃ Dataframe ┃ Values ┃ ┃ Column Type ┃ Count ┃ │
│ ┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━┩ ┡━━━━━━━━━━━━━╇━━━━━━━┩ │
│ │ Number of rows │ 344 │ │ float64 │ 4 │ │
│ │ Number of columns │ 7 │ │ string │ 3 │ │
│ └───────────────────┴────────┘ └─────────────┴───────┘ │
│ number │
│ ┏━━━━━━━━━━━━━━━━━━━━┳━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━┓ │
│ ┃ column ┃ NA ┃ NA % ┃ mean ┃ sd ┃ p0 ┃ p25 ┃ p50 ┃ p75 ┃ p100 ┃ hist ┃ │
│ ┡━━━━━━━━━━━━━━━━━━━━╇━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━┩ │
│ │ bill_length_mm │ 2 │ 0.5813953488372093 │ 43.92 │ 5.46 │ 32.1 │ 39.23 │ 44.45 │ 48.5 │ 59.6 │ ▃█▆█▃ │ │
│ │ bill_depth_mm │ 2 │ 0.5813953488372093 │ 17.15 │ 1.975 │ 13.1 │ 15.6 │ 17.3 │ 18.7 │ 21.5 │ ▄▅▆█▆▂ │ │
│ │ flipper_length_mm │ 2 │ 0.5813953488372093 │ 200.9 │ 14.06 │ 172 │ 190 │ 197 │ 213 │ 231 │ ▂██▄▆▃ │ │
│ │ body_mass_g │ 2 │ 0.5813953488372093 │ 4202 │ 802 │ 2700 │ 3550 │ 4050 │ 4750 │ 6300 │ ▂█▆▄▃▁ │ │
│ └────────────────────┴────┴────────────────────┴───────┴───────┴──────┴───────┴───────┴──────┴──────┴────────┘ │
│ string │
│ ┏━━━━━━━━━┳━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┓ │
│ ┃ column ┃ NA ┃ NA % ┃ shortest ┃ longest ┃ min ┃ max ┃ chars/row ┃ words/row ┃ total words┃ │
│ ┡━━━━━━━━━╇━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━┩ │
│ │ species │ 0 │ 0 │ Adelie │ Chinstrap │ Adelie │ Gentoo │ 6.59 │ 1.00 │ 344 │ │
│ │ island │ 0 │ 0 │ Dream │ Torgersen │ Biscoe │ Torgersen │ 6.09 │ 1.00 │ 344 │ │
│ │ sex │ 11 │ 3.19767442 │ Male │ Female │ Female │ Male │ 4.99 │ 0.97 │ 333 │ │
│ └─────────┴────┴────────────┴──────────┴───────────┴────────┴───────────┴────────────┴───────────┴────────────┘ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯The inline histograms are the standout feature. You get a read on each distribution's shape without opening a plotting library. For interactive exploration, skimpy is a good middle ground: far more informative than describe(), far lighter than a full HTML report.
# 5. Generating a Full Interactive Report with Profiling
**
When you need the *complete* picture — distributions, correlations, interactions, duplicate detection, and data-quality warnings — it is better to generate a full profile report. This is the one-liner that replaced an afternoon of exploratory plotting for a lot of analysts.
With the maintained package:
from data_profiling import ProfileReport # fg-data-profiling
profile = ProfileReport(df, title="Penguins Profiling Report", explorative=True)
profile.to_file("penguins_report.html")If you're on the legacy package, only the import line changes:
import ydata_profiling # legacy ydata-profilingThe output is a self-contained, interactive HTML file with sections for an overview (size, memory, duplicate rows), per-variable detail (descriptive stats plus histograms), correlations across several coefficients, a missing-values analysis, and automatic alerts flagging skew, high cardinality, constant columns, and the like.

A section of the generated profiling report
The one tradeoff is speed: a full report computes a lot, and it slows down on large datasets. Two arguments fix that. Use minimal=True to switch off the most expensive computations, and profile a sample rather than the whole frame when you just need a feel for the data:
profile = ProfileReport(df.sample(frac=0.5), minimal=True)There's also a .compare() method for putting two datasets side by side — invaluable for spotting drift between a training set and production data, or between two time periods.
# 6. Building a Real "Table 1" with tableone
Everything so far is for *you* — exploration aids. tableone** is for your readers. It produces the stratified baseline-characteristics table that opens nearly every clinical and quantitative research paper (hence "Table 1"), with the formatting and statistics that reviewers expect.
from tableone import TableOne
data = df.dropna(subset=["sex"])
columns = ["bill_length_mm", "bill_depth_mm",
"flipper_length_mm", "body_mass_g", "island", "sex"]
categorical = ["island", "sex"]
nonnormal = ["body_mass_g"] # summarize with median [IQR] instead of mean (SD)
table1 = TableOne(
data,
columns=columns,
categorical=categorical,
nonnormal=nonnormal,
groupby="species",
pval=True,
smd=True,
)
print(table1.tabulate(tablefmt="github"))The result is a properly formatted table: continuous variables as mean (SD), categoricals as n (%), anything you flagged as non-normal as median [Q1,Q3] — stratified across your grouping variable, with a missing-data column, p-values, and standardized mean differences (SMD) between groups:
| | | Missing | Overall | Adelie | Chinstrap | Gentoo | SMD (Adelie,Chinstrap) | SMD (Adelie,Gentoo) | SMD (Chinstrap,Gentoo) | P-Value |
|------------------------------|-----------|-----------|------------------------|------------------------|------------------------|------------------------|--------------------------|-----------------------|--------------------------|-----------|
| n | | | 333 | 146 | 68 | 119 | | | | |
| bill_length_mm, mean (SD) | | 0 | 44.0 (5.5) | 38.8 (2.7) | 48.8 (3.3) | 47.6 (3.1) | 3.315 | 3.023 | -0.393 | <0.001 |
| bill_depth_mm, mean (SD) | | 0 | 17.2 (2.0) | 18.3 (1.2) | 18.4 (1.1) | 15.0 (1.0) | 0.062 | -3.022 | -3.220 | <0.001 |
| flipper_length_mm, mean (SD) | | 0 | 201.0 (14.0) | 190.1 (6.5) | 195.8 (7.1) | 217.2 (6.6) | 0.837 | 4.140 | 3.119 | <0.001 |
| body_mass_g, median [Q1,Q3] | | 0 | 4050.0 [3550.0,4775.0] | 3700.0 [3362.5,4000.0] | 3700.0 [3487.5,3950.0] | 5050.0 [4700.0,5500.0] | 0.064 | 2.885 | 3.043 | <0.001 |
| island, n (%) | Biscoe | | 163 (48.9) | 44 (30.1) | 0 (0.0) | 119 (100.0) | 1.819 | 2.153 | nan | <0.001 |
| | Dream | | 123 (36.9) | 55 (37.7) | 68 (100.0) | 0 (0.0) | | | | |
| | Torgersen | | 47 (14.1) | 47 (32.2) | 0 (0.0) | 0 (0.0) | | | | |
| sex, n (%) | Female | | 165 (49.5) | 73 (50.0) | 34 (50.0) | 58 (48.7) | <0.001 | 0.025 | 0.025 | 0.976 |
| | Male | | 168 (50.5) | 73 (50.0) | 34 (50.0) | 61 (51.3) | | | | |The real payoff is export. A TableOne object goes straight to the format your manuscript needs — LaTeX (paste it into Overleaf), HTML, Markdown, or CSV:
table1.to_latex("table1.tex")
table1.to_html("table1.html")
table1.to_csv("table1.csv")One important thing that the package authors stress, and so will I: automated statistics are not a substitute for judgment. The choice of which test to run, whether a variable is truly normal, and how to handle missingness all warrant human review before anything goes to publication. tableone removes the tedium, not the responsibility.
# 7. Polishing It into a Publication-Quality Table with Great Tables
**
tableone formats research tables specifically. For *any other* summary — a business report, a slide, or a README — Great Tables** turns a plain DataFrame into a styled, presentation-ready table, the same way the gt package does in R. It takes a Pandas or Polars frame and renders to HTML or to an image.
Take the custom summary we built back in Step 3 and dress it up:
from great_tables import GT, md
numeric = df.select_dtypes("number")
stats = (numeric.agg(["mean", "median", "std", "min", "max"]).T
.rename_axis("measurement").reset_index())
stats["missing_pct"] = df[numeric.columns].isna().mean().mul(100).values
table = (
GT(stats, rowname_col="measurement")
.tab_header(title="Penguin Body Measurements",
subtitle="Descriptive statistics, Palmer Archipelago")
.fmt_number(columns=["mean", "median", "std", "min", "max"], decimals=1)
.fmt_percent(columns="missing_pct", decimals=1, scale_values=False)
.data_color(columns="std", palette="Blues")
.tab_source_note(md("Source: *palmerpenguins* dataset (Horst et al.)."))
)A few things are happening here. fmt_number and fmt_percent handle display formatting so you never hand-round again. data_color applies a color gradient to the std column, drawing the eye to the most variable measurements. tab_header and tab_source_note add the title and attribution that make a table look finished. There's plenty more — column spanners, conditional styling, even inline sparklines — but even this much produces something you'd happily put in front of stakeholders.
To use the result, render to an HTML string (works anywhere, no extra dependencies):
html = table.as_raw_html()
with open("summary_table.html", "w") as f:
f.write(html)
**
The styled Great Tables output
# Tying It Together: One Function, Every Time
The whole point of automation is repeatability. Wrap the pipeline so the next dataset is a single call:
from great_tables import GT, md
def descriptive_report(df, decimals=1):
numeric = df.select_dtypes("number")
stats = (numeric.agg(["count", "mean", "median", "std", "min", "max"]).T
.rename_axis("variable").reset_index())
stats["missing_%"] = df[numeric.columns].isna().mean().mul(100).values
return (
GT(stats, rowname_col="variable")
.tab_header(title="Descriptive Statistics",
subtitle=f"{len(df):,} rows x {df.shape[1]} columns")
.fmt_integer(columns="count")
.fmt_number(columns=["mean", "median", "std", "min", "max"], decimals=decimals)
.fmt_percent(columns="missing_%", decimals=1, scale_values=False)
.data_color(columns="std", palette="Blues")
.tab_source_note(md("Generated automatically with pandas + Great Tables."))
)
descriptive_report(df) # point it at any DataFrameThat's the difference between a snippet and a tool: you write it once and reuse it on every dataset that lands on your desk.
# Wrapping Up
Descriptive statistics don't have to be a chore you re-implement on every project. The ladder we climbed has a rung for every situation:
- Pandas describe() and .agg(): Zero dependencies, perfect for quick checks and custom summaries.
- skimpy: A richer console summary with histograms and missing-data percentages, in one call.
- fg-data-profiling: A complete interactive HTML report when you need the full exploratory data analysis (EDA) picture.
- tableone: The stratified "Table 1" with p-values and SMDs for research papers, with one-line export to LaTeX, HTML, and CSV.
- Great Tables: Polished, publication-quality styling for any summary you've produced.
Pick the lightest tool that answers your question. For a five-second sanity check, describe() wins. For a manuscript, tableone and Great Tables earn their keep. And once you wrap your favorite combination in a function, you stop *doing* descriptive statistics and start *running* them — which is exactly where you want to be so you can spend your time on the analysis that actually requires your brain.
Kanwal Mehreen** is a machine learning engineer and a technical writer with a profound passion for data science and the intersection of AI with medicine. She co-authored the ebook "Maximizing Productivity with ChatGPT". As a Google Generation Scholar 2022 for APAC, she champions diversity and academic excellence. She's also recognized as a Teradata Diversity in Tech Scholar, Mitacs Globalink Research Scholar, and Harvard WeCode Scholar. Kanwal is an ardent advocate for change, having founded FEMCodes to empower women in STEM fields.
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み