B2B Revenue Forecasting (b2b_revenue_forecasting)
An open-source Python framework designed mathematically for Enterprise RevOps and Data Strategy teams.
Unlike traditional bottom-up time-series libraries (which are strictly built for B2C retail/inventory forecasting and rely on mathematical averages), this package is explicitly architected to handle the realities of B2B enterprise sales: Hierarchical Quotas, Managerial Cascading, Pipeline Health Analysis, and "Sandbagging" Biases.
๐ Features
| Module | Purpose |
|---|---|
SalesHierarchy |
Build flexible org charts as DAGs from flat CRM data โ supports 3-level startups to 10-level enterprises |
QuotaCascader |
Distribute macro-targets top-down using rolling N-quarter capacity models with configurable managerial hedges |
MetricSpec |
Declare which historical metrics (NetNewACV, CloudSeats, DC seats, LTM expansion, โฆ) drive cascading, in what direction (proportional or inverse), and at what weight โ with auto-suggested weights from correlation analysis |
CommitReconciler |
Detect sandbagging and "happy ears" bias via historical Bias Quotients, then auto-correct forecasts |
PipelineAdjuster |
Diagnose pipeline health with per-region thresholds and redistribute IC quotas using zero-sum logic |
What's New in v0.20.0 โ protection-aware pin rescaling (issue #39)
The reported symptom โ hedged-layer manager pins not propagating the cross-level buffer โ does not reproduce: apply_pins scales descendant base values and re-derives cascaded from each row's own hedge ratio, so a basis='cascaded' team pin under HedgeByDepth already rolls reps up to pinned ร cross-level hedge (now pinned by test). The investigation did surface three real defects in the subtree rescale, fixed here: a later manager pin used to trample an earlier pin on one of its descendants (pin order mattered โ almost certainly the wrong rollup actually observed); freeze_nodes inside a pinned or absorbing subtree were scaled despite the "never modified" contract; and exclude didn't protect descendants of a manager pin.
The rescale is now protection-aware: descendants pinned by another pin, frozen, or excluded keep their values while free siblings stretch to fill โ proportional to base, exactly the old rescale when nothing is protected. Absorption is weighted by free capacity, so absorbers never push deltas onto protected rows. If protected values alone exceed a pin, free rows floor at $0 and the feasibility report's new subtree_shortfall column says so, with a warning.
What's New in v0.19.2 โ carrying metric values into quotas_long_df (issue #16)
No new code โ new clarity. The capability #16 requested (carry_metric_cols) has existed since v0.8.0 under a name that hid it: metadata_cols carries ANY hierarchy column onto leaf rows, metric values included. List knowledge_workers in metadata_cols and every leaf row of quotas_long_df carries its value โ even while that same column drives the cascade via an explicit MetricSpec (carried columns are excluded from auto ingestion only; cascade numbers are identical either way, now pinned by test). No re-join against the source frame needed.
The related footgun is key identity: hierarchy leaves are identified by the deepest taxonomy column (plus group_keys present in hierarchy_df); cascade rows are identified by group_keys + sub-target columns. Columns that live only in the target frame (a sales type, a fiscal quarter) are not valid join keys for leaf-grain data โ joining on them KeyErrors. Documented in cascade_many.
What's New in v0.19.1 โ metric-grain guardrails (issue #36)
Metric columns must be at leaf grain โ an account- or region-level value repeated onto every leaf row double-counts under sum rollups and collapses sibling shares to equal splits, silently. The cascader now detects the signature (values identical across โฅ90% of leaf-sibling groups) and warns, naming the column, for both cascade metrics and gates. Booleans and all-zero cases are exempt, it's once-per-metric, and it's warning-only. Auto-deduping via a grain/dedup_key was deliberately not added โ the correct collapse (e.g. MAX per account โ SUM per rep) is source-data semantics that belongs in your feed query; see the new "Metric Grain" section under Key Concepts.
What's New in v0.19.0 โ mixed weight strategies per combination (issue #35)
cascade_many's metrics= now accepts a callable evaluated per combination โ fix some groups' slates verbatim while others use suggested weights, in one call:
DC_ONLY = [MetricSpec('dc_seats', direction='proportional', weight=1.0)]
quotas, weights = cascade_many(
hierarchy_df, target_df, group_keys=['st1_sales_type', 'regional'], ...,
metrics=lambda g: DC_ONLY if g['st1_sales_type'] == 'Migration' else None,
suggest_config=dict(target_column=..., candidate_metrics=[...]),
weights_mode='per_group',
) # Migration: guaranteed pure DC-seat share ยท everything else: suggested
None without a suggest_config falls to the legacy path; bad policies flow through on_error; weights_long shows what each combination actually used; equivalence with the two-call workaround is pinned by test. (For single cascades nothing changed โ fixed specs were always honored verbatim; see v0.17.0.)
What's New in v0.18.1 โ the pin absorption policy, stated and pinned (issue #37)
Docs release. When you pin children (new_ic_overrides), the remainder flows to the non-pinned siblings proportional to their baseline (un-pinned) cascade โ that's not a mode to request, it's an algebraic identity of renormalized shares, now stated in the docstring and locked by a regression test (the DACH scenario: $50M with two teams pinned on base totals; non-pinned siblings match baseline-proportional to the penny, base conserves at every depth, hedged derives per node). The rest of #37's proposed API already existed: pins= โ new_ic_overrides (any level, v0.13.0), pin_basis โ override_basis (default 'base'), cross-combo totals โ apply_pins (v0.16.0).
What's New in v0.18.0 โ level-by-level cascading (issue #30)
When each level needs different behavior โ split regions by knowledge workers but territories by seats, hedge only the front line, pin at one level โ cascade_levels chains one-level cascades with per-transition kwargs, threading each level's base output into the next level's targets:
result = cascade_levels(
hierarchy_df, regional_targets,
taxonomy=['regional', 'node_3_region', 'node_4_team', 'node_5_rep_no'],
target_col='nn_acv_target',
level_kwargs=[
dict(metrics=KW_SPECS), # d0 -> d1
dict(metrics=KW_SPECS, hedge_multiplier=1.05), # d1 -> d2
dict(metrics=SEAT_SPECS, gate_metrics=DC_GATE), # d2 -> d3
])
Base conservation holds per parent at every level, each transition hedges only its own step, quarters/keys thread through, dropped targets carry a level tag โ and with uniform kwargs the result equals a single full-tree cascade_many (pinned test). The one-level primitive itself needs no new API: cascade_many(df, targets, group_keys=[parent_col], taxonomy=[parent_col, child_col]).
What's New in v0.17.0 โ the proportional-split front door (issue #34)
Deterministic capacity-based allocation now has a named entry point: cascader.cascade_proportional(root, target, metric='dc_seats') โ "this team holds 30% of the DC seats โ 30% of the quota," no correlation, no target column, any slice size. Blends: metrics={'dc_seats': 1.0, 'cloud_seats': 0.5}. All cascade options (gates, HedgeByDepth, pinsโฆ) pass through. To be clear about what this is: sugar over the behavior that was always the default โ fixed-weight MetricSpecs are used as-is, and suggest_weights has never been a required stage. See "Deterministic proportional splits" under Key Concepts.
What's New in v0.16.0 โ pin exact totals across cascades (#22 ยท #31 ยท #24)
"This territory carries exactly $2.6M in total across all products and quarters" is now one call on the batch output:
from b2b_revenue_forecasting import Pin, apply_pins
edited, feasibility = apply_pins(
quotas_long,
pins=[Pin('AMER_EAST_East1_4', 2_600_000), # leaf, total across combos
Pin('LATAM', 10_500_000), # manager subtree total
Pin('UKI1_1', 400_000, scope={'fiscal_quarter': 1},
exclude=['UKI1_3'])], # Q1 only, protect UKI1_3
freeze_nodes=['East1_1'], # never absorbs, never changes
)
The pinned node keeps its baseline mix across combos; siblings absorb each cascade's delta proportionally (subtrees rescale, parents conserve, floors at $0 โ never negative); infeasible pins are flagged in the feasibility report with the unabsorbed amount; is_pinned/pin_type mark provenance; and everything runs on the base layer with hedged values derived from each row's own ratio. For per-cascade pins, hedging basis, and post-edit re-hedging, see v0.13.0's new_ic_overrides/override_basis/rehedge.
What's New in v0.15.0 โ conditional gating per combination (issue #14)
cascade_many's gate_metrics now accepts a callable evaluated per combination with its group-key dict โ so a DC-seat gate can apply to Migration only, never Expansion, in one call:
DC_GATE = [MetricSpec('dc_seats', columns=['dc_seats'])]
quotas, weights = cascade_many(
hierarchy_df, target_df, group_keys=['st1_sales_type', 'regional'], ...,
gate_metrics=lambda g: DC_GATE if g['st1_sales_type'] == 'Migration' else None,
)
# mapping style: gate_metrics=lambda g: BY_TYPE.get(g['st1_sales_type'])
Exactly equivalent to the old split-and-concat workaround (pinned by test), with failing policies handled by the on_error/dropped-targets machinery. Also fixed: attrs['dropped_targets'] is now stored as records so pd.concat on cascade outputs works again.
What's New in v0.14.0 โ no target left behind (#26 ยท #25 ยท #32)
Targets with no matching hierarchy branch (Government + EMEA with no Government subtree) are now first-class: cascade_many(return_dropped=True) returns them as a frame with a reason column (also always on quotas_long.attrs['dropped_targets']), and the new route_targets() places that money on named recipients anywhere in the tree:
quotas, weights, dropped = cascade_many(..., return_dropped=True)
routed = route_targets(
dropped, quotas,
recipients=['UKI1_2', 'UKI2_1', 'NORD1_3'], # named Enterprise_EMEA reps
target_col='nn_acv_target',
recipient_keys={'regional': 'Enterprise_EMEA'},
split='base_quota', # proportional to capacity
)
full_plan = pd.concat([quotas, routed], ignore_index=True)
Routing happens on the base layer, hedged values derive from each recipient's own ratio, ancestor rollups keep every depth reconciled, and routed rows carry the original segment tags plus routed=True. Conditional exclusions ("this rep never carries Cloud") are just two calls with different filters.
What's New in v0.13.0 โ pins that behave: any level, a basis, and safe re-hedging (#28 ยท #21 ยท #23)
new_ic_overrides pins now work at any level โ pin a manager and the subtree total is fixed and cascades within; jagged-hierarchy leaf pins are honored too (both were silently ignored). Conservation is guaranteed: unpinned siblings share the exact remainder, the brand-new carve-out is capped at the pool, and a pin exceeding the pool floors siblings at $0 (never negative) with a loud warning plus overpinned_amount/overpinned_nodes in gating_report(). Pins also gained a basis (override_basis): "base" (default โ pin the un-hedged plan number, hedged derived) or "cascaded" (pin the exact final number, base derived) โ previously pinned reps silently received no hedge. And for post-cascade edits: do the math on base_quota, roll parents up on base, then derive the hedged layer with cascader.rehedge(edited_base) (per-node ratios via cascader.hedge_ratios()) โ summing hedged leaves into parents double-counts buffers up the tree.
What's New in v0.12.0 โ small slices split proportionally, not equally (issue #33)
Correlation-based weight suggestion is undefined on tiny slices (n โค 2) and zero-variance columns โ the common case in per-group batch runs. Previously those candidates were zeroed, and an all-zero slate made the cascade equal-split: siblings with a 6ร seat difference got identical quotas, silently. Now on_degenerate="proportional" (the new default) keeps each degenerate candidate's declared weight, so allocation stays proportional to the blended metric values โ with any number of metrics, directions included. The old behavior is one keyword away (on_degenerate="equal"), "raise" fails fast, degenerate candidates are flagged in the report (degenerate / fallback fields) and named in a warning, and missing columns still get weight 0 (absent data โ thin data).
What's New in v0.11.0 โ per-depth hedging with HedgeByDepth (issue #13)
Hedge policies are usually stated by level, not by node: "front-line managers carry 10%, their directors 5%." HedgeByDepth expresses exactly that and works everywhere hedge_multiplier does โ including cascade_many, where per-node dicts were structurally impossible:
from b2b_revenue_forecasting import cascade_many, HedgeByDepth
quotas_long, _ = cascade_many(
hierarchy_df, target_df, group_keys=[...], target_col=..., taxonomy=[...],
metrics=[...],
hedge_multiplier=HedgeByDepth(
from_leaves={1: 1.10, 2: 1.05}, # deepest mgr 10%, next level 5%
default=1.0,
),
)
from_leaves counts distance to the farthest descendant IC (correct in jagged hierarchies); from_root uses node_depths()-style depth; both can combine (multiplying). The spec resolves against each hierarchy at cascade time, so base_quota reconciliation and all audit columns behave exactly as with a hand-built dict.
What's New in v0.10.2 โ degenerate slices warn before equal-splitting (issue #8)
suggest_weights has long degraded gracefully on thin data (single row, zero variance, all-null columns โ weight 0 + rationale, never an exception, batch runs shielded by cascade_many's skip mode). Now it also tells you: when every candidate comes back with weight 0, one UserWarning explains that the slice carries no usable correlation signal and that cascading will fall back to an equal split among siblings โ so the fallback is never a silent surprise. Missing target_column still raises (typos should be loud).
What's New in v0.10.1 โ weight-normalization semantics at point of use (issue #11)
Docs release. The "raw weight โ influence" nuance is now explained where you actually set weights โ on MetricSpec.weight, in the cascade_quota(metrics=) docstring, and in a new "How Weights Become Influence" section below, all with the same worked example ([1.0, 0.5, 0.0] โ [66.7%, 33.3%, 0%]; a raw 0.067 alongside [1.0, 0.98, 0.4] is 2.7% of the influence, not 6.7%). The documented examples are pinned by a unit test so they can't drift from the implementation.
What's New in v0.10.0 โ one-call gating report (issue #10)
cascader.gating_report() consolidates the whole gating story of the last cascade into one dict: which nodes were gated (gated_node_ids, with gated_leaf_ids split out), which were funded anyway as a last resort (gate_relaxed_node_ids), how much target is explicitly unallocated (unallocated_amount / unallocated_nodes), and the per-cascade reconciliation numbers โ leaf_quota_sum (hedged), leaf_base_sum (un-hedged), base_gap, and a single reconciles boolean asserting every input dollar is either on an IC or reported as unallocated. No more manual diagnostics comparing root target to leaf sums.
What's New in v0.9.0 โ gate semantics you can configure (issue #9)
Gates are no longer hardwired to "gated iff value <= threshold". MetricSpec.gate_mode picks the PASS predicate: "gt" (default โ unchanged behavior), "ge" ("at least N seats": MetricSpec('Seats', columns=['Seats'], gate_threshold=5, gate_mode='ge')), "lt"/"le" (gate territories with too much of a signal, e.g. gate_threshold=100, gate_mode='le' to exclude churn-heavy reps), and "truthy" (boolean entitlement flags, threshold ignored). The exact predicate is documented on MetricSpec; all modes compose with AND across multiple gates and inherit the gate_fallback no-stranding guarantees.
What's New in v0.8.0 โ analysis-ready outputs (issue #7)
Exports now carry your source attributes โ no more manual merges. Declare descriptive columns once (from_dataframe(metadata_cols=['Rep_Name', 'Segment', 'Geo']); they're stored raw and never treated as signal), then emit them with quotas_to_dataframe(metadata_cols=[...]). Or skip storage entirely and left-join any frame onto the leaf rows: quotas_to_dataframe(source_df=df, source_join_col='node_5_rep_no') โ the join is keyed on original ids via the new hierarchy.id_map, so it survives collision renames, and an original_id column appears automatically whenever a node was renamed. cascade_many accepts metadata_cols= too.
What's New in v0.7.2 โ one graph accessor, plus hierarchy helpers (issue #5)
.graph is now the canonical name for the underlying nx.DiGraph on every class (SalesHierarchy, QuotaCascader, PipelineAdjuster), with .hierarchy kept as a working alias on both SalesHierarchy and QuotaCascader โ no more AttributeError whichever one you reach for. New read-only helpers mean you rarely need the raw graph at all: hierarchy.roots(), hierarchy.leaves(root=None), hierarchy.managers(root=None), and hierarchy.node_depths() (handy for building per-level hedge dicts).
What's New in v0.7.1 โ MetricSpec columns resolve intuitively (issue #6)
Specs returned by suggest_weights are now directly usable โ no more for s in suggested: s.columns = [s.name]. Column resolution order: explicit columns= always wins โ the Q1_<name>โฆQ<lookback>_<name> convention โ new: the plain attribute named exactly <name> (so a spec called knowledge_workers finds your knowledge_workers column automatically). And if an active metric ends up with zero signal across the whole tree, cascade_quota warns and names the columns it tried โ silent no-op metrics are gone. The full name/columns contract is documented on the MetricSpec dataclass.
What's New in v0.7.0 โ batch cascading with cascade_many (issue #4)
Real planning cascades many targets across many segments โ every (sales_type, product, regional) combination, for every quarter. cascade_many replaces the hand-rolled loop with one call: it prepares each combination once (filter โ validated hierarchy โ weights) and cascades every matching target row against it, returning tidy long frames tagged with your group keys.
from b2b_revenue_forecasting import cascade_many, MetricSpec
quotas_long, weights_long = cascade_many(
hierarchy_df, # taxonomy + metric columns, 1 row per rep
target_df, # group keys + fiscal_quarter + target
group_keys=["st1_sales_type", "base_product_r4f", "regional"],
target_col="nn_acv_target",
taxonomy=["regional", "node_3_region", "node_4_team", "node_5_rep_no"],
metrics=[MetricSpec("knowledge_workers", direction="proportional",
weight=1.0, columns=["knowledge_workers"])],
gate_metrics=[MetricSpec("dc_seats", columns=["dc_seats"])],
hedge_multiplier=1.05,
)
# quotas_long: group keys + fiscal_quarter + node_id/depth/level +
# cascaded_quota + base_quota + gate audit columns
quotas_long.to_csv("all_cascades.csv", index=False)
Extra target_df columns (like fiscal_quarter) act as sub-targets that reuse the prepared combination. Weights can be fixed, suggested once globally, or re-suggested per combination (suggest_config= + weights_mode="per_group"). Failing combinations warn and are skipped by default (on_error="raise" to fail fast). Every slice gets the full correctness stack: value coercion, duplicate-level healing, DAG validation, never-gated roots, and a base layer that reconciles at every depth.
What's New in v0.6.1 โ non-numeric metrics can't silently zero a slice (issue #3)
A gate column holding numpy.bool_ scalars or "true"/"false" strings used to aggregate to 0 for every leaf โ gating entire slices to $0 with no traceback. Now every metric value is coerced on ingest (numpy scalars unboxed, boolean strings โ bools, "1,200" / "$500" / "12.5%" โ numbers), uncoercible cells warn and are treated as missing, and the cascader itself warns once per column if it ever meets a value it can't read. No API changes; the MAX(CASE WHEN flag THEN 1 ELSE 0 END) SQL workaround is no longer needed.
What's New in v0.6.0 โ dirty hierarchies can't crash the cascade (issue #1)
Previously, a row with the same value at two adjacent levels (e.g., team T1 AND rep T1) silently built a self-loop, and cascade_quota crashed with a cryptic RecursionError deep inside networkx. v0.6.0 makes malformed hierarchies either self-heal or fail loudly with an actionable message.
on_collisionparameter onfrom_dataframeโ"suffix"(default, renames the deeper duplicate to<value>__<level_column>and warns),"skip"(drops the duplicate level, jagged-style), or"error"(raise naming the row).- Blank-string hygiene โ empty cells and literal
"nan"/"none"/"null"strings (akeep_default_na=Falsehazard) are treated as missing levels instead of becoming a shared"nan"node.'NA'the region is still data. hierarchy.validate()+ automatic DAG validation at the end offrom_dataframeโ cross-row cycles raiseHierarchyValidationErrornaming the cycle path.- Fail-fast cascades โ
cascade_quotachecks the graph up front, and the recursive aggregators carry recursion-stack guards, so a cyclic graph can never RecursionError again. Diamond-shaped DAGs remain supported.
h = SalesHierarchy()
h.from_dataframe(df, path_cols=taxonomy, metrics_cols=cols,
on_collision='suffix') # default โ shown for clarity
# -> UserWarning: 1 duplicate-level value(s) detected and renamed ...
h.validate() # explicit re-check, chainable
What's New in v0.5.0 โ no more stranded targets (issue #12)
Previously, when a gate zeroed an entire subtree (e.g., a Migration cascade where no rep in the whole slice had DC entitlement), the target for that slice was silently dropped โ depth-0 held the target while depth 1+ summed short. v0.5.0 guarantees the base (un-hedged) quota sums to the macro target at every depth.
gate_fallbackparameter oncascade_quotacontrols what happens when every child of a funded node is gated:"redistribute"(default) โ a fully-gated subtree's share flows to its nearest non-gated siblings (gates still roll up as before); if the entire level โ even the whole tree โ is gated, the gate is relaxed at that level as a last resort so the target still reaches ICs. No silent target loss, ever."strand_at_root"โ children stay $0; the undistributable amount stays on the deepest non-gated ancestor and is reported viacascader.unallocated/cascader.unallocated_nodesplus anis_unallocatedcolumn inquotas_to_dataframe."error"โ raisesGateAllocationErrorso the caller decides.
- The root is never gated to $0. It always carries the macro target in every mode.
cascader.base_quotasโ everycascade_quotacall now also computes the un-hedged cascade in the same pass, sohedged_quota = base_quota ร hedge^depthdecomposes without a second run. Passunhedged_quotas="auto"toquotas_to_dataframeto get the audit columns for free.cascader.reconciliation_report(quotas, target=..., strict=True)โ per-depth reconciliation DataFrame (depth, n_nodes, total_quota, target, delta, reconciles);strict=Trueraises listing every non-reconciling depth. Run it oncascader.base_quotas(hedged quotas legitimately grow with depth).gate_relaxedcolumn inquotas_to_dataframeflags nodes that received quota despite being gated because every sibling was also gated โ so the last-resort fallback is always visible in the CSV.
quotas = cascader.cascade_quota(
'Enterprise_AMER', 1_000_000.0,
hedge_multiplier=1.05,
metrics=forward_metrics,
gate_metrics=[MetricSpec('DC_Seats', columns=['DC_Seats'])],
gate_fallback='redistribute', # default โ shown for clarity
)
# Base layer reconciles at EVERY depth, even with fully-gated teams:
cascader.reconciliation_report(cascader.base_quotas,
target=1_000_000.0, strict=True)
df = cascader.quotas_to_dataframe(quotas, unhedged_quotas='auto')
What's New in v0.4.0
- Gate metrics โ hard kill-switches.
cascade_quota(..., gate_metrics=[...])excludes any node whose rolled-up gate value is at or below a threshold from the cascade entirely (quota = 0), redistributing its share among non-gated siblings. Designed for white-space planning: e.g., gating "migration NetNewACV" onUnmigrated_Seatszeros out territories with nothing left to migrate. Gates propagate upward naturally โ a manager whose whole team fails the gate gets $0 too. Composes with AND across multiple gates. CRO overrides win over gates. - Two planning philosophies, both supported. See the section below.
is_gatedcolumn inquotas_to_dataframewhen gates were used, so analysts can distinguish "$0 because gated" from "$0 because no signal."cascader.gated_nodesโ the set of gated nodes from the most recent cascade, stored for inspection.
Two Planning Philosophies
The package supports two philosophically distinct ways of building a quota plan. Both use the same primitives โ pick the one that matches how your org thinks about fairness.
Earned planning โ "who has proven they can sell this?"
Cascade on historical signals (past NetNewACV attainment, past cloud-seat adds, LTM expansion). Reconcile against forward pipeline (open opps + late-stage commit + best-case). Best when historical attainment is a clean signal of forward capacity (mature business, low churn in territories, stable rep tenure).
historical_metrics = [
MetricSpec('NetNewACV', direction='proportional', weight=1.0, lookback=4),
MetricSpec('CloudSeats', direction='proportional', weight=0.6, lookback=4),
MetricSpec('DCSeats', direction='inverse', weight=0.4, lookback=4),
]
quotas = cascader.cascade_quota('Global_Corp', macro_target, metrics=historical_metrics)
# Reconcile against forward pipeline
adjuster = PipelineAdjuster(hierarchy, quotas,
pipeline_attr=['Open_Pipeline', 'Late_Stage_Commit'])
White-space planning โ "what can be achieved if we look at the opportunity in front of us?"
Cascade on forward-looking signals (current installed seats, knowledge-worker counts, white-space indicators), with dampeners (LTM spend) and hard gates (unmigrated seats). Reconcile against historical attainment to flag where the plan asks for a step-up. Best when past performance is noisy (rapid growth, territory shuffles, recent re-orgs) and the org wants every rep to be measured against the opportunity in front of them.
forward_metrics = [
MetricSpec('Current_Seats_ProductX', direction='proportional', weight=1.0,
columns=['Current_Seats_ProductX']),
MetricSpec('Knowledge_Workers_Count', direction='proportional', weight=0.7,
columns=['Knowledge_Workers_Count']),
MetricSpec('LTM_ExpansionSpent', direction='inverse', weight=0.5,
columns=['LTM_ExpansionSpent']),
]
gate_metrics = [
MetricSpec('Unmigrated_Seats', columns=['Unmigrated_Seats']), # threshold defaults to 0
]
quotas = cascader.cascade_quota(
'Global_Corp', macro_target,
metrics=forward_metrics, gate_metrics=gate_metrics,
)
# Reconcile against historical attainment
adjuster = PipelineAdjuster(hierarchy, quotas, pipeline_attr=[
'Q1_NetNewACV', 'Q2_NetNewACV', 'Q3_NetNewACV', 'Q4_NetNewACV',
])
diagnosis = adjuster.diagnose(coverage_thresholds={
'_default': {'healthy': 1.0, 'at_risk': 0.75}, # ratios near 1.0, not 1.5โ3x
})
Neither philosophy is "correct" โ they answer different questions. The package supports either as a first-class flow, and you can blend them (some metrics historical, some forward) by mixing them in a single metrics= list.
What's New in v0.3.x
- Multi-metric cascading via the new
MetricSpecAPI โ blend historical NetNewACV with any number of secondary signals (cloud seats, on-prem seats, LTM expansion spend, customer-sat scores, certification flags, anything else the analyst tracks), each marked asproportionalorinverse, with per-metric weights and lookbacks - Direction is always a user input. Domain knowledge ("more cloud seats means more ACV") trumps statistical sign. The package surfaces correlations and warns on mismatch but never overrides the analyst's call
MetricSpec.suggest_weights(...)suggests weights (magnitude of correlation) for user-declared directions. For exploratory use,MetricSpec.suggest_directions_and_weights(...)infers both- Normalized-weights view โ
MetricSpec.normalized_weights(specs)shows the post-normalization share each metric actually contributes; auto-printed before every multi-metric cascade and accessible viacascader.weights_report - Brand-new IC handling โ either-or: flag brand-new ICs in the same CSV the analyst already uploads (
brand_new_col='Is_Brand_New'onSalesHierarchy.from_dataframe, thennew_ic_attr='_is_brand_new'oncascade_quota), OR pick a rule (new_ic_rule='all_metrics_zero'/'primary_metric_zero'). Passing both raisesValueError - Any metric name, any numeric type โ including booleans (
Has_Active_Cert: True/False). Boolean / 0-1 sparse metrics are auto-detected and excluded from zero-imputation so False isn't mistaken for missing data PipelineAdjusteraccepts multiple pipeline columns โpipeline_attr=['Open_Pipeline', 'Late_Stage_Commit', 'Best_Case_Adds']sums them per IC into a combined dollar amount for the coverage ratio- CSV / SQL / dashboard exports โ every output converts to a DataFrame via
cascader.quotas_to_dataframe(...),cascader.quotas_diff_to_dataframe(...), orreconciler.reconcile_all(...). From there.to_csv(),.to_sql(), orcascader.to_html_dashboard(...)writes wherever you need - Hedge audit columns โ pass
unhedged_quotas=toquotas_to_dataframeforunhedged_quota,hedge_buffer, andoverassignment_pctcolumns showing exactly how much of each quota is hedge buffer - Fully backward compatible โ
cascade_quota(...)withoutmetrics=behaves exactly as in v0.2.x
What's New in v0.2.0
PipelineAdjuster: Post-cascade pipeline health analyzer withdiagnose()andadjust()modes- Flexible quarter support:
QuotaCascadernow auto-discovers any number of_Attainmentcolumns (4, 8, 12 quarters) - New IC handling: Partial-history imputation and equal-share allocation for brand-new hires
- CRO overrides: Lock specific IC quotas via
new_ic_overridesto bypass the algorithm - Per-node hedging: Apply different hedge multipliers to different regions/managers
- GitHub Actions CI/CD: Automated testing on Python 3.9โ3.12
๐ฆ Installation
pip install b2b-revenue-forecasting
๐ป Quickstart
1. Build the Org Hierarchy
import pandas as pd
from b2b_revenue_forecasting.hierarchy import SalesHierarchy
# โ ๏ธ Use keep_default_na=False if your data has 'NA' as a region name
df = pd.read_csv('your_crm_data.csv', keep_default_na=False)
# Works with any depth: 3 levels or 10 levels
hierarchy = SalesHierarchy()
hierarchy.from_dataframe(
df,
path_cols=['Global', 'Region', 'RVP', 'Director', 'Manager', 'IC'],
metrics_cols=['Q1_Attainment', 'Q2_Attainment', 'Q3_Attainment', 'Q4_Attainment',
'Current_Pipeline']
)
print(f"Nodes: {len(hierarchy.graph.nodes)}")
print(f"ICs: {len(hierarchy.get_leaves('Global_Corp'))}")
2. Cascade Quotas Top-Down
from b2b_revenue_forecasting.quota_cascader import QuotaCascader
cascader = QuotaCascader(hierarchy)
# Basic: distribute $100M evenly by historical capacity
quotas = cascader.cascade_quota('Global_Corp', 100_000_000.0)
# With 5% hedge at every management level (compounds: 1.05^5 โ 27.6% overassignment)
quotas = cascader.cascade_quota('Global_Corp', 100_000_000.0, hedge_multiplier=1.05)
# Per-node hedge: NA gets aggressive 10%, others standard 5%
quotas = cascader.cascade_quota('Global_Corp', 100_000_000.0, hedge_multiplier={
'Global_Corp': 1.05, 'NA': 1.10, 'EMEA': 1.05, 'APAC': 1.05
})
# CRO override: strategic hire gets exactly $500K regardless of history
quotas = cascader.cascade_quota('Global_Corp', 100_000_000.0,
hedge_multiplier=1.05,
new_ic_overrides={'IC_Strategic_Hire': 500_000.0}
)
3. Multi-Metric Cascading (v0.3+)
For real B2B planning, the metric you're cascading (e.g., NetNewACV) is rarely the only signal that should drive its allocation. Cloud-seat counts predict more new ACV; on-prem (DC) seat counts predict less; high LTM expansion spend means the account is already saturated. The MetricSpec API lets you mix any number of these into a single cascade.
Direction is always your call. You declare whether each metric is proportional (more โ more quota) or inverse (more โ less quota) up front. The package surfaces correlations and warns when the data sign disagrees, but never overrides your domain knowledge.
from b2b_revenue_forecasting import MetricSpec
# Declare each metric's role โ direction is required, weight is your knob
metrics = [
MetricSpec('NetNewACV', direction='proportional', weight=1.0, lookback=4),
MetricSpec('CloudSeats', direction='proportional', weight=0.5, lookback=4),
MetricSpec('DCSeats', direction='inverse', weight=0.4, lookback=4),
MetricSpec('ExpansionSpent',direction='inverse', weight=0.7,
columns=['LTM_ExpansionSpent']), # single LTM column
]
quotas = cascader.cascade_quota(
'Global_Corp', 100_000_000.0,
hedge_multiplier=1.05,
metrics=metrics,
)
Any metric name, any data type works. Customer_Sat_Score, MQLs_Sourced_via_Outbound, Has_Active_Cert (boolean), Renewals_Caught_Up (0/1 counter) โ anything numeric, with any column name. Boolean and 0/1 sparse metrics are auto-detected and excluded from zero-imputation so False isn't treated as a missing value.
How the blend works. At every level, each child gets a share of the parent's quota equal to a weighted sum of its per-metric shares-of-siblings. Proportional metrics use raw shares; inverse metrics flip via reciprocal-then-normalize. The final per-child share is ฮฃ_m (weight_m ร share_m(child)), which sums to 1 across siblings.
Don't know the weights? Pass direction= on each candidate, let suggest_weights() propose magnitudes via Pearson correlation:
suggestions, report = MetricSpec.suggest_weights(
df,
target_column='NetNewACV_4Q_sum',
candidate_metrics=[
{'name': 'CloudSeats', 'column': 'CloudSeats_4Q_sum',
'direction': 'proportional', 'lookback': 4},
{'name': 'DCSeats', 'column': 'DCSeats_4Q_sum',
'direction': 'inverse', 'lookback': 4},
{'name': 'ExpansionSpent', 'column': 'LTM_ExpansionSpent',
'columns': ['LTM_ExpansionSpent'],
'direction': 'inverse', 'lookback': 1},
],
)
# report['CloudSeats']['weight'] == 0.62, ['rationale'] explains why,
# ['direction_matches_data'] tells you if your call agrees with the sign
quotas = cascader.cascade_quota('Global_Corp', 100_000_000.0, metrics=suggestions)
For pure exploration (you don't yet have a domain opinion), use MetricSpec.suggest_directions_and_weights(...) โ it infers both from data. This is a sanity-check helper, not a production-planning API.
Brand-new ICs โ either-or, your choice of where they're listed. The cleanest option keeps everything in the same CSV the analyst already uploads:
# CSV has a column Is_Brand_New with True / 1 / "yes" for each new hire
hierarchy = SalesHierarchy()
hierarchy.from_dataframe(
df, path_cols=[...], metrics_cols=[...],
brand_new_col='Is_Brand_New', # ingested as node attribute _is_brand_new
)
quotas = cascader.cascade_quota(
'Global_Corp', 100_000_000.0,
metrics=metrics,
new_ic_attr='_is_brand_new', # read the flag from the CSV
)
Or, if you don't want a separate column, pick an auto-detection rule:
quotas = cascader.cascade_quota(
'Global_Corp', 100_000_000.0,
metrics=metrics,
new_ic_rule='all_metrics_zero', # or 'primary_metric_zero'
)
You pick one or the other โ passing both an explicit identifier (new_ic_attr or new_ic_ids) AND new_ic_rule in the same call raises ValueError, because the two would silently disagree.
Brand-new ICs get an equal-share carve-out of the team target before the remainder is split proportionally โ just like the single-metric path.
4. Detect & Fix Forecasting Bias
from b2b_revenue_forecasting.commit_reconciler import CommitReconciler
historical = pd.DataFrame({
'Manager_ID': ['Mgr_A', 'Mgr_A', 'Mgr_B', 'Mgr_B'],
'Historical_Commit': [200_000, 250_000, 300_000, 350_000],
'Historical_Actual_Closed': [300_000, 375_000, 270_000, 280_000],
})
reconciler = CommitReconciler(historical)
# Mgr_A is a sandbagger (bias = 1.5x) โ commit inflated automatically
adjusted = reconciler.reconcile_forecast('Mgr_A', current_commit=100_000)
# โ $150,000
# Blend with ML baseline (50/50 average)
blended = reconciler.reconcile_forecast('Mgr_A', 100_000, machine_forecast=120_000)
# โ $135,000
5. Export to CSV, SQL, or an Interactive Dashboard
Every output is a pandas DataFrame, so the same code writes anywhere:
# CSV โ analyst-ready, one row per node at every level
cascaded_df = cascader.quotas_to_dataframe(quotas, level_names=taxonomy)
cascaded_df.to_csv('cascaded_quotas.csv', index=False)
# CSV with hedge audit โ also include the unhedged baseline
quotas_unhedged = cascader.cascade_quota(
'Global_Corp', 100_000_000.0, hedge_multiplier=1.0,
metrics=cascade_metrics, verbose=False,
)
cascader.quotas_to_dataframe(
quotas, level_names=taxonomy, unhedged_quotas=quotas_unhedged,
).to_csv('cascaded_quotas_with_audit.csv', index=False)
# โ adds unhedged_quota, hedge_buffer, overassignment_pct columns
# SQL โ same DataFrames, any SQLAlchemy-compatible database
import sqlite3
with sqlite3.connect('cascade.db') as conn:
cascaded_df.to_sql('cascaded_quotas', conn, if_exists='replace', index=False)
cascader.weights_report.to_sql('normalized_weights', conn,
if_exists='replace', index=False)
# Postgres / Snowflake / BigQuery: swap conn for a SQLAlchemy engine
# Interactive HTML dashboard โ Chart.js, self-contained, shareable
cascader.to_html_dashboard(
quotas, output_path='cascade_dashboard.html',
title='Q1 Cascade โ $100M Plan',
unhedged_quotas=quotas_unhedged,
adjusted_quotas=adjusted, diagnosis=diagnosis,
)
6. Pipeline Health Diagnosis & Redistribution
from b2b_revenue_forecasting.pipeline_adjuster import PipelineAdjuster
# Single pipeline column (backward compat)
adjuster = PipelineAdjuster(hierarchy, quotas, pipeline_attr='Current_Pipeline')
# Or sum multiple dollar-denominated pipeline columns from the same CSV
adjuster = PipelineAdjuster(hierarchy, quotas, pipeline_attr=[
'Open_Pipeline', 'Late_Stage_Commit', 'Best_Case_Adds',
])
# Configure per-region coverage thresholds (ICs inherit from ancestors)
thresholds = {
'NA': {'healthy': 1.5, 'at_risk': 0.8},
'EMEA': {'healthy': 2.5, 'at_risk': 1.2},
'APAC': {'healthy': 3.0, 'at_risk': 1.5},
'_default': {'healthy': 2.0, 'at_risk': 1.0}
}
# Diagnose โ returns a DataFrame with risk status for every node
diagnosis = adjuster.diagnose(thresholds)
print(diagnosis.groupby('Risk_Status')['Node'].count())
# Flag-only mode โ returns original quotas unchanged (for pre-approval review)
flagged = adjuster.adjust(mode='flag_only', coverage_thresholds=thresholds)
# Redistribute mode โ zero-sum IC adjustment within each manager's team
adjusted = adjuster.adjust(
mode='redistribute',
coverage_thresholds=thresholds,
max_adjustment_pct=0.20, # ยฑ20% cap per IC
locked_nodes={'IC_Protected': 500_000.0} # CRO-locked ICs excluded
)
# โ
Manager totals preserved | โ
Donors give, receivers get | โ
20% cap enforced
๐ง Key Concepts
Metric Grain
Metric columns must be at leaf grain: one value describing that rep/territory alone. Non-leaf values are always computed as leaf-sums, so an ancestor-level number repeated onto child rows (an account's seats copied to every product row, a region's seats copied to every team) double-counts on the way up and makes siblings identical โ collapsing their shares to an equal split. Resolve grain in the feed query (e.g. MAX per (rep, account) then SUM per rep); since v0.19.1 the cascader warns when a metric looks repeated from a coarser grain.
Deterministic Proportional Splits (No Statistics)
The most common allocation โ "split the target proportional to a metric" โ needs no correlation and no suggest_weights. Fixed-weight MetricSpecs passed to cascade_quota(metrics=...) are used exactly as given; the suggester is an optional helper for when you want data-driven weight magnitudes. The one-liner (v0.17.0):
quotas = cascader.cascade_proportional('Enterprise_EMEA', 1_000_000, metric='dc_seats')
# 30% of the DC seats -> 30% of the quota. Blend: metrics={'dc_seats': 1.0, 'cloud_seats': 0.5}
Deterministic, explainable, and correct at any slice size โ including the tiny nโค2 slices where correlation is undefined.
How Weights Become Influence
Weights you set on MetricSpecs are relative, normalized to sum to 1 across active metrics (weight > 0) at cascade time; inactive metrics contribute exactly 0. A metric's real influence is weight / sum(active weights):
raw weights [1.0, 0.5, 0.0] -> influence [66.7%, 33.3%, 0%]
raw weights [1.0, 0.98, 0.4, 0.067] -> 0.067 / 2.447 = 2.7% (not 6.7%!)
Always check the actual shares with MetricSpec.normalized_weights(specs) or cascader.weights_report โ the same table auto-prints before every verbose multi-metric cascade, and it's the table to show stakeholders.
Managerial Hedge (Overassignment Buffer)
A multiplier applied at each management level to create mathematical safety. A 5% hedge across 5 layers compounds to ~27.6% total overassignment (1.05โต), ensuring the enterprise hits its number even if some ICs miss.
Bias Quotient
Bias Quotient = ฮฃ(Actual Closed) / ฮฃ(Committed)
- > 1.0 = Sandbagger (closes more than committed โ inflate their forecast)
- = 1.0 = Neutral
- < 1.0 = Happy Ears (over-promises โ deflate their forecast)
Pipeline Coverage Ratio
Coverage = Current Pipeline / Cascaded Quota
| Coverage | Status | Action |
|---|---|---|
| โฅ healthy threshold | ๐ข Healthy | May receive quota |
| โฅ at_risk threshold | ๐ก Moderate | No action |
| โฅ 1.0 | ๐ At Risk | May donate quota |
| < 1.0 | ๐ด Critical | Urgent โ pipeline below target (May donate quota) |
New IC Handling
| Scenario | Behavior |
|---|---|
| Full history | Proportional by total capacity |
| Partial history (e.g., 1 of 4 quarters) | Zero quarters imputed with own non-zero average |
| Brand new (all zeros) | Equal share of team target |
| CRO override | Fixed amount, excluded from pool |
๐งช Testing
# Run all tests
cd hierarchical_sales_forecasting
pip install -e .
python -m pytest tests/ -v
# Run the full demo
python demo_full_pipeline.py
๐ Publications
This framework is the subject of peer-reviewed research and technical publications:
| Publication | Venue | Status |
|---|---|---|
| Hierarchical Sales Target Cascading using DAGs in Python | Towards AI | โ Published |
| Graph-Theoretic Approaches to Hierarchical Revenue Target Allocation in B2B Enterprises | SSRN (Preprint) | โ Published |
| Graph-Theoretic Approaches to Hierarchical Revenue Target Allocation in B2B Enterprises | Journal of Revenue and Pricing Management (Springer) | โณ Under Review |
If you use this package in your research, please cite:
Karwa, S. (2026). Graph-Theoretic Approaches to Hierarchical Revenue Target Allocation
in B2B Enterprises: A Methodological Framework. SSRN Working Paper. https://papers.ssrn.com/sol3/papers.cfm?abstract_id=6456999
๐ Requirements
- Python โฅ 3.8
- pandas โฅ 1.0.0
- networkx โฅ 2.5
- numpy โฅ 1.19.0
๐ค Contributing
Built explicitly for RevOps analysts, Data Scientists, and VP Revenue Operations executing scaling go-to-market strategies. Contributions, issues, and pull requests are warmly welcomed!
- Report bugs: GitHub Issues
- Source code: GitHub
๐ License
MIT License โ see LICENSE for details.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file b2b_revenue_forecasting-0.20.0.tar.gz.
File metadata
- Download URL: b2b_revenue_forecasting-0.20.0.tar.gz
- Upload date:
- Size: 169.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8ece55185f3388a7bff82f4eff99bed92837499fc266fefc277adc74fca887da
|
|
| MD5 |
fd48d244ced6d834895f79dac56efb08
|
|
| BLAKE2b-256 |
02252fe70b87f7eb48bd91d144ab33434cc5ab2dab76137549a2db4888a99005
|
File details
Details for the file b2b_revenue_forecasting-0.20.0-py3-none-any.whl.
File metadata
- Download URL: b2b_revenue_forecasting-0.20.0-py3-none-any.whl
- Upload date:
- Size: 87.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e5d8da0f9cf289757d05e8f0845bc1c12c0b4511157f73cc6a3a8251ac986edb
|
|
| MD5 |
bad8175396d97dc33a8fbbcbe6070100
|
|
| BLAKE2b-256 |
cf799f82cd0a920f630ee10cf8ad0598275310cddbd579ea8935014a84326f25
|