Chapter 13: Presenting data with Great Tables

Open In Colab Download Notebook

You run the analysis, produce the numbers, and paste df.head() into the report. The stakeholder replies: “Which column is which? Why are the numbers so long? Can you highlight the failing programs?” A plain DataFrame output served you during exploration. It doesn’t serve a reader in a report.

Great Tables (great_tables) bridges that gap: it wraps a DataFrame in a fluent API and produces publication-ready HTML tables: precise formatting, readable labels, conditional highlighting, and summary rows: with no CSS knowledge required. Chapters 8-11 built the data; this notebook builds the presentation.

Callout markers used throughout this notebook are explained on the book cover page.

from great_tables import GT, loc, md as gt_md, nanoplot_options, style
import numpy as np
import pandas as pd
import polars as pl

from ark.plot.gt_style import metrics_report, themed_gt
from ark.plot.tokens import PRIMARY, SUCCESS, SURFACE_MUTED

df = pd.read_csv("data/university_analytics.csv")
df["average_marks"] = (df["midterm_score"] + df["final_score"] + df["project_score"]) / 3
df.head(3)
student_id cohort program gender region guardian has_internet course_id course semester enrollment_date study_hours attendance_pct midterm_score final_score project_score final_grade passed average_marks
0 S0001 2023 Information Technology F South Father True C01 Python Programming Fall 2023 2023-09-04 10.8 88.6 49.9 58.2 61.6 C True 56.566667
1 S0001 2023 Information Technology F South Father True C02 Statistics Spring 2024 2024-01-15 16.1 71.5 48.7 63.7 65.8 C True 59.400000
2 S0001 2023 Information Technology F South Father True C03 Data Structures Fall 2023 2023-09-04 23.6 71.5 54.1 60.9 79.8 C True 64.933333

The Last Mile of a Data Story

You have run the analysis. You have the numbers: pass rates by school, score distributions by program, trend lines across semesters. Now your manager asks for a report : something to put in front of a stakeholder, not a developer. You open a notebook, call df.head(), and stare at a grey monospace grid with no hierarchy, no colour, no units, and no sense of which numbers matter.

df.head() serves you in a notebook. It doesn’t serve anyone else.

The gap between “I have the result” and “I can communicate the result” is called the last mile of a data story. It is where a lot of analysis work quietly disappears: correct findings, buried in formatting nobody wanted to read. Great Tables (posit-dev.github.io/great-tables) is the Python library that closes that gap. It wraps a pandas DataFrame in a fluent API : one that mirrors R’s {gt} package : and produces publication-ready HTML tables with column spanners, colour scales, embedded sparklines, and controlled footnotes.

How it compares to other table tools

Tool Output Strengths When to use instead
Great Tables (posit-dev.github.io/great-tables) HTML Full layout control, colour scales, sparklines, {gt}-compatible API Reports, notebooks, any HTML output
pandas Styler (pandas docs) HTML Built-in, no extra install, fast for simple highlighting Quick colouring when GT is overkill
tabulate (tabulate on PyPI) Text, Markdown, HTML, LaTeX Lightweight, great for terminal or Markdown output CLI output, .md files
rich (rich.readthedocs.io) Terminal Beautiful terminal tables, progress bars Terminal-only display
itables (mwouts.github.io/itables) Interactive HTML Sort, filter, search in notebook Exploratory analysis, large tables

Already in your environment

uv add great-tables          # for a standalone project

Official docs: posit-dev.github.io/great-tables/articles/intro

By the end of Chapter 9 you will be able to:

# Skill Covered in
1 Explain when a table is the right choice over a chart Sec. 1
2 Wrap a DataFrame with GT() and apply the project brand with themed_gt() Sec. 2
3 Format numbers, percentages, and missing values with fmt_* methods Sec. 3
4 Add readable column labels with cols_label and group related columns with tab_spanner Sec. 4
5 Target cells with loc and apply styling with tab_style Sec. 5
6 Add grand summary rows with grand_summary_rows Sec. 6
7 Build a model comparison table with metrics_report() Sec. 7

1. When Tables Beat Charts

A chart compresses a distribution into shape: it shows a trend, a cluster, or an outlier at a glance. A table preserves exact values so a reader can answer a precise question: which course has the highest midterm average? or by how many points does one program outperform another?

Use a table when: - Readers will look up a specific row or compare two exact values - The differences between groups are small and a chart would compress them into noise - A report or stakeholder document needs a citable number, not an impression

Use a chart when: - You want to show a trend, a distribution, or a relationship across many data points - The pattern matters more than the individual values

Key Concept: Tables serve lookup; charts serve pattern recognition

Neither replaces the other. A data storytelling section (Chapter 7) shows a trend with a chart. A summary report shows the underlying numbers in a table. The combination answers both the what happened and the by exactly how much.

2. Your First Styled Table

Every Great Tables workflow starts with GT(df): wrapping a pandas DataFrame in the Great Tables object. From there you chain methods to add structure and styling. On its own, GT(df) renders a minimal unstyled table. themed_gt() applies the project’s brand: column header background, font, border colours, and alternating row stripes: one call at the end of the chain.

The first example is a summary of mean scores by gender:

summary = (
    df.groupby("gender")
    .agg(
        n_students=("student_id", "count"),
        midterm=("midterm_score", "mean"),
        final=("final_score", "mean"),
        project=("project_score", "mean"),
        fail_rate=("passed", lambda x: (~x).mean()),
    )
    .reset_index()
    .round(2)
)
summary
gender n_students midterm final project fail_rate
0 F 1170 60.55 59.44 65.35 0.04
1 M 1128 60.51 59.29 64.65 0.05
2 Other 102 60.22 62.39 63.94 0.06

GT(df) alone already renders a table, but column names are raw and values have no formatting. Wrapping it in themed_gt() applies the brand while .tab_header() adds a title and subtitle:

table = (
    GT(summary)
    .tab_header(
        title=gt_md("**Mean Exam Scores by Gender**"),
        subtitle="Students with complete score records across all three components",
    )
    .tab_source_note("Source: University analytics dataset · 2,400 rows")
)
themed_gt(table, n_rows=len(summary))
Mean Exam Scores by Gender
Students with complete score records across all three components
gender n_students midterm final project fail_rate
F 1170 60.55 59.44 65.35 0.04
M 1128 60.51 59.29 64.65 0.05
Other 102 60.22 62.39 63.94 0.06
Source: University analytics dataset · 2,400 rows

Key Concept: The chain always ends with themed_gt()

themed_gt() applies brand-wide options (tab_options) and text styling. Call it last, after all structural methods (tab_header, cols_label, tab_spanner, etc.) so it can apply consistently across everything you have added.

Activity 1 - First Styled Table

Group df by program instead of gender, compute the same five aggregates, then wrap with GT and themed_gt. Add a title that identifies the program.
program_summary = df.groupby("program").agg(...).reset_index().round(2)
GT(program_summary).tab_header(title=gt_md("**...**"), subtitle="...").pipe(themed_gt, n_rows=len(program_summary))
# TODO: build program_summary and display with GT + themed_gt
...

3. Formatting Values and Labelling Columns

Key Concept: Format numbers to signal meaningful precision, not raw float noise

A pass rate of 0.87654 tells the reader nothing that 87.7% doesn’t: and the extra digits imply a precision the data doesn’t have. Great Tables formats numbers through chainable fmt_*() methods: fmt_number(decimals=1), fmt_percent(decimals=1), fmt_integer(). These are display-only transforms: the underlying DataFrame is unchanged.

Raw floats in a table communicate false precision: a pass rate of 0.87654 signals noise, not information. Great Tables fmt_* methods format each column’s values to the right precision for its type, and cols_label replaces machine-readable column names with reader-facing ones.

The four formatting methods used most in DS tables: - fmt_number(columns, decimals): round to decimals places - fmt_integer(columns): strip decimal point, add thousands separator - fmt_percent(columns, decimals): multiply by 100 and append % - fmt_missing(columns, missing_text): replace NaN with a readable label

Example: fmt_percent turns 0.913 into 91.3%

Without formatting, fail_rate=0.04 reads as a raw proportion. With fmt_percent(columns=‘fail_rate’, decimals=1), the same cell displays as 4.0%: the reader doesn’t need to mentally multiply by 100.

formatted = (
    GT(summary)
    .tab_header(
        title=gt_md("**Mean Exam Scores by Gender**"),
        subtitle="All figures rounded to one decimal place",
    )
    .cols_label(
        gender="Gender",
        n_students="Students",
        midterm="Midterm",
        final="Final",
        project="Project",
        fail_rate="Fail Rate",  # noqa: S106
    )
    .fmt_integer(columns="n_students")
    .fmt_number(columns=["midterm", "final", "project"], decimals=1)
    .fmt_percent(columns="fail_rate", decimals=1)  # noqa: S106
    .tab_source_note("Source: University analytics dataset · 2,400 rows")
)
themed_gt(formatted, n_rows=len(summary))
Mean Exam Scores by Gender
All figures rounded to one decimal place
Gender Students Midterm Final Project Fail Rate
F 1,170 60.5 59.4 65.3 4.0%
M 1,128 60.5 59.3 64.7 5.0%
Other 102 60.2 62.4 63.9 6.0%
Source: University analytics dataset · 2,400 rows

Pro Tip: fmt_missing catches the NaN before the reader sees it

Any column that can contain NaN: a score column with ~3% missing, an optional field: should have fmt_missing(columns=…, missing_text=“:”) added to the chain. A blank cell in a published table is ambiguous: did the student not sit the exam, or did the pipeline drop the value?

Activity 2 - Format the Program Table

Take the program_summary from Activity 1 and add cols_label, fmt_integer, fmt_number, and fmt_percent to match the formatted table above.
formatted_programs = (
    GT(program_summary)
    .cols_label(program="Program", n_students="Students", ...)
    .fmt_integer(columns="n_students")
    .fmt_number(columns=[...], decimals=1)
    .fmt_percent(columns="fail_rate", decimals=1)
)
themed_gt(formatted_programs, n_rows=len(program_summary))
# TODO: add cols_label and fmt_* to your program_summary table
...

4. Column Spanners

When a table has several columns that belong to a natural group, for example three score columns or multiple model metrics, a column spanner adds a shared header label above the group. This reduces cognitive load: the reader understands the table structure before reading the individual values.

tab_spanner(label, columns) draws the label above the specified columns. It doesn’t move or reorder columns; it only adds a visual grouping above them.

# Course performance table: one row per course
course_detail = (
    df.groupby("course")
    .agg(
        students=("student_id", "count"),
        midterm=("midterm_score", "mean"),
        final=("final_score", "mean"),
        project=("project_score", "mean"),
        pass_rate=("passed", "mean"),
    )
    .reset_index()
    .round(2)
)
course_detail
course students midterm final project pass_rate
0 Data Structures 400 59.60 57.53 64.09 0.94
1 Databases 400 63.77 62.49 67.82 0.98
2 Linear Algebra 400 57.59 56.53 61.61 0.93
3 Machine Learning 400 56.83 56.64 60.68 0.91
4 Python Programming 400 65.39 64.13 70.14 0.99
5 Statistics 400 60.02 59.66 65.44 0.96

Apply the same styling to the complete table:

with_spanner = (
    GT(course_detail)
    .tab_header(title=gt_md("**Performance by Course**"))
    .cols_label(
        course="Course",
        students="Students",
        midterm="Midterm",
        final="Final",
        project="Project",
        pass_rate="Pass Rate",  # noqa: S106
    )
    .tab_spanner(label="Score (0-100)", columns=["midterm", "final", "project"])
    .fmt_integer(columns="students")
    .fmt_number(columns=["midterm", "final", "project"], decimals=1)
    .fmt_percent(columns="pass_rate", decimals=1)  # noqa: S106
    .tab_source_note("Source: University analytics dataset · 2,400 rows")
)
themed_gt(with_spanner, n_rows=len(course_detail))
Performance by Course
Course Students Score (0-100) Pass Rate
Midterm Final Project
Data Structures 400 59.6 57.5 64.1 94.0%
Databases 400 63.8 62.5 67.8 98.0%
Linear Algebra 400 57.6 56.5 61.6 93.0%
Machine Learning 400 56.8 56.6 60.7 91.0%
Python Programming 400 65.4 64.1 70.1 99.0%
Statistics 400 60.0 59.7 65.4 96.0%
Source: University analytics dataset · 2,400 rows
Activity 3 - Add a Spanner

Take the formatted_programs table from Activity 2 and add a tab_spanner labelled “Scores (0-100)” over the three score columns.
GT(program_summary)
    ...
    .tab_spanner(label="Scores (0-100)", columns=["midterm", "final", "project"])
    ...
# TODO: add a tab_spanner to the program table
...

5. Conditional Styling

Conditional styling directs the reader’s eye to the cells that matter: the highest pass rate, the lowest score, an outlier. tab_style applies a visual property and loc specifies exactly where it applies. style is the what, loc is the where.

The most common locations: - loc.body(columns, rows): specific cells in the data area - loc.column_labels(): the column header row - loc.title() / loc.subtitle(): the table header text

rows inside loc.body() accepts an integer index, a list of indices, or a lambda that receives the DataFrame and returns a boolean Series.

highlighted = (
    GT(course_detail)
    .tab_header(title=gt_md("**Course Performance: Best and Worst Pass Rate**"))
    .cols_label(
        course="Course",
        students="Students",
        midterm="Midterm",
        final="Final",
        project="Project",
        pass_rate="Pass Rate",  # noqa: S106
    )
    .tab_spanner(label="Score (0-100)", columns=["midterm", "final", "project"])
    .fmt_integer(columns="students")
    .fmt_number(columns=["midterm", "final", "project"], decimals=1)
    .fmt_percent(columns="pass_rate", decimals=1)  # noqa: S106
    .tab_style(
        style=style.fill(color=SUCCESS),
        locations=loc.body(
            columns="pass_rate",
            rows=lambda df_gt: df_gt["pass_rate"] == df_gt["pass_rate"].max(),
        ),
    )
    .tab_style(
        style=style.fill(color="#FEF2F2"),
        locations=loc.body(
            columns="pass_rate",
            rows=lambda df_gt: df_gt["pass_rate"] == df_gt["pass_rate"].min(),
        ),
    )
)
themed_gt(highlighted, n_rows=len(course_detail))
Course Performance: Best and Worst Pass Rate
Course Students Score (0-100) Pass Rate
Midterm Final Project
Data Structures 400 59.6 57.5 64.1 94.0%
Databases 400 63.8 62.5 67.8 98.0%
Linear Algebra 400 57.6 56.5 61.6 93.0%
Machine Learning 400 56.8 56.6 60.7 91.0%
Python Programming 400 65.4 64.1 70.1 99.0%
Statistics 400 60.0 59.7 65.4 96.0%

Key Concept: loc is a targeting system, not a filter

loc.body(rows=lambda df: df[‘pass_rate’] == df[‘pass_rate’].max()) doesn’t subset the table: it identifies which rows receive the styling. The underlying data is unchanged. You can chain multiple tab_style calls; later ones add to earlier ones without overwriting.

Common Mistake: Passing a boolean mask directly to rows

loc.body(rows=course_detail[‘pass_rate’] == course_detail[‘pass_rate’].max()) fails because rows inside loc.body() needs a callable that receives the rendered DataFrame, not the original one. Always use a lambda: rows=lambda df: df[‘pass_rate’] == df[‘pass_rate’].max().

Activity 4 - Highlight the Best Midterm Score

Take the highlighted table and add a third tab_style call that highlights the midterm cell with the highest value in a light blue (#EAF3FA). Use a lambda for the row selection.
.tab_style(
    style=style.fill(color="#EAF3FA"),
    locations=loc.body(
        columns="midterm",
        rows=lambda df: df["midterm"] == df["midterm"].max(),
    ),
)
# TODO: add a third tab_style call for the highest midterm value
...

6. Summary Rows

A summary row aggregates the entire table into one footer row: a grand mean, a column total, or a count. The reader no longer needs to mentally compute the aggregate, and the table and its summary stay in the same visual unit.

grand_summary_rows(fns) adds these rows. fns is a dict mapping a display label to an aggregation function. In version 0.20, it aggregates all numeric columns in the table, so the DataFrame passed to GT should contain only the columns you want summarised:

from great_tables import vals as gt_vals  # noqa: F401

# Use only the score + pass_rate columns so the summary row is meaningful
course_scores = course_detail.drop(columns=["students"])

with_summary = (
    GT(course_scores)
    .tab_header(title=gt_md("**Course Summary with Grand Mean**"))
    .cols_label(
        course="Course",
        midterm="Midterm",
        final="Final",
        project="Project",
        pass_rate="Pass Rate",  # noqa: S106
    )
    .tab_spanner(label="Score (0-100)", columns=["midterm", "final", "project"])
    .fmt_number(columns=["midterm", "final", "project"], decimals=1)
    .fmt_percent(columns="pass_rate", decimals=1)  # noqa: S106
    .grand_summary_rows(
        fns={"Mean": lambda x: x.mean(numeric_only=True)},
    )
)
themed_gt(with_summary, n_rows=len(course_scores))
Course Summary with Grand Mean
Course Score (0-100) Pass Rate
Midterm Final Project
  Data Structures 59.6 57.5 64.1 94.0%
  Databases 63.8 62.5 67.8 98.0%
  Linear Algebra 57.6 56.5 61.6 93.0%
  Machine Learning 56.8 56.6 60.7 91.0%
  Python Programming 65.4 64.1 70.1 99.0%
  Statistics 60.0 59.7 65.4 96.0%
Mean --- 60.53333333333333 59.49666666666667 64.96333333333332 0.9516666666666667

Pro Tip: Shape the DataFrame before passing it to GT

grand_summary_rows aggregates every numeric column in the table. If a count column like students would produce a meaningless mean, drop it before calling GT(): df.drop(columns=[“students”]). If the table still includes a string column like the row label, pass numeric_only=True to the aggregation: lambda x: x.mean(numeric_only=True).

Activity 5 - Add a Min and Max Row

Extend with_summary to show three summary rows: Min, Max, and Mean across all score columns. Pass a dict with three keys to fns.
fns={"Min": lambda x: x.min(numeric_only=True), "Max": lambda x: x.max(numeric_only=True), "Mean": lambda x: x.mean(numeric_only=True)}
# TODO: add Min/Max/Mean grand summary rows
...

7. Inline Sparklines with fmt_nanoplot

A table that shows a pass rate of 72.4% answers “how many passed?” It doesn’t show whether that rate improved over five semesters or collapsed. A sparkline: a mini line chart inside a single cell: answers both questions at once without opening a separate figure.

fmt_nanoplot transforms a column of Python lists into inline micro-charts. The pattern is two steps:

  1. Add a list-valued column: each cell holds a sequence of numbers (one per period, per category, or per experiment)
  2. Chain .fmt_nanoplot(columns='col_name', plot_type='line') into your GT chain

plot_type accepts 'line' (trends over time) or 'bar' (magnitude across categories). nanoplot_options controls colours, point visibility, and hover labels.

rng: np.random.Generator = np.random.default_rng(42)
N_SEMESTERS: int = 5

# Simulate a rising score trajectory with noise for each course
trajectories: list[list[float]] = []
for _, row in course_detail.iterrows():
    start: float = row["midterm"] - rng.uniform(4, 8)
    end: float = row["midterm"] + rng.uniform(2, 10)
    noise: np.ndarray = rng.normal(0, 1.5, N_SEMESTERS)
    traj: list[float] = [round(float(v), 1) for v in np.linspace(start, end, N_SEMESTERS) + noise]
    trajectories.append(traj)

course_trend: pd.DataFrame = course_detail[["course", "midterm", "pass_rate"]].copy()
course_trend["score_trend"] = trajectories
course_trend.head(3)

# fmt_nanoplot requires a Polars DataFrame for list columns
# (pd.isna on Python lists in pandas 3.x returns an array, not a scalar)
course_trend_pl: pl.DataFrame = pl.from_pandas(course_trend)
course_trend_pl.head(3)
shape: (3, 4)
course midterm pass_rate score_trend
str f64 f64 list[f64]
"Data Structures" 59.6 0.94 [53.6, 57.1, … 65.3]
"Databases" 63.77 0.98 [55.3, 60.5, … 68.5]
"Linear Algebra" 57.59 0.93 [52.4, 52.8, … 61.1]

Apply the same styling to the complete table:

spark_table: GT = (
    GT(course_trend_pl)
    .tab_header(
        title=gt_md("**Midterm Score Trend by Course**"),
        subtitle="Sparkline shows the estimated 5-semester score trajectory",
    )
    .cols_label(
        course="Course",
        midterm="Mean Midterm",
        pass_rate="Pass Rate",  # noqa: S106
        score_trend="Score Trend",
    )
    .fmt_number(columns="midterm", decimals=1)
    .fmt_percent(columns="pass_rate", decimals=1)  # noqa: S106
    .fmt_nanoplot(
        columns="score_trend",
        plot_type="line",
        options=nanoplot_options(
            data_point_fill_color=PRIMARY,
            data_line_stroke_color=PRIMARY,
            show_data_area=False,
            interactive_data_values=True,
        ),
    )
    .tab_source_note("Trajectories are simulated; real data would use per-semester aggregations")
)
themed_gt(spark_table, n_rows=len(course_trend))
Midterm Score Trend by Course
Sparkline shows the estimated 5-semester score trajectory
Course Mean Midterm Pass Rate Score Trend
Data Structures 59.6 94.0%
65.353.653.657.155.960.065.3
Databases 63.8 98.0%
68.555.355.360.562.964.468.5
Linear Algebra 57.6 93.0%
61.152.452.452.857.958.961.1
Machine Learning 56.8 91.0%
67.151.251.254.658.563.667.1
Python Programming 65.4 99.0%
73.863.264.063.266.068.673.8
Statistics 60.0 96.0%
63.954.054.056.060.162.263.9
Trajectories are simulated; real data would use per-semester aggregations

Key Concept: fmt_nanoplot requires a list-valued column

fmt_nanoplot doesn’t aggregate data for you. Pass a Polars DataFrame to GT() when your column holds Python lists: Polars types them as List(Float64), which GT renders without ambiguity. Pandas 3.x pd.isna(list) returns an array that causes a ValueError inside the nanoplot renderer. If your data is in wide format (one column per semester), convert it first with .values.tolist() row-wise or with a list comprehension, then assign the result as a new column before calling GT().

Activity 7 - Switch to a Bar Sparkline

Copy spark_table and change two things: set plot_type=‘bar’ and set show_data_area=True inside nanoplot_options. Compare the two chart types side by side. Which makes it easier to spot the course with the largest single-semester score peak? Which better communicates overall trend direction?

Hint: Bar nanoplots colour negative bars differently by default. Try setting show_data_points=False to clean up the bar variant.

# TODO: build a bar nanoplot version of spark_table
...

8. Model Comparison with metrics_report()

The ark.plot.gt_style module ships metrics_report(): a one-call wrapper that produces a publication-ready model comparison table. It handles formatting, brand styling, and conditional highlighting in a single call.

metrics_report(df, metrics, minimize_cols, maximize_cols) highlights the best value in each metric column: green for minimize metrics (lower is better: MAE, RMSE), green for maximize metrics (higher is better: R², accuracy). The caller decides which direction is better for each metric; the function doesn’t guess.

comparison = pd.DataFrame(
    {
        "Model": ["Linear Regression", "Ridge (α=0.1)", "Ridge (α=1.0)", "Random Forest"],
        "MAE": [8.21, 8.09, 7.98, 7.43],
        "RMSE": [10.42, 10.31, 10.19, 9.61],
        "R2": [0.781, 0.784, 0.788, 0.810],
    }
)

metrics_report(
    comparison,
    metrics=["MAE", "RMSE", "R2"],
    minimize_cols=["MAE", "RMSE"],
    maximize_cols=["R2"],
    title="Grade Prediction: Model Comparison",
    subtitle="Predicting average_marks from study_hours, attendance_pct, and program",
    source_note="university_analytics.csv · 5-fold CV · held-out 20% test set",
)
Grade Prediction: Model Comparison
Predicting average_marks from study_hours, attendance_pct, and program
Model MAE RMSE R2
Linear Regression 8.210 10.420 0.781
Ridge (α=0.1) 8.090 10.310 0.784
Ridge (α=1.0) 7.980 10.190 0.788
Random Forest 7.430 9.610 0.810
university_analytics.csv · 5-fold CV · held-out 20% test set

Key Concept: metrics_report highlights by direction, not by rank

minimize_cols highlights the row with the lowest value: better for error metrics. maximize_cols highlights the row with the highest value: better for performance metrics. A column can appear in at most one list. If a column appears in neither, it’s formatted but not highlighted.

Activity 6 - Add a Gradient Boosting Row

Add a fifth row to comparison: “Gradient Boosting” with MAE=6.91, RMSE=8.84, R2=0.843: and re-run metrics_report(). Confirm the highlighted row updates automatically.
comparison = pd.concat([comparison, pd.DataFrame([{"Model": "Gradient Boosting", "MAE": 6.91, "RMSE": 8.84, "R2": 0.843}])], ignore_index=True)
metrics_report(comparison, metrics=[...], minimize_cols=[...], maximize_cols=[...], ...)
# TODO: add Gradient Boosting row and re-run metrics_report
...

Capstone: Course Performance Report

Combine every technique from this notebook into one complete report table. The report should give a department head a single table they can paste into a slide deck.

Capstone Exercise - Course Performance Report

  1. Build a report DataFrame grouped by course with columns: students, midterm mean, final mean, project mean, pass rate, and average_marks mean
  2. Wrap with GT. Add a descriptive title and source note
  3. Apply cols_label and the appropriate fmt_* for each column
  4. Add a tab_spanner over the three score columns
  5. Highlight the course with the highest pass rate (green) and lowest pass rate (light red)
  6. Add a grand Mean summary row across all numeric columns
  7. Call themed_gt() last
# Build report DataFrame first, then chain all GT methods in one expression
# TODO: build the complete course performance report
...

Further reading

Resource Why it matters
Great Tables: fmt_nanoplot reference Parameters and options for inline sparklines; includes line, bar, and boxplot types
Great Tables documentation Complete API reference with rendered examples for every method
Great Tables: loc reference Full list of location helpers: loc.body, loc.column_labels, loc.spanner_labels, loc.grand_summary
Great Tables blog: Python tables Worked examples including financial reports and ML comparison tables
Knaflic, C.N. (2015). Storytelling with Data. Wiley. Chapter 2 covers when tables serve communication better than charts
pandas GroupBy.agg reference Named aggregations (col=(src, func)) used throughout this notebook

Summary

GT method What it does
GT(df) Wrap a DataFrame and begin the method chain
themed_gt(table, n_rows=n) Apply project brand: header colors, font, striped rows. Call last.
tab_header(title, subtitle) Add a title row above the column headers
tab_source_note(text) Add an attribution line below the table
cols_label(**kwargs) Replace column names with reader-facing labels
fmt_number(columns, decimals) Round floats to decimals places
fmt_integer(columns) Remove decimal, add thousands separator
fmt_percent(columns, decimals) Multiply by 100 and append %
fmt_missing(columns, missing_text) Replace NaN with a readable placeholder
tab_spanner(label, columns) Group related columns under a shared header label
tab_style(style, locations) Apply a visual property (fill, text) to a location (loc.body, loc.column_labels)
loc.body(columns, rows) Target specific cells; rows takes an index or a lambda
grand_summary_rows(fns) Add one summary row per key in fns; aggregates all numeric columns
metrics_report(df, metrics, ...) One-call ML comparison table with directional highlighting

Next: Chapter 3: Dev Tools covers the professional toolchain: uv, ruff, type annotations, git, pytest, and pre-commit.