Chris Parmer — home

Population pyramid

California age × sex, semicolon + UTF-8 BOM CSV

Example from the compendium of canonical charts

Population pyramid — California age × sex, semicolon + UTF-8 BOM CSV

Python Code

"""Population pyramid — California age × sex, semicolon + UTF-8 BOM CSV."""


import io
import pandas as pd
import plotly.graph_objects as go
import requests, warnings

URL = "https://raw.githubusercontent.com/plotly/datasets/master/state-population-projections-by-race-sex-age-groups.csv"


def generate():
    print("fetching CA population pyramid data (;-delimited, UTF-8 BOM) …")
    warnings.filterwarnings("ignore")
    requests.packages.urllib3.disable_warnings()
    r = requests.get(URL, verify=False, timeout=30)
    r.raise_for_status()
    text = r.content.decode("utf-8-sig")   # strip BOM
    df = pd.read_csv(io.StringIO(text), sep=None, engine="python")
    df.columns = [c.strip() for c in df.columns]
    print(f"  cols: {list(df.columns)}, rows: {len(df)}")

    # Filter to California and pick most recent year
    state_col = next((c for c in df.columns if "state" in c.lower()), None)
    year_col  = next((c for c in df.columns if c.lower() == "year"), None)
    sex_col   = next((c for c in df.columns if c.lower() == "sex"), None)

    # Wide-format dataset: age group columns start with "Age "
    # Only use the detailed single-age-band columns (before the 'Total' column),
    # not the broad summary bands like 'Age 0 to 17', 'Age 65 Plus'.
    col_list = list(df.columns)
    total_idx = next((i for i, c in enumerate(col_list) if c.lower() == "total"), len(col_list))
    age_cols = [c for c in col_list[:total_idx] if c.lower().startswith("age ")]
    print(f"  state={state_col}, year={year_col}, sex={sex_col}, age_cols={len(age_cols)}")

    if not all([sex_col, age_cols]):
        raise ValueError(f"Could not identify required columns. Available: {list(df.columns)}")

    # Filter California
    if state_col:
        ca_vals = ["California", "CA", "6"]
        df_ca = df[df[state_col].astype(str).isin(ca_vals)]
        if len(df_ca) == 0:
            df_ca = df
    else:
        df_ca = df

    # Most recent real year (not projection), or just max
    if year_col:
        est_col = next((c for c in df_ca.columns if "estimate" in c.lower()), None)
        if est_col:
            df_est = df_ca[df_ca[est_col].astype(str).str.lower().str.contains("estimate", na=False)]
            df_use = df_est if len(df_est) > 0 else df_ca
        else:
            df_use = df_ca
        latest = df_use[year_col].max()
        df_ca = df_use[df_use[year_col] == latest]
        print(f"  using year: {latest}")

    # Numeric conversion of age columns
    for c in age_cols:
        df_ca[c] = pd.to_numeric(df_ca[c], errors="coerce").fillna(0)

    # Melt to long format: Sex, Age, Population
    id_vars = [sex_col]
    df_long = df_ca.melt(id_vars=id_vars, value_vars=age_cols, var_name="Age", value_name="Population")
    agg = df_long.groupby(["Age", sex_col])["Population"].sum().reset_index()
    print(f"  sex values: {agg[sex_col].unique()}")

    # Identify male/female values
    male_val   = next((v for v in agg[sex_col].unique() if str(v).lower() in ("male","m","1")), agg[sex_col].unique()[0])
    female_val = next((v for v in agg[sex_col].unique() if str(v).lower() in ("female","f","2")), agg[sex_col].unique()[-1])

    male_df   = agg[agg[sex_col] == male_val].set_index("Age")["Population"]
    female_df = agg[agg[sex_col] == female_val].set_index("Age")["Population"]

    # Sort by age group order (preserve original column order)
    ages = [c for c in age_cols if c in male_df.index and c in female_df.index]
    # Shorten labels: "Age 0 to 2" → "0-2"
    age_labels = [a.replace("Age ", "").replace(" to ", "-").replace(" ", "") for a in ages]

    m_vals = [-male_df.get(a, 0) / 1000 for a in ages]    # negated for left side, in thousands
    f_vals = [ female_df.get(a, 0) / 1000 for a in ages]

    fig = go.Figure()
    fig.add_trace(go.Bar(
        x=m_vals, y=age_labels,
        orientation="h", name="Male",
        marker=dict(color=VIOLET, opacity=0.85),
        hovertemplate="Male %{y}: %{customdata:,.0f}<extra></extra>",
        customdata=[-v * 1000 for v in m_vals],
    ))
    fig.add_trace(go.Bar(
        x=f_vals, y=age_labels,
        orientation="h", name="Female",
        marker=dict(color=PINK, opacity=0.85),
        hovertemplate="Female %{y}: %{customdata:,.0f}<extra></extra>",
        customdata=[v * 1000 for v in f_vals],
    ))

    fig.update_layout(
        title=dict(text="California Population Pyramid", x=0.5),
        barmode="overlay",
        xaxis=dict(
            title="Population (thousands)",
            tickformat=".0f",
            tickvals=[-max(abs(v) for v in m_vals)*0.8, -max(abs(v) for v in m_vals)*0.4,
                      0, max(f_vals)*0.4, max(f_vals)*0.8],
            ticktext=[f"{abs(v):.0f}" for v in [-max(abs(v) for v in m_vals)*0.8,
                      -max(abs(v) for v in m_vals)*0.4, 0, max(f_vals)*0.4, max(f_vals)*0.8]],
        ),
        yaxis=dict(title="Age Group"),
        legend=dict(orientation="h", y=1.08),
        bargap=0.05,
        margin=dict(t=60, b=50, l=100, r=40),
        height=520,
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly