Base Phylogeny

PortfolioOptimisers.HopCountType
struct HopCount{__T_n} <: AbstractSeparationAlgorithm

Separation measured as the number of graph edges between two assets.

The separation between two assets is the length of the shortest path between them counted in edges, ignoring the weights those edges carry, and the budget is n of them. It is the separation the network family has always used: phylogeny_matrix's sum(A^i for i in 0:n) and both clusterise methods' power sums are hop budgets, and this member is where that n now lives.

The budget is a field rather than an argument because it is stated in hops, a unit only this member uses. PathLength measures the same structure in the distance estimator's units and carries its own budget in those, so no caller has to know which unit is in play.

The power sum is the source's range connection matrix

phylogeny_matrix's $\mathbf{P} = \mathbb{1}_{x \geq 1}\left(\sum_{i=0}^{n} \mathbf{A}^{i}\right) - \mathbf{I}$ is the range connection matrix $\mathbf{B}_{1,n}$ of the source, spelled with one indicator instead of n of them. The source builds a per-length connection matrix $\mathbf{B}_{k} = \mathbb{1}_{x \geq 1}(\mathbf{A}^{k} + \mathbf{I}) - \mathbf{I}$ and then indicates their sum; the library adds the powers first, and the $\mathbf{A}^{0} = \mathbf{I}$ term the sum picks up is the term the trailing $- \mathbf{I}$ removes again. The two agree entry for entry: on the source's own six-node example the matrices are identical for every n from 1 to 5.

The budget may be a rule instead of a number

n also takes a HopCountAlgorithm or a bare Function, which resolve_separation calls as n(nte, X, g; dims = dims, kwargs...) at the point of use, g being the structure the consumer already built. A caller who cannot state the budget in advance — because the universe is a cross-validation fold or a subproblem of a meta optimiser — states the rule that produces it instead of a number that was right for one universe. HopCountQuantile is the shipped rule.

n is still an Integer once resolved, and never a Real. Three readers use 0:(nte.sep.n) as a matrix-power count, where 0:1.5 silently drops a power instead of failing, so resolve_separation checks the rule's return value rather than trusting it.

Fields

  • n: Number of steps to take in the network for deciding adjacency. An Integer is used as it stands. A HopCountAlgorithm or a Function is a rule, called as n(nte, X, g; dims = dims, kwargs...) by resolve_separation at the point of use, g being the structure the consumer already built, and must return an Integer.

Constructors

HopCount(;    n::HopCountValue = 1) -> HopCount

Keywords correspond to the struct's fields.

Validation

  • If n is an Integer, 1 <= n <= RESOURCE_LIMITS[].max_hop_count (three readers sum A^i over i in 0:n, so the compute cost is linear in n; see RESOURCE_LIMITS). A rule is checked when it is resolved, not when it is stored.

Examples

julia> HopCount()HopCount  n ┴ Int64: 1julia> HopCount(; n = HopCountQuantile())HopCount  n ┼ HopCountQuantile    │   q ┴ Float64: 0.25

Related

References

  • [5] D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025). Section 13.1.2, Equations 13.1-13.2.
source
PortfolioOptimisers.PathLengthType
struct PathLength{__T_dmax} <: AbstractSeparationAlgorithm

Separation measured as the length of the shortest weighted path between two assets.

The separation between two assets is the sum of the distances along the shortest path joining them in the network, and the budget is dmax of the same units. It is the graded counterpart of HopCount: both measure how far apart two assets sit in the same structure, but one counts the edges and the other adds up how long they are.

It is a library generalisation and rests on no published source. HopCount has one — it is the range connection matrix of a walk length — but that literature counts edges throughout, and states no budget in the units a distance estimator emits.

The path runs over distances on both branches

The path is taken over the distance matrix restricted to the structure's edge setcalc_distance_weighted_graph — whichever branch built the structure. On the tree branch that is the graph's own weights; on the PMFG branch the structure is selected by similarity and then re-weighted by the distance that the similarity is a strictly decreasing function of.

Pathing over the PMFG's similarities instead is not a second convention, it is backwards: a shortest path over similarities minimises total similarity, so it prefers the route through the weakest links. It fails quietly — measured over the four similarity algorithms, the backwards answer correlates 0.95 to 0.97 with the right one, which is far too close to catch by looking.

It is not comparable with a hop count, only interchangeable with one

PathLength and HopCount satisfy the same contract, so any consumer reading a separation through that contract takes either — Proximity and phylogeny_matrix both do. Their outputs are not comparable as values: the budgets are in different units, the supports differ, and under LinearDecay the scales differ.

Both clusterise methods are the exception, and refuse PathLength at dispatch. They do not read the separation through the contract at all: they index a matrix power by sep.n, and a radius has no analogue of one.

On a real universe the two agree far more than that suggests — measured over twenty assets, rho = 0.99 on a minimum spanning tree and 0.95 to 0.98 on a PMFG, with 0.16% of pairs of pairs strictly inverted on the tree and none at all on the PMFG — because both structures are selected by distance to begin with. That agreement is empirical, not guaranteed: it is a fact about the graphs an AbstractDistanceEstimator tends to produce, not a property of either separation.

The budget

dmax = nothing is the default and means the whole connected component, implemented as the observed diameter: the largest finite entry of the separation matrix. It is the default because nobody has an intuition for a summed path in the units an AbstractDistanceEstimator emits — dmax = 0.37 is not a number a caller can reason about, whereas "look at the whole component and let the decay do the falling off" is. Choosing a number is how a caller buys fold-stability.

separation_budget clamps a chosen dmax to the observed diameter. The clamp cuts nothing — no pair sits beyond the diameter — so it is a scale-top correction and bites only LinearDecay, the one decay that reads the budget: without it, a dmax far above the diameter would flatten Z towards a constant while forbidding no pair at all.

The default reads very differently through phylogeny_matrix, which selects on the budget instead of shaping a fall-off inside it. "The whole connected component" there means every reachable pair is related, so NetworkEstimator(; sep = PathLength()) yields a matrix of ones off the diagonal — the opposite end of the dial from HopCount's default n = 1. State a numeric dmax to select anything narrower.

The budget may be a rule instead of a number

dmax also takes a PathLengthAlgorithm or a bare Function, which resolve_separation calls as dmax(nte, X, g; dims = dims, kwargs...) at the point of use, g being the structure the consumer already built. This is the answer to the paragraph above for a caller who cannot state a number: PathLengthQuantile asks for the budget that relates a stated fraction of the reachable pairs, which is a quantity a caller does have an intuition for, and which means the same thing on every fold of a cross-validation and in every subproblem of a meta optimiser.

A fixed dmax and a rule buy different things, and the difference is the whole point. A fixed dmax holds the radius still and lets the related-pair count move with the graph. A quantile rule holds the count still and lets the radius move. Neither is fold-stable in both senses at once, because the graph is refitted either way.

Fields

  • dmax: Separation budget, in the units the separation is measured in. nothing means the observed diameter of the structure. A PathLengthAlgorithm or a Function is a rule, called as dmax(nte, X, g; dims = dims, kwargs...) by resolve_separation at the point of use, g being the structure the consumer already built, and must return a Number.

Constructors

PathLength(;    dmax::Option{PathLengthValue} = nothing) -> PathLength

Keywords correspond to the struct's fields.

Validation

  • If dmax is a Number, dmax > 0. A rule is checked when it is resolved, not when it is stored.

Examples

julia> PathLength()PathLength  dmax ┴ nothingjulia> PathLength(; dmax = 0.5)PathLength  dmax ┴ Float64: 0.5julia> PathLength(; dmax = PathLengthQuantile(; q = 0.3))PathLength  dmax ┼ PathLengthQuantile       │   q ┴ Float64: 0.3

Related

source
PortfolioOptimisers.LinearDecayType
struct LinearDecay <: AbstractSeparationDecayAlgorithm

Separation decay falling off linearly to the edge of the budget.

Mathematical definition

\[\begin{align} f(d) &= d_{\mathrm{max}} + 1 - d\,, \end{align}\]

Where:

  • $d$: Separation between two assets.
  • $d_{\mathrm{max}}$: Separation budget in scope.

The default, and the only member that reads the budget. It is the fall-off the graded neighbourhood hardcoded before the family existed, so it reproduces those values exactly: a direct neighbour scores $d_{\mathrm{max}}$, the asset itself $d_{\mathrm{max}} + 1$.

Because truncation lives with the budget rather than in the decay, no max(0, ⋅) floor is needed — on the kept range 0 <= d <= dmax the expression is strictly positive, bottoming out at 1.

Examples

julia> separation_decay.(Ref(LinearDecay()), 0:3, 3)4-element Vector{Int64}: 4 3 2 1

Related

source
PortfolioOptimisers.ExponentialDecayType
struct ExponentialDecay{__T_rate} <: AbstractSeparationDecayAlgorithm

Separation decay falling off exponentially.

Mathematical definition

\[\begin{align} f(d) &= e^{-\lambda d}\,, \end{align}\]

Where:

  • $d$: Separation between two assets.
  • $\lambda$: Rate of the fall-off, rate.

Pins f(0) = 1 and lets rate set the self-versus-neighbour contrast independently of the budget, which is what a caller wanting relatedness to drop sharply needs — the budget only says how far to look.

Parameterised by rate rather than by per-step retention. $\rho^d$ and $e^{-\lambda d}$ are the same function ($\lambda = -\log\rho$), but "retention per step" is a statement about integers, and the family's argument is a real separation. The rate form also matches ExponentialSimilarity and ExpGerberIQDecay, and needs only a one-sided bound to stay monotone.

Fields

  • rate: Rate of the exponential fall-off, exp(-rate * d). Larger values decay faster. The per-step retention form, ratio^d, is rate = -log(ratio).

Constructors

ExponentialDecay(;    rate::Number = 1.0) -> ExponentialDecay

Keywords correspond to the struct's fields.

Validation

  • rate > 0.

Examples

julia> ExponentialDecay()ExponentialDecay  rate ┴ Float64: 1.0julia> separation_decay.(Ref(ExponentialDecay()), 0:3, 3)4-element Vector{Float64}: 1.0 0.36787944117144233 0.1353352832366127 0.049787068367863944

Related

source
PortfolioOptimisers.ReciprocalDecayType
struct ReciprocalDecay{__T_power} <: AbstractSeparationDecayAlgorithm

Separation decay falling off as a power of the separation.

Mathematical definition

\[\begin{align} f(d) &= \left(1 + d\right)^{-p}\,, \end{align}\]

Where:

  • $d$: Separation between two assets.
  • $p$: Exponent of the fall-off, power.

The middle ground between LinearDecay and ExponentialDecay: heavier-tailed than the exponential, so distant assets keep a small but non-negligible score.

The 1 + is what makes classical inverse-distance weighting finite at $d = 0$; it also pins f(0) = 1, matching ExponentialDecay's scale for free. The alternative spelling $(1 + d^p)^{-1}$ is not used: it pins $f(1) = 1/2$ for every p, and its response to p at fixed d flips sign about that pivot — raising the exponent scores a pair nearer than $d = 1$ higher and a pair further away lower. So p is no fall-off dial there, because it cannot sharpen the decay inside the pivot at all. Under the spelling above, raising p lowers the score at every $d > 0$.

Fields

  • power: Exponent of the reciprocal fall-off, inv((1 + d)^power). Larger values decay faster.

Constructors

ReciprocalDecay(;    power::Number = 1.0) -> ReciprocalDecay

Keywords correspond to the struct's fields.

Validation

  • power > 0.

Examples

julia> ReciprocalDecay()ReciprocalDecay  power ┴ Float64: 1.0julia> separation_decay.(Ref(ReciprocalDecay()), 0:3, 3)4-element Vector{Float64}: 1.0 0.5 0.3333333333333333 0.25

Related

source
PortfolioOptimisers.NoDecayType
struct NoDecay <: AbstractSeparationDecayAlgorithm

Separation decay that does not fall off at all.

Mathematical definition

\[\begin{align} f(d) &= 1\,, \end{align}\]

Where:

  • $d$: Separation between two assets.

No decay is not no truncation

The name is about the fall-off and nothing else. The budget still cuts: a pair outside it scores 0, because truncation was never the decay's job — see AbstractSeparationDecayAlgorithm's "The budget is an argument, not a field". What comes out is therefore an indicator of the neighbourhood the budget selects, not a matrix of ones.

That is exactly what makes it useful. Under HopCount it turns Proximity into the n-hop neighbourhood indicator, which is what the retired BinaryNeighbourhood produced; under PathLength it is an ε-ball, and neither needs a type of its own once the fall-off is a knob.

It is the flat end of the family, so it is the only member that is not strictly decreasing. The contract asks for monotone non-increasing, which a constant satisfies.

Examples

julia> separation_decay.(Ref(NoDecay()), 0:3, 3)4-element Vector{Int64}: 1 1 1 1

Related

source
PortfolioOptimisers.factoryMethod
factory(
    pl::Union{AbstractPhylogenyEstimator, AbstractPhylogenyResult},
    args...;
    kwargs...
) -> NetworkEstimator{<:CovarianceEstimator, <:AbstractDistanceEstimator, KruskalTree{Tuple{}, @NamedTuple{}}, <:AbstractSeparationAlgorithm}

Return the phylogeny estimator or result pl unchanged.

Identity pass-through used when a phylogeny estimator or pre-computed result is provided in a context that calls factory. A phylogeny estimator carries no prior-dependent field to rebuild, so every field is passed through and none is replaced.

Algorithm

  1. Return pl itself. No field of it is rebuilt, and args and kwargs are discarded.

Arguments

  • pl: Phylogeny estimator or phylogeny result.
  • args...: Optional arguments (ignored).
  • kwargs...: Optional keyword arguments (ignored).

Returns

  • pl::PlE_Pl: The original phylogeny estimator or result.

Related

source
PortfolioOptimisers.factoryMethod
factory(
    alg::AbstractPhylogenyAlgorithm,
    args...;
    kwargs...
) -> AbstractClustersAlgorithm

Return the phylogeny algorithm alg unchanged.

Identity pass-through used when a phylogeny algorithm is provided in a context that calls factory. A phylogeny algorithm carries no prior-dependent field to rebuild, so every field is passed through and none is replaced.

Algorithm

  1. Return alg itself. No field of it is rebuilt, and args and kwargs are discarded.

Arguments

  • alg: Phylogeny algorithm.
  • args...: Optional arguments (ignored).
  • kwargs...: Optional keyword arguments (ignored).

Returns

  • alg::AbstractPhylogenyAlgorithm: The original phylogeny algorithm.

Related

source
PortfolioOptimisers.separation_decayFunction
separation_decay(dk::LinearDecay, d::Number, dmax::Number)
separation_decay(dk::ExponentialDecay, d::Number, dmax::Number)
separation_decay(dk::ReciprocalDecay, d::Number, dmax::Number)
separation_decay(dk::NoDecay, d::Number, dmax::Number)

Score a separation under a decay algorithm.

The whole extension contract of AbstractSeparationDecayAlgorithm: a new member is a struct and one method of this function.

The method the call selects

This function is a selector and runs no step of its own. It dispatches on dk, and the selected method evaluates one closed form. Each form is stated under # Mathematical definition on the member that owns it, and none is restated here.

dkThe fall-off it selectsReads dmax
LinearDecayLinear to the edge of the budgetyes
ExponentialDecayExponential in rateno
ReciprocalDecayA power of 1 + d, set by powerno
NoDecayFlatno

LinearDecay is the only member that reads the budget, which is why the other three methods leave their third argument unnamed.

Arguments

  • dk: Separation decay algorithm.
  • d: Separation between two assets, d >= 0. Real rather than integral, so a weighted path length is as admissible as a hop count.
  • dmax: Separation budget in scope. Inert for members that do not need it — only LinearDecay reads it. Inert arguments have precedent here: phylogeny_features ignores its alg entirely for a partition source.

Validation

The contract is not checked here — this runs inside an assets × assets loop. Callers probe once up front with assert_separation_decay instead.

Returns

  • f::Number: Score for the separation. Non-negative for 0 <= d <= dmax; above the budget the sign is unconstrained and LinearDecay does go negative, which is harmless because the consumer's budget test short-circuits before the call.

Examples

julia> separation_decay(LinearDecay(), 2, 3)2julia> separation_decay(ExponentialDecay(; rate = 2.0), 2, 3)0.01831563888873418julia> separation_decay(ReciprocalDecay(; power = 2.0), 2, 3)0.1111111111111111julia> separation_decay(NoDecay(), 2, 3)1

Related

source

References

[5]
D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025).