How do I turn each group's values into proportions in pandas?
Short answer
Divide each value by its group total obtained with transform, so every group's proportions sum to one. Using apply to return a whole frame works too, but transform is faster and clearer.
The short version
Divide each value by its own group's total, using groupby(...).transform("sum") to broadcast that total back onto every row. Each group's proportions then sum to one. This is the vectorised way, and it is both faster and clearer than an apply that rebuilds each group.
The transform approach
import pandas as pd
df = pd.DataFrame({
"kind": ["x", "x", "y"],
"value": [1, 2, 3],
})
df["share"] = df["value"] / df.groupby("kind")["value"].transform("sum")
print(df["share"].round(2).tolist()) # [0.33, 0.67, 1.0]
The two x rows share their group's total of 3, giving a third and two thirds. The single y row is all of its group, giving 1.0. Each group's shares sum to one, as they must.
Why not apply
You can do it by handing each group to a function, but it is slower and the output shape is easy to get wrong.
df.groupby("kind", group_keys=False).apply(
lambda g: g.assign(share=g["value"] / g["value"].sum()),
include_groups=False,
)
This works, and it is sometimes necessary when the per-group logic is complex. But for a straightforward normalisation, transform does it in one vectorised pass with no lambda, and it aligns the result to the original rows automatically.
The group_keys argument
If you do use apply and it returns a frame, group_keys=False stops pandas adding the group key as an extra index level, which is usually what you want when the group column is already in the data. Forgetting it produces a confusing MultiIndex that then needs resetting.
The zero-total trap
If a group's values sum to zero, the division is undefined and produces inf or NaN rather than an error, which then flows silently into everything downstream.
totals = df.groupby("kind")["value"].transform("sum")
df["share"] = df["value"] / totals.replace(0, pd.NA)
Replacing a zero denominator with a missing value makes the undefined result visible as NaN instead of an infinity lurking in a chart.
Negative values
Proportions of a group containing both positive and negative values are usually meaningless, because the total can be near zero even when the individual values are large, producing enormous or wildly signed shares. If your data can be negative, ask whether "share of total" is really the question, or whether you want a share of the absolute values, or something else entirely.
Percentages
Multiply by 100 at the point of display, and keep the proportion in the data.
df["percent"] = (df["share"] * 100).round(1)
Rounded percentages within a group sometimes sum to 99.9 or 100.1, which is honest rounding rather than a bug, and is better acknowledged than forced.