Multi-level hierarchies#
Real-world classification is rarely flat. A global equity fund
typically splits the world into regions (Americas, Europe, Asia),
then each region into countries, each country into sectors or
industries. pybrinson handles arbitrary-depth hierarchies with
a single parents= argument — the same call produces per-leaf
attribution and per-parent roll-up at every level.
One-level hierarchies via Segment.parent#
For a single level of nesting, annotate each leaf with its immediate parent:
from pybrinson import Segment, bhb
leaves = [
Segment("UK", 0.20, 0.25, 0.10, 0.08, parent="Europe"),
Segment("Germany", 0.25, 0.20, 0.05, 0.06, parent="Europe"),
Segment("US", 0.30, 0.35, 0.12, 0.10, parent="Americas"),
Segment("Brazil", 0.25, 0.20, -0.04, -0.02, parent="Americas"),
]
result = bhb(leaves)
result.by_segment now contains both the four leaves and two
parent rows (Europe, Americas), each carrying the sum of
its descendants' allocation, selection, and interaction. The
identity \(A + S + I == R_p - R_b\) still closes on the totals.
Multi-level hierarchies via parents={...}#
For chains deeper than one level, pass a
{parent_label: grandparent_label_or_None} mapping. None
marks the root.
result.by_segment now walks three levels:
flowchart TD
Equity["Equity<br/>level 0 — root"]
Americas["Americas<br/>level 1"]
Europe["Europe<br/>level 1"]
US["US<br/>leaf"]
Brazil["Brazil<br/>leaf"]
UK["UK<br/>leaf"]
Germany["Germany<br/>leaf"]
Equity --> Americas
Equity --> Europe
Americas --> US
Americas --> Brazil
Europe --> UK
Europe --> Germany
Each non-leaf node's (A, S, I) is the additive sum of its children's (A, S, I):
flowchart BT
L1["UK: A, S, I"]
L2["Germany: A, S, I"]
L3["US: A, S, I"]
L4["Brazil: A, S, I"]
P1["Europe = UK + Germany"]
P2["Americas = US + Brazil"]
R["Equity = Europe + Americas<br/>= Rp - Rb"]
L1 --> P1
L2 --> P1
L3 --> P2
L4 --> P2
P1 --> R
P2 --> R
Each non-leaf row's allocation / selection / interaction equals
the sum of its children. The root equals the period total.
Rendering via period_to_table(result) indents leaves under their
parents.
What pybrinson enforces#
- Every parent mentioned in
Segment.parentor in theparentsmapping must eventually reach aNoneroot. Missing ancestors raiseAttributionError. No silent orphan nodes. - No cycles. A parent cannot be its own ancestor. Cycles raise.
- Depth limit 32. A well-formed classification is never this
deep; hitting the limit is almost always a
parents-dict bug, so pybrinson raises early. - Leaves are the only thing that needs weights that sum to 1. Parents are derived; their aggregated weights and returns are computed, not validated against user input.
Why not just do this in post-processing?#
Because the Brinson identity does not survive naive post-grouping. The interaction term \(I_i = (w_p - w_b)(R_p - R_b)\) is quadratic in the active bet; grouping segments by parent label and recomputing gives a different (and wrong) number. The roll-up must be additive, segment by segment. pybrinson does this once, correctly, inside the attribution itself.
Combining with linking#
Multi-level hierarchical single-period attributions link exactly
like flat ones. Pass each period's PeriodAttribution — already
hierarchical — into any link_* function. Each parent rolls up
across time just as leaves do.
from pybrinson import bhb, link_carino
q1 = bhb(leaves_q1, parents=parents, period="Q1")
q2 = bhb(leaves_q2, parents=parents, period="Q2")
linked = link_carino([q1, q2])
# linked.by_segment preserves the parent / level / is_leaf columns
# (linked via the underlying tuple of PeriodAttribution results).
Worked example#
from pybrinson import Segment, bhb
leaves = [
Segment("UK Large", 0.20, 0.25, 0.10, 0.08, parent="UK"),
Segment("UK Small", 0.20, 0.15, 0.12, 0.06, parent="UK"),
Segment("US Large", 0.30, 0.30, 0.05, 0.07, parent="US"),
Segment("US Small", 0.30, 0.30, 0.04, 0.05, parent="US"),
]
result = bhb(leaves)
print(result)
BHB attribution — period (unlabelled)
R_p = 6.9000% R_b = 6.4500% excess = 0.4500%
Segment Allocation Selection Interaction Total
------------- ---------- --------- ----------- -------
UK 0.2600% 1.1000% 0.1100% 1.4700%
UK Large -0.4000% 0.4000% 0.0400% 0.0400%
UK Small 0.6600% 0.7000% 0.0700% 1.4300%
US -0.6400% -0.3000% 0.0200% -0.9200%
US Large 0.0000% -0.6000% 0.0000% -0.6000%
US Small -0.6400% 0.3000% 0.0200% -0.3200%
Total -0.3800% 0.8000% 0.1300% 0.5500%
(Illustrative numbers; actual numerics depend on your inputs.)