Kappa Gcn

manify.predictors.kappa_gcn

\(\kappa\)-GCN implementation.

KappaGCN(pm, output_dim, num_hidden=2, nonlinearity=torch.relu, task='classification', random_state=None, device=None)

Bases: BasePredictor, Module

Implementation for the Kappa GCN.

Attributes:
  • pm

    ProductManifold object for the Kappa GCN.

  • output_dim

    Number of output features.

  • num_hidden

    Number of hidden layers.

  • nonlinearity

    Function for nonlinear activation.

  • task

    Task type, one of ["classification", "regression", "link_prediction"]

  • random_state

    Random seed for reproducibility.

  • device

    Device to run the model on (default: None, uses current device).

  • is_fitted_ (bool) –

    Whether the model has been fitted.

  • loss_history_ (dict[str, list[float]]) –

    History of loss values during training.

Parameters:
  • pm (ProductManifold) –

    ProductManifold object for the Kappa GCN

  • output_dim (int) –

    Number of output features

  • num_hidden (int, default: 2 ) –

    Number of hidden layers.

  • nonlinearity (Callable, default: relu ) –

    Function for nonlinear activation.

  • task (Literal['classification', 'regression', 'link_prediction'], default: 'classification' ) –

    Task type, one of ["classification", "regression", "link_prediction"].

  • random_state (int | None, default: None ) –

    Random seed for reproducibility.

  • device (str | None, default: None ) –

    Device to run the model on (default: None, uses current device).

Raises:
  • ValueError

    If the ProductManifold is not stereographic.

Source code in manify/predictors/kappa_gcn.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def __init__(
    self,
    pm: ProductManifold,
    output_dim: int,
    num_hidden: int = 2,
    nonlinearity: Callable = torch.relu,
    task: Literal["classification", "regression", "link_prediction"] = "classification",
    random_state: int | None = None,
    device: str | None = None,
):
    BasePredictor.__init__(self, pm=pm, task=task, random_state=random_state, device=device)
    torch.nn.Module.__init__(self)

    self.pm = pm
    self.task = task
    self.output_dim = output_dim
    self.num_hidden = num_hidden
    self.nonlinearity = nonlinearity

    # Ensure pm is stereographic
    if not pm.is_stereographic:
        raise ValueError(
            "ProductManifold must be stereographic for KappaGCN to work.Please use pm.stereographic() to convert."
        )

    # Build layer dimensions
    dims = [pm.dim] + [pm.dim] * num_hidden

    # Build the main GCN layers using Sequential
    gcn_layers = []
    for i in range(len(dims) - 1):
        gcn_layers.append(KappaGCNLayer(dims[i], dims[i + 1], pm, nonlinearity))

    self.gcn_layers = KappaSequential(*gcn_layers)

    # Task-specific output layers - much cleaner now!
    if task == "link_prediction":
        self.output_layer = FermiDiracDecoder(pm, learnable_params=True)
    else:
        # This is the same for classification/regression since we apply softmax in the loss function, not here
        self.output_layer = StereographicLogits(output_dim, pm, apply_softmax=False)

forward(X, A_hat=None, aggregate_logits=True, softmax=False)

Forward pass through the GCN layers and output head.

Source code in manify/predictors/kappa_gcn.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def forward(
    self,
    X: Float[torch.Tensor, "n_nodes dim"],
    A_hat: Float[torch.Tensor, "n_nodes n_nodes"] | None = None,
    aggregate_logits: bool = True,
    softmax: bool = False,
) -> (
    Float[torch.Tensor, "n_nodes n_classes"]
    | Float[torch.Tensor, "n_nodes"]
    | Float[torch.Tensor, "n_nodes n_nodes"]
):
    """Forward pass through the GCN layers and output head."""
    # Pass through main GCN layers
    H = self.gcn_layers(X, A_hat)

    # Task-specific output using the specialized layers
    if self.task == "link_prediction":
        return self.output_layer(H)  # Flattened for link prediction
    else:
        # For classification/regression, use stereographic logits
        logits = self.output_layer(H, A_hat, aggregate_logits=aggregate_logits)

        if softmax:
            logits = torch.softmax(logits, dim=-1)

        return logits.squeeze()

fit(X, y, A=None, epochs=2000, lr=0.01, use_tqdm=True, tqdm_prefix=None)

Fit the Kappa GCN model.

Parameters:
  • X (Tensor) –

    Feature matrix.

  • y (Tensor) –

    Labels for training nodes.

  • A (Tensor, default: None ) –

    Adjacency or distance matrix.

  • epochs (int, default: 2000 ) –

    Number of training epochs (default=200).

  • lr (float, default: 0.01 ) –

    Learning rate (default=1e-2).

  • use_tqdm (bool, default: True ) –

    Whether to use tqdm for progress bar.

  • tqdm_prefix (str | None, default: None ) –

    Prefix for tqdm progress bar.

Source code in manify/predictors/kappa_gcn.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def fit(
    self,
    X: Float[torch.Tensor, "n_nodes dim"],
    y: Real[torch.Tensor, "n_nodes"],
    A: Float[torch.Tensor, "n_nodes n_nodes"] | None = None,
    epochs: int = 2_000,
    lr: float = 1e-2,
    use_tqdm: bool = True,
    tqdm_prefix: str | None = None,
) -> KappaGCN:
    """Fit the Kappa GCN model.

    Args:
        X (torch.Tensor): Feature matrix.
        y (torch.Tensor): Labels for training nodes.
        A (torch.Tensor): Adjacency or distance matrix.
        epochs: Number of training epochs (default=200).
        lr: Learning rate (default=1e-2).
        use_tqdm: Whether to use tqdm for progress bar.
        tqdm_prefix: Prefix for tqdm progress bar.
    """
    # Copy everything
    X = X.clone()
    y = y.clone()
    A = A.clone() if A is not None else None

    # Convert A to A_hat
    A_hat = get_A_hat(A, make_symmetric=True, add_self_loops=True) if A is not None else None

    # Collect all paramters
    euclidean_params = []
    riemannian_params = []
    for layer in self.gcn_layers.layers:
        euclidean_params.append(layer.W)
    if self.task == "link_prediction":
        euclidean_params += [self.output_layer.temperature, self.output_layer.bias]
    else:
        euclidean_params += [self.output_layer.W]
        riemannian_params += [self.output_layer.p_ks]

    # Optimizers
    opt = torch.optim.Adam(euclidean_params, lr=lr)
    ropt = geoopt.optim.RiemannianAdam(riemannian_params, lr=lr) if riemannian_params else None

    if self.task == "classification":
        loss_fn = torch.nn.CrossEntropyLoss()
        y = y.long()
    elif self.task == "regression":
        loss_fn = torch.nn.MSELoss()
        y = y.float()
    elif self.task == "link_prediction":
        loss_fn = torch.nn.BCEWithLogitsLoss()
        # y = y.flatten().float()
        y = y.float()
    else:
        raise ValueError("Invalid task!")

    self.train()
    if use_tqdm:
        my_tqdm = tqdm(total=epochs, desc=tqdm_prefix)

    losses = []
    for i in range(epochs):
        opt.zero_grad()
        if riemannian_params:
            ropt.zero_grad()  # type: ignore
        y_pred = self(X, A_hat)
        loss = loss_fn(y_pred, y)
        loss.backward()
        opt.step()
        if riemannian_params:
            ropt.step()  # type: ignore

        # Progress bar
        if use_tqdm:
            my_tqdm.update(1)
            my_tqdm.set_description(f"Epoch {i + 1}/{epochs}, Loss: {loss.item():.4f}")

        # Early termination for nan loss
        if torch.isnan(loss):
            print("Loss is NaN, stopping training.")
            break
        losses.append(loss.item())

    if use_tqdm:
        my_tqdm.close()

    self.is_fitted_ = True
    self.loss_history_["train"] = losses
    return self

predict_proba(X, A=None)

Predict class probabilities using the trained Kappa GCN.

Parameters:
  • X (Tensor) –

    Feature matrix (NxD).

  • A (Tensor, default: None ) –

    Adjacency or distance matrix (NxN).

Returns:
  • Real[Tensor, 'n_nodes n_classes'] | Real[Tensor, 'n_nodes']

    torch.Tensor: Predicted class probabilities / regression targets.

Source code in manify/predictors/kappa_gcn.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def predict_proba(
    self, X: Float[torch.Tensor, "n_nodes dim"], A: Float[torch.Tensor, "n_nodes n_nodes"] | None = None
) -> Real[torch.Tensor, "n_nodes n_classes"] | Real[torch.Tensor, "n_nodes"]:
    """Predict class probabilities using the trained Kappa GCN.

    Args:
        X (torch.Tensor): Feature matrix (NxD).
        A (torch.Tensor): Adjacency or distance matrix (NxN).

    Returns:
        torch.Tensor: Predicted class probabilities / regression targets.
    """
    # Copy everything
    X = X.clone()
    A = A.clone() if A is not None else None
    A_hat = get_A_hat(A, make_symmetric=True, add_self_loops=True) if A is not None else None

    # Get edges for test set
    self.eval()
    y_pred = self(X, A_hat)
    return y_pred

get_A_hat(A, make_symmetric=True, add_self_loops=True)

Normalize adjacency matrix.

Parameters:
  • A (Float[Tensor, 'n_nodes n_nodes']) –

    Adjacency matrix.

  • make_symmetric (bool, default: True ) –

    Whether to make the adjacency matrix symmetric.

  • add_self_loops (bool, default: True ) –

    Whether to add self-loops to the adjacency matrix.

Returns:
  • A_hat( Float[Tensor, 'n_nodes n_nodes'] ) –

    Normalized adjacency matrix.

Source code in manify/predictors/kappa_gcn.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def get_A_hat(
    A: Float[torch.Tensor, "n_nodes n_nodes"], make_symmetric: bool = True, add_self_loops: bool = True
) -> Float[torch.Tensor, "n_nodes n_nodes"]:
    """Normalize adjacency matrix.

    Args:
        A: Adjacency matrix.
        make_symmetric: Whether to make the adjacency matrix symmetric.
        add_self_loops: Whether to add self-loops to the adjacency matrix.

    Returns:
        A_hat: Normalized adjacency matrix.
    """
    # Fix nans
    A[torch.isnan(A)] = 0

    # Optional steps to make symmetric and add self-loops
    if make_symmetric and not torch.allclose(A, A.T):
        A = A + A.T
    if add_self_loops and not torch.allclose(torch.diag(A), torch.ones(A.shape[0], dtype=A.dtype, device=A.device)):
        A = A + torch.eye(A.shape[0], device=A.device, dtype=A.dtype)

    # Get degree matrix
    D = torch.diag(torch.sum(A, axis=1))

    # Compute D^(-1/2)
    D_inv_sqrt = torch.inverse(torch.sqrt(D))

    # Normalize adjacency matrix
    A_hat = D_inv_sqrt @ A @ D_inv_sqrt

    return A_hat.detach()