Source code for qbiocode.apps.quvine.fusion.fuse

# Copyright 2026, IBM Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import numpy as np
from numpy.linalg import svd
from qbiocode.apps.quvine.analysis.analyze import normalize

def _row_norm(Z, eps=1e-8):
    nrm = np.linalg.norm(Z, axis=1, keepdims=True)
    return Z / (nrm + eps)

def _block_standardize(Z, eps=1e-8):
    mu = Z.mean(axis=0, keepdims=True)
    sd = Z.std(axis=0, keepdims=True)
    return (Z - mu) / (sd + eps)

def _prep_blocks(Zs, do_row_norm=True, do_block_standardize=True):
    out = []
    for Z in Zs:
        Zp = Z
        if do_row_norm:
            Zp = _row_norm(Zp)
        if do_block_standardize:
            Zp = _block_standardize(Zp)
        out.append(Zp)
    return out

[docs] def fuse_embeddings_svd(Zs, k): """ Fast early fusion: concatenate (after per-block normalization) + SVD/PCA to shared k-dim space. """ Zcat = np.concatenate(Zs, axis=1) # (n, sum d_v) U, S, _ = svd(Zcat, full_matrices=False) k_eff = min(k, U.shape[1]) Z_fused = U[:, :k_eff] * S[:k_eff] # (n,k_eff), scaled PCs if k_eff < k: Z_fused = np.pad(Z_fused, ((0, 0), (0, k - k_eff)), mode='constant') return Z_fused
# ---------- Optional: graph-regularized shared U, scalable ---------- def _apply_graph_regularization(U0, L, beta, lam, max_cg_iter=200, cg_tol=1e-6): """ Solve (beta*L + (1+lam)*I) u_j = u0_j for each column via sparse CG. Returns U0 unchanged if scipy is unavailable or beta <= 0. """ import scipy.sparse as sp import scipy.sparse.linalg as spla if not sp.issparse(L): # DO NOT densify on big graphs; pass sparse L. L = sp.csr_matrix(L) n, k = U0.shape Aop = (beta * L) + (1.0 + lam) * sp.eye(n, format="csr") U = np.zeros_like(U0) for j in range(k): rhs = U0[:, j] uj, info = spla.cg(Aop, rhs, maxiter=max_cg_iter, rtol=cg_tol) if info != 0: uj = rhs U[:, j] = uj return U
[docs] def fuse_embeddings_graphreg(Zs, k, L, beta=1e-2, lam=1e-2, max_cg_iter=200, cg_tol=1e-6): """ Solve: argmin_U ||U - Zbar||_F^2 + beta * tr(U^T L U) + lam ||U||_F^2 where Zbar is the SVD-fused initialization projected to k dims. This is a *much simpler* regularization story than your full multiview + Ws + alpha, and it avoids over-parameterization reviewers will question. """ U0 = fuse_embeddings_svd(Zs, k) if beta <= 0: return U0 try: return _apply_graph_regularization(U0, L, beta, lam, max_cg_iter, cg_tol) except ImportError: return U0
[docs] def fuse_embeddings_attention(Zs, k, temperature=1.0): """ Attention-based fusion: learn attention weights for each embedding view. Uses softmax attention over embedding similarities to weight each view. """ # Per-view quality = effective rank (exp of the spectral entropy of Z). # The previous score ||Z_norm||_F^2 / n is identically 1 for any # row-normalized Z, so the softmax was always uniform and this fused # method silently collapsed to a plain mean of the views. Effective rank # measures how much independent information a view carries and genuinely # varies across views. (Matches fuse_embeddings_attention_fixed.) quality = [] for Z in Zs: s = svd(np.asarray(Z, dtype=np.float64), full_matrices=False, compute_uv=False) s2 = s ** 2 if s2.sum() <= 0: quality.append(0.0) continue p = s2 / s2.sum() entropy = -np.sum(p * np.log(p + 1e-20)) quality.append(float(np.exp(entropy))) from scipy.special import softmax attention_weights = softmax(np.array(quality) / temperature) # Weighted combination Z_weighted = sum(w * Z for w, Z in zip(attention_weights, Zs)) # Apply SVD for dimensionality reduction U, S, _ = svd(Z_weighted, full_matrices=False) k_eff = min(k, U.shape[1]) Z_fused = U[:, :k_eff] * S[:k_eff] if k_eff < k: Z_fused = np.pad(Z_fused, ((0, 0), (0, k - k_eff)), mode='constant') return Z_fused
[docs] def fuse_embeddings_hybrid(Zs, k, L=None, beta=1e-2, lam=1e-2, temperature=1.0): """ Hybrid fusion: combines SVD and attention-based fusion. First applies attention-weighted fusion, then applies SVD with optional graph regularization. """ # Step 1: Attention-based weighting attention_fused = fuse_embeddings_attention(Zs, k=k, temperature=temperature) if L is not None and beta > 0: try: return _apply_graph_regularization(attention_fused, L, beta, lam) except ImportError: pass return attention_fused
[docs] def fuse_embeddings_svd_shared_private(Zs, k, gate_type='attention'): """ SVD-based shared/private fusion for N ≥ 2 embedding views. Each view is decomposed into a *shared* component (extracted by a joint rank-k SVD over all views concatenated) and a *private* residual. The views are then recombined using one of two gating strategies: ``attention`` — a per-feature sigmoid gate is applied to each view's private component; the result is added to the mean shared component. Good when views are complementary and private details matter. ``moe`` — a per-node softmax over the V views selects a weighted combination of all normalised views. Good when views are exchangeable and the model should pick the most informative one per node. Parameters ---------- Zs : list of np.ndarray, each (n, d) Embedding views to fuse (V ≥ 2). k : int Rank for SVD approximation (typically d // 4). gate_type : {'attention', 'moe'} Returns ------- Z_final : np.ndarray, shape (n, d) """ V = len(Zs) if V < 2: raise ValueError("fuse_embeddings_svd_shared_private requires at least 2 views.") n, d = Zs[0].shape # 1. Layer-normalise all views. Zn = [_layer_norm(Z) for Z in Zs] # 2. Joint SVD over the horizontally concatenated views → rank-k shared reconstruction. Z_cat = np.concatenate(Zn, axis=1) # (n, V*d) U, S, Vh = svd(Z_cat, full_matrices=False) k_eff = min(k, U.shape[1]) Z_hat = (U[:, :k_eff] * S[:k_eff]) @ Vh[:k_eff, :] # (n, V*d) # 3. Per-view shared and private components. Z_sh = [Z_hat[:, i*d:(i+1)*d] for i in range(V)] # each (n, d) Z_priv = [Zn[i] - Z_sh[i] for i in range(V)] # each (n, d) # Mean shared component — the consensus across all views. Z_shared_mean = np.mean(np.stack(Z_sh, axis=0), axis=0) # (n, d) if gate_type == 'attention': # Gate features: [all shared ‖ all private] → (n, 2V*d) # Gate output: (n, V*d) — one d-dimensional sigmoid gate per view. gate_feat = np.concatenate(Z_sh + Z_priv, axis=1) # (n, 2V*d) gate = _simple_mlp_gate(gate_feat, V * d) # (n, V*d) # Add gated private corrections to the shared mean; average over views. private_mix = sum(gate[:, i*d:(i+1)*d] * Z_priv[i] for i in range(V)) Z_final = Z_shared_mean + private_mix / V elif gate_type == 'moe': # Gate features: normalised views concatenated → (n, V*d) # Gate output: (n, V) softmax weights over views. gate_feat = np.concatenate(Zn, axis=1) # (n, V*d) raw = _simple_mlp_gate(gate_feat, V) # (n, V) sigmoid gate_w = raw / (raw.sum(axis=1, keepdims=True) + 1e-8) # (n, V) sum-to-1 Z_final = sum(gate_w[:, i:i+1] * Zn[i] for i in range(V)) # (n, d) else: raise ValueError(f"Unknown gate_type '{gate_type}'. Use 'attention' or 'moe'.") return Z_final
def _layer_norm(Z, eps=1e-8): """Layer normalization.""" mean = Z.mean(axis=1, keepdims=True) std = Z.std(axis=1, keepdims=True) return (Z - mean) / (std + eps) def _simple_mlp_gate(features, output_dim, random_state=0): n, input_dim = features.shape hidden_dim = max(64, output_dim) rng = np.random.default_rng(random_state) W1 = rng.standard_normal((input_dim, hidden_dim)) * 0.01 b1 = np.zeros(hidden_dim) W2 = rng.standard_normal((hidden_dim, output_dim)) * 0.01 b2 = np.zeros(output_dim) h = np.maximum(0, features @ W1 + b1) return 1.0 / (1.0 + np.exp(-(h @ W2 + b2)))
[docs] def fuse_embeddings(store, k=None, L=None, method="svd", beta=1e-2, lam=1e-2, temperature=1.0, svd_rank=None, gate_type='attention'): """ Fuse multiple embeddings using various methods. Parameters ---------- store : EmbeddingStore Store containing multiple embeddings k : int, optional Target embedding dimension L : sparse matrix, optional Graph Laplacian (required for 'graphreg' and 'hybrid') method : str Fusion method: - "svd" : SVD-based fusion (fast, default) - "graphreg" : Graph-regularized fusion (requires L) - "attention" : Attention-weighted fusion - "hybrid" : Attention + graph regularization (requires L) - "svd_shared_priv" : SVD shared/private decomposition with gating - "all" : Compute all methods (requires L) beta : float Graph regularization strength lam : float L2 regularization strength temperature : float Temperature for attention softmax svd_rank : int, optional Rank for SVD approximation in shared/private decomposition (default: k // 4) gate_type : str Gate type for shared/private fusion: 'attention' or 'moe' Returns ------- embeddings : list List of fused embeddings names : list List of method names """ names = store.names() Zs_raw = [store.get(name) for name in names] # Column-normalize then row-normalize each view once. Zs = [normalize(Z) for Z in Zs_raw] Zs = _prep_blocks(Zs, do_row_norm=True, do_block_standardize=False) if k is None: k = min(Z.shape[1] for Z in Zs) if method == "svd": return [fuse_embeddings_svd(Zs, k)], ['svd'] if method == "graphreg": if L is None: raise ValueError("L must be provided for method='graphreg'.") return [fuse_embeddings_graphreg(Zs, k, L=L, beta=beta, lam=lam)], ['graphreg'] if method == "attention": return [fuse_embeddings_attention(Zs, k, temperature=temperature)], ['attention'] if method == "hybrid": if L is None: raise ValueError("L must be provided for method='hybrid'.") return [fuse_embeddings_hybrid(Zs, k, L=L, beta=beta, lam=lam, temperature=temperature)], ['hybrid'] if method == "svd_shared_priv": rank = svd_rank if svd_rank is not None else max(k // 4, 1) return [fuse_embeddings_svd_shared_private(Zs, rank, gate_type=gate_type)], [f'svd_shared_priv_{gate_type}'] if method == "all": if L is None: raise ValueError("L must be provided for method='graphreg' and 'hybrid'.") svd_emb = fuse_embeddings_svd(Zs, k) graphreg_emb = fuse_embeddings_graphreg(Zs, k, L=L, beta=beta, lam=lam) attention_emb = fuse_embeddings_attention(Zs, k, temperature=temperature) hybrid_emb = fuse_embeddings_hybrid(Zs, k, L=L, beta=beta, lam=lam, temperature=temperature) rank = svd_rank if svd_rank is not None else max(k // 4, 1) svd_sp_att = fuse_embeddings_svd_shared_private(Zs, rank, gate_type='attention') svd_sp_moe = fuse_embeddings_svd_shared_private(Zs, rank, gate_type='moe') return [svd_emb, graphreg_emb, attention_emb, hybrid_emb, svd_sp_att, svd_sp_moe], \ ['svd', 'graphreg', 'attention', 'hybrid', 'svd_shared_priv_attention', 'svd_shared_priv_moe'] raise ValueError(f"Unknown fusion method: {method}")
# ============================================================================ # Hierarchical Fusion for 39-Method System # ============================================================================ # Define all 39 methods ALL_39_METHODS = [ # SGNS (3) 'quvine_rwr', 'quvine_ctqw', 'quvine_dtqw', # Filters (6) 'baseline_filter_heat', 'baseline_filter_poly', 'filter_rwr_heat', 'filter_rwr_poly', 'filter_ctqw_heat', 'filter_ctqw_poly', # GAT (12) 'gat_baseline', 'gat_heat', 'gat_poly', 'gat_rwr', 'gat_ctqw', 'gat_dtqw', 'gat_rwr_heat', 'gat_rwr_poly', 'gat_ctqw_heat', 'gat_ctqw_poly', 'gat_dtqw_heat', 'gat_dtqw_poly', # GraphGPS (12) 'graphgps_baseline', 'graphgps_heat', 'graphgps_poly', 'graphgps_rwr', 'graphgps_ctqw', 'graphgps_dtqw', 'graphgps_rwr_heat', 'graphgps_rwr_poly', 'graphgps_ctqw_heat', 'graphgps_ctqw_poly', 'graphgps_dtqw_heat', 'graphgps_dtqw_poly', # Classical baselines (6) 'node2vec', 'netmf', 'graphsage', 'appnp', 'baseline_filter', 'baseline_gcnmf' ] # Define quantum methods QUANTUM_METHODS = { 'quvine_ctqw', 'quvine_dtqw', 'filter_ctqw_heat', 'filter_ctqw_poly', 'gat_ctqw', 'gat_dtqw', 'gat_ctqw_heat', 'gat_ctqw_poly', 'gat_dtqw_heat', 'gat_dtqw_poly', 'graphgps_ctqw', 'graphgps_dtqw', 'graphgps_ctqw_heat', 'graphgps_ctqw_poly', 'graphgps_dtqw_heat', 'graphgps_dtqw_poly' } def _filter_methods_by_type(method_names, method_type, quantum_only=False, classical_only=False): """ Filter methods by type and quantum/classical. Args: method_names: List of method names to filter method_type: 'sgns', 'filter', 'gat', 'graphgps', or 'baselines' quantum_only: Only include quantum methods classical_only: Only include classical methods Returns: List of filtered method names """ # Define method type patterns type_patterns = { 'sgns': ['quvine_rwr', 'quvine_ctqw', 'quvine_dtqw'], 'filter': [ 'baseline_filter_heat', 'baseline_filter_poly', 'filter_rwr_heat', 'filter_rwr_poly', 'filter_ctqw_heat', 'filter_ctqw_poly' ], 'gat': [m for m in ALL_39_METHODS if m.startswith('gat_')], 'graphgps': [m for m in ALL_39_METHODS if m.startswith('graphgps_')], 'baselines': ['node2vec', 'netmf', 'graphsage', 'appnp', 'baseline_filter', 'baseline_gcnmf'] } # Filter by type candidates = [m for m in method_names if m in type_patterns.get(method_type, [])] # Filter by quantum/classical if quantum_only: candidates = [m for m in candidates if m in QUANTUM_METHODS] elif classical_only: candidates = [m for m in candidates if m not in QUANTUM_METHODS] return candidates def _fuse_via_svd(embeddings_list, target_dim=None): """ Fuse embeddings using SVD. Args: embeddings_list: List of embedding arrays (n_nodes, embedding_dim) target_dim: Target dimension for fused embedding (default: first embedding's dim) Returns: Fused embedding array (n_nodes, target_dim) """ if len(embeddings_list) == 0: raise ValueError("embeddings_list cannot be empty") if len(embeddings_list) == 1: return embeddings_list[0] # Stack embeddings horizontally stacked = np.hstack(embeddings_list) # SVD U, S, Vt = np.linalg.svd(stacked, full_matrices=False) # Keep dimensions equal to first embedding or target_dim if target_dim is None: target_dim = embeddings_list[0].shape[1] target_dim = min(target_dim, U.shape[1]) fused = U[:, :target_dim] @ np.diag(S[:target_dim]) return fused
[docs] def fuse_by_method_type(embeddings_dict, method_type, quantum_only=False, classical_only=False, fusion_method='svd', target_dim=None): """ Fuse embeddings within a method type. Args: embeddings_dict: {method_name: embedding_array} method_type: Type of methods to fuse ('sgns', 'filter', 'gat', 'graphgps', 'baselines') quantum_only: Only fuse quantum methods classical_only: Only fuse classical methods fusion_method: 'svd', 'concatenate', 'average' target_dim: Target dimension for fused embedding Returns: Fused embedding array """ # Filter methods by type and quantum/classical filtered_methods = _filter_methods_by_type( list(embeddings_dict.keys()), method_type, quantum_only, classical_only ) # Extract embeddings to fuse embeddings_to_fuse = [ embeddings_dict[m] for m in filtered_methods if m in embeddings_dict ] if len(embeddings_to_fuse) == 0: raise ValueError(f"No embeddings found for type={method_type}, " f"quantum={quantum_only}, classical={classical_only}") # Perform fusion if fusion_method == 'svd': return _fuse_via_svd(embeddings_to_fuse, target_dim) elif fusion_method == 'concatenate': return np.concatenate(embeddings_to_fuse, axis=1) elif fusion_method == 'average': return np.mean(embeddings_to_fuse, axis=0) else: raise ValueError(f"Unknown fusion method: {fusion_method}")
[docs] def fuse_best_across_types(embeddings_dict, performance_scores, quantum_only=False, classical_only=False, fusion_method='svd', target_dim=None): """ Fuse best-performing method from each type. Steps: 1. For each method type (SGNS, Filter, GAT, GraphGPS): - Select best-performing method based on scores 2. Fuse the best methods using specified fusion method Args: embeddings_dict: {method_name: embedding} performance_scores: {method_name: score} (mean across replicates) quantum_only: Only consider quantum methods classical_only: Only consider classical methods fusion_method: 'svd', 'concatenate', 'average' target_dim: Target dimension for fused embedding Returns: Fused embedding from best methods across types """ method_types = ['sgns', 'filter', 'gat', 'graphgps'] best_methods = [] for mtype in method_types: # Get methods of this type type_methods = _filter_methods_by_type( list(embeddings_dict.keys()), mtype, quantum_only, classical_only ) # Find best-performing method if len(type_methods) > 0: best_method = max( type_methods, key=lambda m: performance_scores.get(m, 0.0) ) best_methods.append(best_method) if len(best_methods) == 0: raise ValueError("No methods found to fuse") # Fuse best methods best_embeddings = [embeddings_dict[m] for m in best_methods] if fusion_method == 'svd': return _fuse_via_svd(best_embeddings, target_dim) elif fusion_method == 'concatenate': return np.concatenate(best_embeddings, axis=1) elif fusion_method == 'average': return np.mean(best_embeddings, axis=0) else: raise ValueError(f"Unknown fusion method: {fusion_method}")
[docs] def hierarchical_fusion(embeddings_dict, performance_scores, target_dim=None): """ Perform hierarchical fusion strategy for all 39 methods. Strategy: 1. Within-type fusion: - Fuse quantum methods per type → fused_quantum_{type} - Fuse classical methods per type → fused_classical_{type} 2. Cross-type fusion: - Select best quantum method per type - Select best classical method per type - Fuse best quantum methods → fused_q - Fuse best classical methods → fused_c Args: embeddings_dict: {method_name: embedding_array} performance_scores: {method_name: score} target_dim: Target dimension for fused embeddings Returns: dict: Fused embeddings, under these keys - 'fused_quantum_sgns', 'fused_classical_sgns' - 'fused_quantum_filter', 'fused_classical_filter' - 'fused_quantum_gat', 'fused_classical_gat' - 'fused_quantum_graphgps', 'fused_classical_graphgps' - 'fused_q' (best quantum across types) - 'fused_c' (best classical across types) """ fused_embeddings = {} method_types = ['sgns', 'filter', 'gat', 'graphgps'] # Step 1: Within-type fusion for mtype in method_types: # Quantum fusion try: fused_q = fuse_by_method_type( embeddings_dict, mtype, quantum_only=True, fusion_method='svd', target_dim=target_dim ) fused_embeddings[f'fused_quantum_{mtype}'] = fused_q except ValueError: pass # No quantum methods for this type # Classical fusion try: fused_c = fuse_by_method_type( embeddings_dict, mtype, classical_only=True, fusion_method='svd', target_dim=target_dim ) fused_embeddings[f'fused_classical_{mtype}'] = fused_c except ValueError: pass # No classical methods for this type # Step 2: Cross-type fusion try: fused_q = fuse_best_across_types( embeddings_dict, performance_scores, quantum_only=True, fusion_method='svd', target_dim=target_dim ) fused_embeddings['fused_q'] = fused_q except ValueError: pass try: fused_c = fuse_best_across_types( embeddings_dict, performance_scores, classical_only=True, fusion_method='svd', target_dim=target_dim ) fused_embeddings['fused_c'] = fused_c except ValueError: pass return fused_embeddings