Skip to content

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 = bhb(leaves, parents={
    "Europe":   "Equity",
    "Americas": "Equity",
    "Equity":   None,          # 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.parent or in the parents mapping must eventually reach a None root. Missing ancestors raise AttributionError. 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.)