Головна▸Статті▸Graph ML

Класифікація вузлів графа

Graph neural networks для класифікації вузлів - GNN архітектури, node embeddings, transductive vs inductive навчання та продакшен.

mysimulator teamОновлено 2026-01≈ 6 хв читання▶ Open the simulation

Класифікація вузлів прогнозує мітки для сутностей у графі, використовуючи їхні зв'язки. На відміну від табличного ML, де кожен зразок незалежний, графовий ML експлуатує реляційну структуру - друзі користувача, цитування статті, зв'язки молекули. Коли відносини важливі, графи часто перевершують підходи тільки на фічах.

Де використовується

Класифікація вузлів забезпечує мережеві застосування:

Продукти: Pinterest PinSage, LinkedIn graph ML, Google Knowledge Graph.

Типові дані

Компонент Опис Приклади
Вузли Сутності для класифікації Користувачі, продукти, статті
Ребра Відносини Follows, покупки, цитує
Фічі вузлів Атрибути сутностей Профіль, текстові ембедінги
Фічі ребер Атрибути відносин Часові мітки, ваги, типи
Мітки Категорії вузлів Бот/людина, тема, роль

Що ви робите на практиці

  1. Побудуйте граф - Визначте вузли, ребра, фічі
  2. Проаналізуйте структуру графа - Щільність, розподіл ступенів, компоненти
  3. Обробіть дисбаланс міток - Часто суворий у класифікації вузлів
  4. Оберіть transductive vs inductive - Фіксований граф чи нові вузли?
  5. Виберіть GNN архітектуру - GCN, GAT, GraphSAGE
  6. Запобігайте витоку міток - Обережно з message passing
  7. Оцінюйте правильно - Train/val/test розбивка вузлів
  8. Масштабуйте для продакшену - Mini-batching, sampling

Спочатку базова лінія

Почніть з простих підходів перед GNN:

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
import networkx as nx

class NodeClassificationBaseline:
    """Прості базові лінії для класифікації вузлів."""

    def __init__(self, graph, features, labels):
        self.graph = graph
        self.features = features
        self.labels = labels

    def feature_only_baseline(self, train_mask, test_mask):
        """Класифікація тільки за фічами вузлів (без графа)."""
        scaler = StandardScaler()
        X_train = scaler.fit_transform(self.features[train_mask])
        X_test = scaler.transform(self.features[test_mask])

        model = LogisticRegression(max_iter=1000)
        model.fit(X_train, self.labels[train_mask])

        predictions = model.predict(X_test)
        return predictions

    def label_propagation(self, train_mask, max_iter=100, alpha=0.8):
        """Напівкероване поширення міток."""
        n_nodes = len(self.labels)
        n_classes = len(np.unique(self.labels[self.labels >= 0]))

        # Ініціалізація матриці міток
        Y = np.zeros((n_nodes, n_classes))
        for i in np.where(train_mask)[0]:
            Y[i, self.labels[i]] = 1

        # Нормалізована матриця суміжності
        A = nx.adjacency_matrix(self.graph).toarray()
        D = np.diag(A.sum(axis=1) + 1e-10)
        D_inv_sqrt = np.diag(1.0 / np.sqrt(np.diag(D)))
        A_norm = D_inv_sqrt @ A @ D_inv_sqrt

        # Поширення
        Y_init = Y.copy()
        for _ in range(max_iter):
            Y = alpha * A_norm @ Y + (1 - alpha) * Y_init

        predictions = Y.argmax(axis=1)
        return predictions

    def node2vec_baseline(self, train_mask, test_mask, dimensions=64):
        """Node2Vec ембедінги + класифікатор."""
        from node2vec import Node2Vec

        # Генерація ембедінгів
        node2vec = Node2Vec(self.graph, dimensions=dimensions,
                            walk_length=30, num_walks=200, workers=4)
        model = node2vec.fit(window=10, min_count=1)

        # Отримання ембедінгів для всіх вузлів
        embeddings = np.array([model.wv[str(i)] 
                              for i in range(len(self.labels))])

        # Комбінуємо з фічами
        X = np.hstack([self.features, embeddings])

        # Навчаємо класифікатор
        scaler = StandardScaler()
        X_train = scaler.fit_transform(X[train_mask])
        X_test = scaler.transform(X[test_mask])

        clf = LogisticRegression(max_iter=1000)
        clf.fit(X_train, self.labels[train_mask])

        return clf.predict(X_test)

Чому базові лінії важливі: - Feature-only показує цінність структури графа - Label propagation - сильна напівкерована базова лінія - Node2Vec захоплює структурну інформацію - GNN повинні значимо перевершувати їх

GNN реалізація

Graph Neural Networks для класифікації вузлів:

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GCNConv, GATConv, SAGEConv

class GCN(nn.Module):
    """Graph Convolutional Network."""

    def __init__(self, in_channels, hidden_channels, out_channels, 
                 num_layers=2, dropout=0.5):
        super().__init__()
        self.convs = nn.ModuleList()
        self.convs.append(GCNConv(in_channels, hidden_channels))

        for _ in range(num_layers - 2):
            self.convs.append(GCNConv(hidden_channels, hidden_channels))

        self.convs.append(GCNConv(hidden_channels, out_channels))
        self.dropout = dropout

    def forward(self, x, edge_index):
        for i, conv in enumerate(self.convs[:-1]):
            x = conv(x, edge_index)
            x = F.relu(x)
            x = F.dropout(x, p=self.dropout, training=self.training)

        x = self.convs-1
        return x

class GraphSAGE(nn.Module):
    """GraphSAGE для індуктивного навчання."""

    def __init__(self, in_channels, hidden_channels, out_channels,
                 num_layers=2, dropout=0.5):
        super().__init__()
        self.convs = nn.ModuleList()
        self.convs.append(SAGEConv(in_channels, hidden_channels))

        for _ in range(num_layers - 2):
            self.convs.append(SAGEConv(hidden_channels, hidden_channels))

        self.convs.append(SAGEConv(hidden_channels, out_channels))
        self.dropout = dropout

    def forward(self, x, edge_index):
        for i, conv in enumerate(self.convs[:-1]):
            x = conv(x, edge_index)
            x = F.relu(x)
            x = F.dropout(x, p=self.dropout, training=self.training)

        x = self.convs-1
        return x

def train_gnn(model, data, optimizer, epochs=200):
    """Цикл навчання для класифікації вузлів."""
    model.train()

    for epoch in range(epochs):
        optimizer.zero_grad()

        out = model(data.x, data.edge_index)
        loss = F.cross_entropy(out[data.train_mask], 
                               data.y[data.train_mask])

        loss.backward()
        optimizer.step()

        # Валідація
        if epoch % 10 == 0:
            model.eval()
            with torch.no_grad():
                pred = model(data.x, data.edge_index).argmax(dim=1)
                val_acc = (pred[data.val_mask] == data.y[data.val_mask]).float().mean()
            model.train()
            print(f'Epoch {epoch}: Loss {loss:.4f}, Val Acc {val_acc:.4f}')

    return model

Обробка великих графів

Mini-batching для масштабованості:

from torch_geometric.loader import NeighborLoader

class ScalableNodeClassification:
    """Обробка великих графів з семплінгом."""

    def __init__(self, data, num_neighbors=[25, 10], batch_size=1024):
        self.data = data
        self.num_neighbors = num_neighbors
        self.batch_size = batch_size

    def get_loader(self, mask, shuffle=True):
        """Створення loader з семплінгом сусідів."""
        return NeighborLoader(
            self.data,
            num_neighbors=self.num_neighbors,
            batch_size=self.batch_size,
            input_nodes=mask,
            shuffle=shuffle
        )

    def train_epoch(self, model, loader, optimizer):
        """Навчання однієї епохи з mini-batching."""
        model.train()
        total_loss = 0

        for batch in loader:
            optimizer.zero_grad()

            out = model(batch.x, batch.edge_index)
            # Обчислюємо loss тільки на цільових вузлах
            loss = F.cross_entropy(out[:batch.batch_size], 
                                   batch.y[:batch.batch_size])

            loss.backward()
            optimizer.step()

            total_loss += loss.item() * batch.batch_size

        return total_loss / self.data.train_mask.sum()

Transductive vs Inductive

class TransductiveVsInductive:
    """Правильна обробка обох налаштувань."""

    def transductive_split(self, data, train_ratio=0.6, val_ratio=0.2):
        """
        Transductive: Всі вузли в графі під час навчання,
        але тільки деякі мають мітки.
        """
        n = data.num_nodes
        perm = torch.randperm(n)

        train_size = int(n * train_ratio)
        val_size = int(n * val_ratio)

        train_mask = torch.zeros(n, dtype=torch.bool)
        val_mask = torch.zeros(n, dtype=torch.bool)
        test_mask = torch.zeros(n, dtype=torch.bool)

        train_mask[perm[:train_size]] = True
        val_mask[perm[train_size:train_size+val_size]] = True
        test_mask[perm[train_size+val_size:]] = True

        return train_mask, val_mask, test_mask

    def inductive_split(self, data, train_ratio=0.6, val_ratio=0.2):
        """
        Inductive: Тестові вузли повністю невидимі під час навчання.
        Потрібно видалити тестові вузли з тренувального графа.
        """
        n = data.num_nodes
        perm = torch.randperm(n)

        train_size = int(n * train_ratio)
        val_size = int(n * val_ratio)

        train_nodes = perm[:train_size]
        val_nodes = perm[train_size:train_size+val_size]
        test_nodes = perm[train_size+val_size:]

        # Створюємо тренувальний підграф (без тестових вузлів)
        train_val_nodes = torch.cat([train_nodes, val_nodes])
        train_graph = self.subgraph(data, train_val_nodes)

        return {
            'train_graph': train_graph,
            'train_nodes': train_nodes,
            'val_nodes': val_nodes,
            'test_nodes': test_nodes,
            'full_graph': data
        }

Метрики оцінки

Метрика Коли використовувати Примітки
Accuracy Збалансовані класи Просто, але оманливо якщо дисбаланс
Macro F1 Дисбаланс, важливі всі класи Середній F1 по класах
Micro F1 Дисбаланс, важливий загальний Зважений за частотою класів
AUC-ROC Бінарний або one-vs-rest Незалежний від порогу

Типові помилки та чек-лист

Чек-лист для продакшену

Перед деплоєм: - [ ] Пайплайн побудови графа валідовано - [ ] Індуктивне навчання якщо очікуються нові вузли - [ ] Mini-batching для великих графів - [ ] Препроцесинг фіч стандартизовано - [ ] Порівняння з базовою лінією задокументовано

Після деплою: - [ ] Обробка нових вузлів протестована - [ ] Частота оновлення графа визначена - [ ] Латентність прогнозів моніториться - [ ] Обробка edge cases (ізольовані вузли) - [ ] Розклад перенавчання моделі

Інструменти та стек

Категорія Інструменти
Фреймворки PyTorch Geometric, DGL, Spektral
Графові БД Neo4j, Amazon Neptune, TigerGraph
Ембедінги Node2Vec, DeepWalk, LINE
Візуалізація NetworkX, Gephi, Graphistry
Масштабування DistDGL, PyG distributed

Приклад міні-кейсу

Сценарій: Виявлення ботів у соціальній мережі.

Побудова графа: - Вузли: Акаунти користувачів - Ребра: Follows, mentions, retweets - Фічі: Вік акаунту, частота постів, текстові ембедінги - Мітки: Бот/людина (маленький розмічений набір)

Підхід: 1. Побудовано граф follows з 10M вузлів 2. Напівкероване з 5% розміченими 3. GraphSAGE для індуктивного навчання (нові акаунти) 4. Neighbor sampling для масштабованості

Результати: - 94% F1 на виявленні ботів (vs 78% тільки фічі) - Виловлює координовані бот-мережі - 50ms інференс на новий акаунт - Перенавчається щотижня на нових мітках

Пов'язані сторінки

Додаткові матеріали

Спробуйте наживо

Дослідіть пов'язану модель на практиці: відкрийте Graph Attention Network: Node Classification і змінюйте параметри під час роботи. Усе лишається у вашому браузері.

▶ Відкрити симуляцію Graph Attention Network: Node Classification

Що ви знайшли?

Додати кроки відтворення (опційно)