Feature Distance
PortfolioOptimisers.AngularDist — Type
struct AngularDist <: Distances.MetricNormalised angular distance metric.
Unlike Distances.CosineDist ($1 - \cos$), the angular distance satisfies the triangle inequality, so it is a true metric and the hierarchies built from it are well defined. It maps $[-1,\,1] \to [1,\,0]$, so it is bounded, scale-invariant per asset, and admits signed features. Its exact similarity counterpart is AngularSimilarity, which recovers the cosine from the distance alone.
A zero feature vector has no direction, so the cosine is undefined. By convention two zero vectors are at distance 0 from each other (they are identical) and at distance 1 from every non-zero vector (maximally dissimilar), which keeps $S = \cos(\pi D)$ true on every entry of the matching similarity matrix.
Mathematical definition
\[\begin{align} d_{i,\,j} &= \dfrac{1}{\pi}\arccos\left(\dfrac{\boldsymbol{z}_{i} \cdot \boldsymbol{z}_{j}}{\lVert\boldsymbol{z}_{i}\rVert \lVert\boldsymbol{z}_{j}\rVert}\right)\,, \end{align}\]
Where:
- $d_{i,\,j}$: Pairwise distance between assets $i$ and $j$.
- $\boldsymbol{z}_{i}$: Feature vector of asset $i$, its row of the feature matrix.
Algorithm
The metric carries two paths, and both are its contract. The elementwise method answers one pair of feature vectors, and Distances._pairwise! answers a whole matrix.
The elementwise method, AngularDist()(a, b):
- Promote the element types of
aandbwithFloat64, givingT. - Take the norms of
aandb, givingnaandnb. - Return
zero(T)when both norms are zero, andone(T)when exactly one of them is. This is the zero-feature-vector convention above. - Divide the dot product of
aandbbyna * nb, giving the cosine. - Clamp the cosine to $[-1,\,1]$, take its $\arccos$, and divide by $\pi$.
The matrix method, Distances._pairwise!(::AngularDist, r, a). It receives a already permuted to columns-as-observations, so a zero column of a is a zero feature vector:
- Delegate the whole matrix to the
Distances.CosineDistkernel, which writes $1 - \cos$ intorwith one BLASgemmcall. That kernel divides by the norm, so a zero column ofaleavesNaNin its row and its column ofr. - Mark the zero columns of
a, givingz. - Rewrite every entry of
rin place: the diagonal tozero(T); a pair of zero columns tozero(T); a zero column against a non-zero one toone(T); every other entry to $\arccos(1 - r_{i,\,j}) / \pi$.
One matrix multiplication replaces $N^{2}$ scalar calls, and it is the faster path from three assets upward. It loses only at $N = 2$, where the single distance it saves does not pay for the call. So there is one matrix path and nothing to tune.
$\arccos(1 - r) / \pi$ is the algebraic identity of the elementwise method, not its floating-point result. Off the diagonal the two paths agree to a few units in the last place. On the diagonal they differ more: the cosine of a vector with itself rounds only to within floating-point precision of 1, $\arccos$ has an infinite derivative at 1, so that residual is amplified into a much larger error in the distance. The matrix path writes an exact zero instead.
Distances.pairwise writes an exact zero diagonal, so the matrix entry points — which are the only route FeatureDistance takes — never see the residual. Call the metric directly on a pair of identical vectors and it is there. The "AngularDist gemm path matches the elementwise method" testset pins the two paths together, and that is why it pins them with a tolerance.
Related
References
- [44] S. Van Dongen and A. J. Enright. Metric distances derived from cosine similarity and Pearson and Spearman correlations. arXiv preprint arXiv:1208.3145 (2012).
PortfolioOptimisers.MeanCollapse — Type
struct MeanCollapse <: AbstractCollapseAlgorithmAggregates along the observation axis with the possibly weighted arithmetic mean.
This is the only collapse algorithm AggregateDistances accepts, because a convex combination of metrics is itself a metric. AggregateFeatures accepts it too, so it is the one member both consumers share, and the default of both.
Mathematical definition
\[\begin{align} \bar{z}_{i,\,k} &= \dfrac{\sum\limits_{t=1}^{T} w_{t} z_{t,\,i,\,k}}{\sum\limits_{t=1}^{T} w_{t}}\,, \end{align}\]
Where:
- $\bar{z}_{i,\,k}$: Collapsed feature $k$ of asset $i$, the aggregate of $z_{t,\,i,\,k}$ over the observation axis.
- $z_{t,\,i,\,k}$: Feature window entry: feature $k$ of asset $i$ at observation $t$.
- $w_{t}$: Observation weight of observation $t$.
- $T$: Number of observations.
An unweighted collapse sets every $w_{t}$ to $1$. The weights are non-negative and the denominator normalises them, so the aggregate is a convex combination of the window. That is what makes it a metric when it is applied to distance matrices.
Algorithm
- Reduce the leading observation axis of
ZwithStatistics.mean, weighted bywwhenwis notnothing. - Drop the reduced axis, giving an
assets × featuresmatrix.
Related
PortfolioOptimisers.MedianCollapse — Type
struct MedianCollapse <: AbstractCollapseAlgorithmAggregates along the observation axis with the possibly weighted median, which resists an outlying observation.
Only AggregateFeatures accepts it: it aggregates the features and applies the metric afterwards, so the result is a metric. AggregateDistances rejects it at construction, because an entrywise median of distance matrices need not satisfy the triangle inequality.
A quantile interpolates, so the aggregate need not be an element of the window. Statistics.median(v, w) is the StatsBase $0.5$-quantile rather than an order statistic: on the window [0, 1, 2, 3] under the weights [1, 2, 3, 4] it returns 11/6, which lies strictly between the second and the third value. Interpolation is what the quantile may do rather than what it always does — the same window under the weights [4, 3, 2, 1] returns 1, an element. The unweighted median of an even window averages the two central values for the same reason.
Mathematical definition
\[\begin{align} \bar{z}_{i,\,k} &= Q_{0.5}\left(\left\{z_{t,\,i,\,k}\right\}_{t=1}^{T},\, \left\{w_{t}\right\}_{t=1}^{T}\right)\,, \end{align}\]
Where:
- $\bar{z}_{i,\,k}$: Collapsed feature $k$ of asset $i$, the aggregate of $z_{t,\,i,\,k}$ over the observation axis.
- $z_{t,\,i,\,k}$: Feature window entry: feature $k$ of asset $i$ at observation $t$.
- $w_{t}$: Observation weight of observation $t$.
- $T$: Number of observations.
- $Q_{0.5}$: The $0.5$-quantile of the window under those weights.
An unweighted collapse sets every $w_{t}$ to $1$.
Algorithm
- For each asset
jand each featurek, take the observation seriesview(Z, :, j, k). - Reduce that series with
Statistics.median, weighted bywwhenwis notnothing, giving the entry of the collapsed matrix.
Related
PortfolioOptimisers.LastObservation — Type
struct LastObservation <: AbstractFeatureCollapseAlgorithmDiscards the window and measures the last observation's feature matrix alone.
The cheapest member of the family and its default, because it is the only one whose result depends on no aggregation choice. It is also the one member that names its rows before the stack exists: collapse_rows answers the last row, so the kernel stacks a window of one observation from an Asset Panel rather than every observation the collapse then discards.
Algorithm
- Take the last slice of the observation axis,
view(Z, size(Z, 1), :, :), giving anassets × featuresmatrix. - Apply the metric to that matrix once.
Related
PortfolioOptimisers.AggregateFeatures — Type
struct AggregateFeatures{__T_w, __T_alg} <: AbstractFeatureCollapseAlgorithmCollapses the window to one assets × features matrix, then applies the metric once.
Each feature is aggregated along the observation axis. The metric runs after the aggregation, so the result is a metric for both MeanCollapse and MedianCollapse, and this is the only consumer that takes the median.
Algorithm
- Resolve
wagainstZwithcollapse_weights, giving a weight vector of one entry per observation, ornothing. - Collapse the observation axis of
Zwithalg, giving oneassets × featuresmatrix. - Apply the metric to that matrix once, and apply the zero-feature-vector convention to the result.
Fields
w: Optional observation weights vectorobservations × 1, or a concrete subtype ofDynamicAbstractWeights. Ifnothing, the computation is unweighted.
alg: Collapse algorithm, the aggregator applied along the observation axis.
Constructors
AggregateFeatures(; w::Option{<:ObsWeights} = nothing, alg::AbstractCollapseAlgorithm = MeanCollapse()) -> AggregateFeaturesKeywords correspond to the struct's fields.
Validation
- If
wis notnothing,!isempty(w).
Propagated parameters
When factory is called on this type, the following @fprop-tagged fields are automatically propagated:
w: Replaced with the incomingObsWeights.
Observation weight parameters
When obs_weights_view is called on this type, the following fields are automatically indexed to the selected observations:
w: Indexed to the selected observations viaobs_weights_view.
Examples
julia> AggregateFeatures()AggregateFeatures w ┼ nothing alg ┴ MeanCollapse()Related
PortfolioOptimisers.AggregateDistances — Type
struct AggregateDistances{__T_w, __T_alg} <: AbstractFeatureCollapseAlgorithmMeasures every observation, then aggregates the resulting distance matrices.
Produces one distance matrix per observation and combines them into a single assets × assets matrix. Costs observations metric evaluations against AggregateFeatures's one, and accumulates into a single buffer rather than materialising the whole stack.
Only MeanCollapse is accepted: a convex combination of metrics is a metric, an entrywise median of them is not. Because the metric is applied before the aggregation, the zero-feature convention is applied per observation — an asset that is zero at some observations but not others is treated as zero only in the observations where it is.
Algorithm
- Resolve
wagainstZwithcollapse_weights, giving a weight vector of one entry per observation, ornothing. - Allocate the accumulator
Dand the single per-observation bufferDt, bothassets × assets, and set the weight totalswto zero. - For each observation
t: measure that slice ofZintoDt; apply the zero-feature-vector convention toDt; read the observation's weightwt, which isone(T)whenwisnothing; addwt .* DttoD; and addwttosw. - Divide
Dbysw, giving the convex combination of the per-observation distance matrices.
Fields
w: Optional observation weights vectorobservations × 1, or a concrete subtype ofDynamicAbstractWeights. Ifnothing, the computation is unweighted.
alg: Collapse algorithm, the aggregator applied along the observation axis.
Constructors
AggregateDistances(; w::Option{<:ObsWeights} = nothing, alg::AbstractCollapseAlgorithm = MeanCollapse()) -> AggregateDistancesKeywords correspond to the struct's fields.
Validation
- If
wis notnothing,!isempty(w). algis not aMedianCollapse.
Propagated parameters
When factory is called on this type, the following @fprop-tagged fields are automatically propagated:
w: Replaced with the incomingObsWeights.
Observation weight parameters
When obs_weights_view is called on this type, the following fields are automatically indexed to the selected observations:
w: Indexed to the selected observations viaobs_weights_view.
Examples
julia> AggregateDistances()AggregateDistances w ┼ nothing alg ┴ MeanCollapse()julia> AggregateDistances(; alg = MedianCollapse())ERROR: ArgumentError: alg must not be a MedianCollapse: an entrywise median of distance matrices need not satisfy the triangle inequality, so the result would not be a metric. Use MeanCollapse, or aggregate the features instead with AggregateFeatures.[...]Related
PortfolioOptimisers.StackObservations — Type
struct StackObservations <: AbstractFeatureCollapseAlgorithmConcatenates the window into one long feature vector per asset, so nothing is averaged away.
Turns observations × assets × features into an assets × (observations · features) matrix along the feature axis, and applies the metric once. Two assets are close only when their whole trajectories agree — which is also why the result is dominated by whichever observations carry the largest magnitudes, and why heterogeneous features should be standardised before it is used.
Equals none of the other members of the family in general, but agrees with all of them when observations == 1.
Algorithm
- Permute
Zso the asset axis leads:(2, 1, 3)atdims = 1, and(3, 1, 2)atdims = 2. - Reshape the permuted array to
assets × (observations · features), giving one long feature vector per asset. - Apply the metric to that matrix once, along its first axis.
Related
PortfolioOptimisers.FeatureDistance — Type
struct FeatureDistance{__T_metric, __T_alg, __T_sim, __T_ape, __T_sel, __T_strict} <: AbstractDistanceEstimatorTurns a feature matrix into a distance matrix, by applying a metric to the rows of that matrix.
A feature matrix describes assets by their exposures, memberships, loadings or adjacencies rather than by their returns. This estimator is a peer of Distance and DistanceDistance: unlike them, it never consults a correlation matrix, so it is usable where returns are uninformative or unavailable.
Any Distances.SemiMetric is accepted, including a user-defined one, and every metric yields a similarity, so no combination throws on this path. The remarks below are about the metric a caller chooses, not about this type.
A metric returning a distance above 1 gives similarities outside $[-1,\,1]$ under the default ComplementSimilarity, which plot_clusters silently clips. The threshold is 1, not "the metric is unbounded" — Distances.CosineDist and Distances.CorrDist are bounded by 2 and cross it routinely.
That claim is scoped to this path. Handing this estimator to a NetworkEstimator, DBHT or LoGo as their de puts the resulting distance matrix on the PMFG path, where their own similarity field applies rather than sim, and where assert_similarity_domain refuses a distance above 1 under ComplementSimilarity and a non-finite one under MaximumDistanceSimilarity.
Every metric other than AngularDist and Distances.CorrDist is scale-sensitive, and even AngularDist is invariant to scaling an asset's feature vector but not to scaling a feature across assets. Heterogeneous features should be standardised before use. Distances.CorrDist is NaN against any constant feature vector, hence unusable with a single feature.
Distances.Jaccard is the general non-negative-real (Ruzicka) form, not the binary-set Jaccard, and returns values up to 2 on signed input without erroring. It, Distances.BrayCurtis and Distances.ChiSqDist therefore require a non-negative feature matrix, which assert_metric_domain checks in the kernel rather than at construction, because the feature matrix is not known here.
Choosing the columns
sel names the Panel Fields the Feature Matrix stacks, and nothing stacks every Panel Field's values. Without it this estimator swallows the whole panel, which is harmless while a panel holds only features and wrong the moment it holds anything else.
An entry of sel takes one of four forms, and they mix freely in one vector:
"industry"is a Panel Field name, and stands for that Panel Field's value columns alone."industry" => ["Tech", "Energy"]keeps the levels or labels it names, in that order."industry" => "Tech"keeps one level or label. This is the form a column label takes."mcap" => :observedis the Panel Field's observed mask, one0/1column.
There is no integer entry: every Panel Field, level and label carries a name, so a position has nothing to index. A taxonomy is selected by the name of the categorical Panel Field it entered the panel as.
strict decides what an entry naming a field, a level or a label the panel does not hold does: it throws when strict is true, and warns and drops the entry otherwise.
The order of sel is the column order the metric reads, so a caller decides it. feature_matrix stacks the matrix and feature_labels names its columns, one selector entry per column.
Mathematical definition
\[\begin{align} D_{i,\,j} &= m\left(\boldsymbol{z}_{i},\, \boldsymbol{z}_{j}\right)\\ S_{i,\,j} &= \sigma\left(D_{i,\,j}\right)\,, \end{align}\]
Where:
- $D_{i,\,j}$: Distance between assets $i$ and $j$.
- $S_{i,\,j}$: Similarity between assets $i$ and $j$.
- $\boldsymbol{z}_{i}$: Feature vector of asset $i$, its row of the feature matrix.
- $m$: Distance metric,
metric. - $\sigma$: Similarity transformation,
sim.
Fields
metric: Distance metric applied to the rows of the feature matrix.
alg: Feature collapse algorithm, used to reduce a window of time-varying features to a single distance matrix. Inert for a 2-D feature matrix.
sim: Similarity matrix algorithm used to derive the similarity counterpart of the feature distance matrix.
ape: Asset Panel producer, ornothingto read the panel the data carrier holds. A producer is configuration: it builds a static panel at the point of use, from the prior result and the returns of the subproblem that runs it, so a view passes it through and a fold refits it.
sel: Feature Selector naming the Panel Fields the Feature Matrix stacks, ornothingto stack every field's values. An entry is a field name, a field paired with the levels or labels it keeps, a field paired with one level or label, or a field paired with:observed. The vector order is the column order.
strict: Whether aselentry naming a field, a level or a label the Asset Panel does not hold throws instead of warning and being dropped.
Constructors
FeatureDistance(; metric::Distances.SemiMetric = AngularDist(), alg::AbstractFeatureCollapseAlgorithm = LastObservation(), sim::AbstractSimilarityMatrixAlgorithm = default_similarity(metric), ape::Option{<:AbstractAssetPanelEstimator} = nothing, sel::Option{<:AbstractVector} = nothing, strict::Bool = false) -> FeatureDistanceKeywords correspond to the struct's fields.
Validation
simis defaulted frommetricviadefault_similarity, so the resolved value is visible on the printed object rather than hidden inside the distance kernel.selis checked byassert_feature_selector:nothing, or a non-empty vector of distinct entries, each of the four admitted forms.
Propagated parameters
When factory is called on this type, the following @fprop-tagged fields are automatically propagated:
Examples
julia> FeatureDistance()FeatureDistance metric ┼ AngularDist: AngularDist() alg ┼ LastObservation() sim ┼ AngularSimilarity() ape ┼ nothing sel ┼ nothing strict ┴ Bool: falsejulia> FeatureDistance(; metric = PortfolioOptimisers.Distances.CosineDist())FeatureDistance metric ┼ Distances.CosineDist: Distances.CosineDist() alg ┼ LastObservation() sim ┼ ComplementSimilarity() ape ┼ nothing sel ┼ nothing strict ┴ Bool: falseRelated
AbstractDistanceEstimatorAngularDistAbstractFeatureCollapseAlgorithmAbstractSimilarityMatrixAlgorithmdefault_similarityDistancedistancecor_and_distassert_metric_domain: the non-negativity check that the three restricted metrics take in the kernel.assert_feature_selector: the construction check onsel.select_fields: the one resolution ofselagainst anAssetPanel.feature_matrix: the stacking itself.feature_labels: one selector entry per column of the stacked matrix.DBHT: carries asimfield of its own, deliberately named alike — same type, same job. When both are set DBHT's wins, becauseclusteriseoverwrites the similarity matrix immediately aftercor_and_distreturns.factory
PortfolioOptimisers.feature_matrix — Function
feature_matrix(pnl::AssetPanel, sel = nothing; strict::Bool = false, rows = Colon()) -> ArrayStack the Panel Fields a Feature Selector names into the Feature Matrix a distance measures.
Nothing stores the result. The Asset Panel is the data, and the Feature Matrix is one view of it, so it is built where it is measured and thrown away after.
A static panel gives an assets × features matrix, and a time-varying one an observations × assets × features array. A numeric Panel Field gives one column, a categorical Panel Field one 0/1 column per level, a tensor Panel Field one column per label, and an observed mask one 0/1 column. The order of sel is the column order.
A time-varying panel stacks every observation unless rows names the rows to stack, and then it stacks those alone, length(rows) × assets × features. That is how a consumer that reads one row stacks one row: a FeatureDistance under LastObservation passes the last row through collapse_rows, so a lifted static Panel Field, whose values are a RepeatedLeading, is read once rather than copied once per observation. The stack keeps its observation axis whatever rows holds, so a one-row stack is a window of one observation, on which every collapse algorithm agrees. A static panel has no observation axis, so it takes Colon() alone.
Algorithm
- Resolve
selagainst the panel withselect_fields. - Derive the element type, as the promotion over the Panel Fields whose value columns were resolved. An observed-mask column is a
0/1column that every type carries, so it contributes nothing, and neither does an indicator. A selection of mask and indicator columns alone stacks in the panel's own type, the promotion over every Panel Field's values, so aFloat32panel's one-hot block isFloat32; a panel with no numeric or tensor Panel Field at all stacks inFloat64. Seepanel_value_eltype. - Allocate the matrix as zeros, over the observation rows
rowsnames, the panel's asset axis and the resolved column count. Seestacked_axes. - Write each column, cut to
rows, withpanel_field_value_column!orpanel_field_observed_column!.
Arguments
pnl: The Asset Panel.- Feature Selector naming the Panel Fields the Feature Matrix stacks, or
nothingto stack every field's values. An entry is a field name, a field paired with the levels or labels it keeps, a field paired with one level or label, or a field paired with:observed. The vector order is the column order. - Whether a
selentry naming a field, a level or a label the Asset Panel does not hold throws instead of warning and being dropped. - The observation rows a time-varying Asset Panel stacks,
Colon()for every row. A static panel has no observation axis and refuses any other value.
Validation
- The panel holds a Panel Field, and
selresolves to at least one column. Seeselect_fields. rowsisColon()on a static panel, and indexes the observation axis of a time-varying one. Seestacked_axes.
Returns
Z::Array: The Feature Matrix, in the type derived over the Panel Fields it stacks.
Related
feature_matrix(de::FeatureDistance, pr, rd, X) -> AbstractArray{<:Number}Stack the Feature Matrix a FeatureDistance measures, from the panel its ape slot resolves.
The resolution has one site. asset_panel(de.ape, pr, rd, X) answers the carrier's panel under a nothing producer and builds one otherwise, and feature_matrix's panel method then stacks the columns de.sel names, over the observation rows de.alg reads. The kernel calls this, and a caller who asks what a clustering measured calls feature_labels with the arguments the optimiser received, so the caller's rebuild is the kernel's measurement by construction.
The rows are the collapse algorithm's, read through collapse_rows: under LastObservation a time-varying panel stacks its last observation alone, 1 × assets × features, which is the slice that collapse measures and the whole of what a caller asking what was measured is answered with. Every other collapse stacks every observation.
Algorithm
- Resolve the panel with
asset_panel. - Stack it with
feature_matrix, readingde.selandde.strict, over the rowscollapse_rowsnames forde.alg.
Arguments
de: Feature distance estimator.pr: Prior result or returns result. Both carry the asset returns matrixXand the feature matrixZ, so either can supply them.rd: The returns result to use.X: Returns matrix of the subproblem, observations × assets. A producer reads it.
Returns
- The Feature Matrix,
assets × featuresorobservations × assets × features, where the observation count is the onede.algreads.
Related
PortfolioOptimisers.feature_labels — Function
feature_labels(pnl::AssetPanel, sel = nothing; strict::Bool = false) -> VectorName the columns feature_matrix stacks, one Feature Selector entry per column.
A label is the entry that selects exactly its column, so the returned vector is itself a Feature Selector and stacking the panel against it rebuilds the same matrix. That is what lets a caller ask what a distance measured without the matrix being stored anywhere.
The kernel never calls this: it reads the matrix alone, so no label is allocated on a path that does not read one.
Algorithm
- Resolve
selagainst the panel withselect_fields. - Name each resolved column with
panel_column_label.
Arguments
pnl: The Asset Panel.- Feature Selector naming the Panel Fields the Feature Matrix stacks, or
nothingto stack every field's values. An entry is a field name, a field paired with the levels or labels it keeps, a field paired with one level or label, or a field paired with:observed. The vector order is the column order. - Whether a
selentry naming a field, a level or a label the Asset Panel does not hold throws instead of warning and being dropped.
Returns
labels::Vector: One Feature Selector entry per column offeature_matrix, in column order.
Related
feature_labels(de::FeatureDistance, pr, rd, X) -> VectorName each column of the Feature Matrix a FeatureDistance measures.
The sibling of feature_matrix, and it resolves the panel and the selector the same way, so the two agree by construction. A label is the selector entry that selects exactly that column, so the label vector is itself a selector that rebuilds the matrix — which is what a caller who asks what was measured needs.
The kernel never calls it: no clustering or phylogeny result records the labels, because the estimator and the carriers derive them with no distance computed. A caller who wants them calls feature_labels(de, res.pr, rd, rd.X) with the arguments the optimiser received.
Algorithm
- Resolve the panel with
asset_panel. - Label it with
feature_labels, readingde.selandde.strict.
Arguments
de: Feature distance estimator.pr: Prior result or returns result. Both carry the asset returns matrixXand the feature matrixZ, so either can supply them.rd: The returns result to use.X: Returns matrix of the subproblem, observations × assets. A producer reads it.
Returns
- One label per column of the Feature Matrix.
Related
References
- [44]
- S. Van Dongen and A. J. Enright. Metric distances derived from cosine similarity and Pearson and Spearman correlations, arXiv preprint arXiv:1208.3145 (2012).