Уявіть, що ви хочете зрозуміти місто. Традиційний підхід: зібрати всі будинки в блендер, зробити середнє — і отримати "середній будинок". Абсурд? Саме так працювала традиційна геноміка десятиліттями.
Тканина — це не суміш клітин. Це архітектура. Імунна клітина поруч з пухлинною — це зовсім інша історія, ніж та сама клітина за мікрон. Нейрони формують circuits саме через свої з'єднання. Tumor microenvironment визначає відповідь на терапію більше, ніж мутації самої пухлини.
Spatial omics революціонізує біологію, додаючи просторову координату до молекулярних даних. А deep learning робить ці дані interpretable. Це frontier computational biology — і один з найперспективніших напрямків для дипломних та магістерських робіт.
Від Bulk до Spatial: еволюція омік
Традиційний підхід (Bulk Omics):
Тканина → Гомогенізація → Bulk measurement
Результат: Середнє по мільйонах клітин
Втрачено: Просторовий контекст, клітинна гетерогенність
Single-cell підхід:
Тканина → Дисоціація → Секвенування окремих клітин
Результат: Профіль кожної клітини
Втрачено: Просторове розташування
Spatial підхід:
Тканина → Imaging in situ → Вимірювання з координатами
Результат: Gene expression + XY координати
Збережено: Клітинні neighborhoods, tissue structure
Порівняння підходів:
| Feature | Bulk | Single-cell | Spatial |
|---------|------|-------------|---------|
| Resolution | Tissue average | Single cell | Single cell + location |
| Genes measured | Whole transcriptome | Whole transcriptome | 100-10,000+ |
| Spatial context | Lost | Lost | Preserved |
| Sample size | 1 value per sample | ~10K cells | ~100K-1M cells |
| Cost per sample | $100-500 | $5,000-20,000 | $10,000-50,000 |
Технології Spatial Omics
1. Spot-based технології (Visium, 10x Genomics)
import scanpy as sc
import squidpy as sq
import numpy as np
class VisiumAnalyzer:
"""Аналіз Visium Spatial Transcriptomics"""
def __init__(self, adata_path: str):
# Завантаження AnnData об'єкта
self.adata = sc.read_h5ad(adata_path)
def preprocess(self):
"""Стандартний preprocessing pipeline"""
# QC фільтрація
sc.pp.filter_cells(self.adata, min_genes=200)
sc.pp.filter_genes(self.adata, min_cells=10)
# Мітохондріальні гени (маркер якості)
self.adata.var['mt'] = self.adata.var_names.str.startswith('MT-')
sc.pp.calculate_qc_metrics(
self.adata,
qc_vars=['mt'],
percent_top=None,
inplace=True
)
# Нормалізація
sc.pp.normalize_total(self.adata, target_sum=1e4)
sc.pp.log1p(self.adata)
# Highly variable genes
sc.pp.highly_variable_genes(
self.adata,
n_top_genes=2000,
flavor='seurat_v3'
)
return self.adata
def analyze_neighborhoods(self, n_neighbors: int = 6):
"""Аналіз просторових neighborhoods"""
# Побудова spatial graph
sq.gr.spatial_neighbors(
self.adata,
n_neighs=n_neighbors,
coord_type='generic'
)
# Neighborhood enrichment
sq.gr.nhood_enrichment(
self.adata,
cluster_key='cluster'
)
# Interaction matrix
sq.gr.interaction_matrix(
self.adata,
cluster_key='cluster'
)
return self.adata
def find_spatial_patterns(self):
"""Пошук просторово-варіабельних генів"""
# Moran's I для spatial autocorrelation
sq.gr.spatial_autocorr(
self.adata,
mode='moran',
genes=self.adata.var_names[:500] # Top 500 genes
)
# Сортування за spatial variation
spatial_genes = self.adata.var.sort_values(
'moranI',
ascending=False
)
return spatial_genes
2. Single-cell resolution (MERFISH, Xenium, CosMx)
import numpy as np
from scipy.spatial import Delaunay, cKDTree
class SingleCellSpatialAnalyzer:
"""Аналіз single-cell resolution spatial data"""
def __init__(self, expression_matrix: np.ndarray,
coordinates: np.ndarray,
gene_names: list):
self.expression = expression_matrix # cells x genes
self.coords = coordinates # cells x 2 (x, y)
self.genes = gene_names
self.n_cells = expression_matrix.shape[0]
def segment_cells(self, nuclei_image: np.ndarray,
membrane_image: np.ndarray = None):
"""Сегментація клітин з зображень"""
from cellpose import models
# Cellpose для сегментації
model = models.Cellpose(model_type='cyto2')
# Якщо є membrane image - використовуємо обидва канали
if membrane_image is not None:
images = np.stack([membrane_image, nuclei_image], axis=-1)
channels = [1, 2] # membrane, nuclei
else:
images = nuclei_image
channels = [0, 0] # grayscale
masks, flows, styles, diams = model.eval(
images,
diameter=None, # auto-detect
channels=channels,
flow_threshold=0.4,
cellprob_threshold=0
)
return masks
def build_spatial_graph(self, method: str = 'delaunay',
k: int = 10,
radius: float = None):
"""Побудова просторового графа"""
if method == 'delaunay':
# Delaunay triangulation
tri = Delaunay(self.coords)
edges = set()
for simplex in tri.simplices:
for i in range(3):
for j in range(i + 1, 3):
edges.add((simplex[i], simplex[j]))
edges.add((simplex[j], simplex[i]))
return list(edges)
elif method == 'knn':
# k-nearest neighbors
tree = cKDTree(self.coords)
edges = []
for i in range(self.n_cells):
distances, indices = tree.query(self.coords[i], k=k+1)
for j in indices[1:]: # skip self
edges.append((i, j))
return edges
elif method == 'radius':
# Radius-based
tree = cKDTree(self.coords)
edges = []
for i in range(self.n_cells):
indices = tree.query_ball_point(self.coords[i], radius)
for j in indices:
if i != j:
edges.append((i, j))
return edges
def calculate_cell_interactions(self, cell_types: np.ndarray):
"""Розрахунок cell-cell interactions"""
edges = self.build_spatial_graph(method='knn', k=6)
# Count interactions between cell types
unique_types = np.unique(cell_types)
n_types = len(unique_types)
interaction_matrix = np.zeros((n_types, n_types))
type_to_idx = {t: i for i, t in enumerate(unique_types)}
for i, j in edges:
type_i = type_to_idx[cell_types[i]]
type_j = type_to_idx[cell_types[j]]
interaction_matrix[type_i, type_j] += 1
# Normalize
row_sums = interaction_matrix.sum(axis=1, keepdims=True)
interaction_matrix = interaction_matrix / (row_sums + 1e-10)
return interaction_matrix, unique_types
3. Imaging Mass Cytometry (IMC)
class IMCAnalyzer:
"""Аналіз Imaging Mass Cytometry data"""
def __init__(self, intensity_matrix: np.ndarray,
coordinates: np.ndarray,
marker_names: list):
self.intensities = intensity_matrix # cells x markers
self.coords = coordinates
self.markers = marker_names
def phenotype_cells(self, marker_thresholds: dict = None):
"""Фенотипування клітин за маркерами"""
from sklearn.mixture import GaussianMixture
phenotypes = np.zeros(self.intensities.shape[0], dtype=object)
if marker_thresholds is None:
# Автоматичне визначення порогів через GMM
marker_thresholds = {}
for i, marker in enumerate(self.markers):
gmm = GaussianMixture(n_components=2, random_state=42)
gmm.fit(self.intensities[:, i].reshape(-1, 1))
threshold = np.mean(gmm.means_)
marker_thresholds[marker] = threshold
# Phenotype assignment (simplified)
for idx in range(self.intensities.shape[0]):
positive_markers = []
for i, marker in enumerate(self.markers):
if self.intensities[idx, i] > marker_thresholds[marker]:
positive_markers.append(marker)
phenotypes[idx] = self._assign_cell_type(positive_markers)
return phenotypes
def _assign_cell_type(self, positive_markers: list) -> str:
"""Правила для визначення типу клітини"""
# Simplified rules for immune cells
if 'CD3' in positive_markers:
if 'CD8' in positive_markers:
return 'CD8+ T cell'
elif 'CD4' in positive_markers:
return 'CD4+ T cell'
return 'T cell'
elif 'CD20' in positive_markers:
return 'B cell'
elif 'CD68' in positive_markers:
return 'Macrophage'
elif 'CD56' in positive_markers:
return 'NK cell'
else:
return 'Other'
Graph Neural Networks для Spatial Data
Клітини — це nodes. Spatial proximity — edges. Gene expression — node features. Cell-cell interactions — message passing. GNN — ідеальна архітектура.
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch_geometric as pyg
from torch_geometric.nn import GATConv, GCNConv, SAGEConv
class SpatialGNN(nn.Module):
"""Graph Neural Network для spatial omics"""
def __init__(self, in_channels: int, hidden_channels: int,
out_channels: int, num_layers: int = 3,
conv_type: str = 'GAT', heads: int = 4,
dropout: float = 0.5):
super().__init__()
self.convs = nn.ModuleList()
self.bns = nn.ModuleList()
self.dropout = dropout
# Input layer
if conv_type == 'GAT':
self.convs.append(GATConv(in_channels, hidden_channels, heads=heads))
first_out = hidden_channels * heads
elif conv_type == 'GCN':
self.convs.append(GCNConv(in_channels, hidden_channels))
first_out = hidden_channels
elif conv_type == 'SAGE':
self.convs.append(SAGEConv(in_channels, hidden_channels))
first_out = hidden_channels
self.bns.append(nn.BatchNorm1d(first_out))
# Hidden layers
for _ in range(num_layers - 2):
if conv_type == 'GAT':
self.convs.append(GATConv(first_out, hidden_channels, heads=heads))
elif conv_type == 'GCN':
self.convs.append(GCNConv(first_out, hidden_channels))
elif conv_type == 'SAGE':
self.convs.append(SAGEConv(first_out, hidden_channels))
self.bns.append(nn.BatchNorm1d(first_out))
# Output layer
if conv_type == 'GAT':
self.convs.append(GATConv(first_out, out_channels, heads=1, concat=False))
else:
self.convs.append(GCNConv(first_out, out_channels) if conv_type == 'GCN'
else SAGEConv(first_out, out_channels))
def forward(self, x: torch.Tensor, edge_index: torch.Tensor) -> torch.Tensor:
for i, conv in enumerate(self.convs[:-1]):
x = conv(x, edge_index)
x = self.bns[i](x)
x = F.elu(x)
x = F.dropout(x, p=self.dropout, training=self.training)
x = self.convs[-1](x, edge_index)
return x
class SpatialDomainIdentifier(nn.Module):
"""Ідентифікація spatial domains через GNN clustering"""
def __init__(self, in_dim: int, hidden_dim: int, n_domains: int):
super().__init__()
self.encoder = SpatialGNN(
in_channels=in_dim,
hidden_channels=hidden_dim,
out_channels=hidden_dim,
num_layers=3,
conv_type='GAT'
)
# Clustering head
self.cluster_head = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, n_domains)
)
# Reconstruction head (for unsupervised learning)
self.decoder = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, in_dim)
)
def forward(self, x: torch.Tensor, edge_index: torch.Tensor):
# Encode
z = self.encoder(x, edge_index)
# Cluster assignment
cluster_logits = self.cluster_head(z)
# Reconstruction
x_recon = self.decoder(z)
return z, cluster_logits, x_recon
def loss_function(self, x: torch.Tensor, x_recon: torch.Tensor,
cluster_logits: torch.Tensor, z: torch.Tensor,
edge_index: torch.Tensor):
# Reconstruction loss
recon_loss = F.mse_loss(x_recon, x)
# Clustering loss (entropy regularization)
cluster_probs = F.softmax(cluster_logits, dim=-1)
entropy = -(cluster_probs * torch.log(cluster_probs + 1e-10)).sum(dim=-1).mean()
# Spatial smoothness loss
src, dst = edge_index
neighbor_sim = F.cosine_similarity(z[src], z[dst])
smoothness_loss = 1 - neighbor_sim.mean()
total_loss = recon_loss + 0.1 * entropy + 0.5 * smoothness_loss
return total_loss, {
'recon': recon_loss.item(),
'entropy': entropy.item(),
'smoothness': smoothness_loss.item()
}
Spatial Domain Detection: SpaGCN та альтернативи
class SpaGCNModel(nn.Module):
"""Імплементація SpaGCN для spatial domain detection"""
def __init__(self, in_features: int, hidden_features: int,
out_features: int, alpha: float = 1.0):
super().__init__()
self.alpha = alpha # Spatial weight
self.gc1 = pyg.nn.GCNConv(in_features, hidden_features)
self.gc2 = pyg.nn.GCNConv(hidden_features, out_features)
self.cluster_layer = nn.Parameter(torch.Tensor(out_features, out_features))
nn.init.xavier_normal_(self.cluster_layer)
def forward(self, x: torch.Tensor, edge_index: torch.Tensor,
edge_weight: torch.Tensor = None):
# GCN layers
x = F.relu(self.gc1(x, edge_index, edge_weight))
x = F.dropout(x, p=0.5, training=self.training)
z = self.gc2(x, edge_index, edge_weight)
# Soft clustering
q = self._compute_q(z)
return z, q
def _compute_q(self, z: torch.Tensor) -> torch.Tensor:
"""Student's t-distribution for soft assignment"""
# q_ij = (1 + ||z_i - μ_j||^2)^(-1) / Σ_k (1 + ||z_i - μ_k||^2)^(-1)
dist = torch.cdist(z, self.cluster_layer)
q = 1.0 / (1.0 + dist ** 2)
q = q / q.sum(dim=1, keepdim=True)
return q
@staticmethod
def target_distribution(q: torch.Tensor) -> torch.Tensor:
"""Auxiliary target distribution"""
weight = q ** 2 / q.sum(dim=0)
return (weight.T / weight.sum(dim=1)).T
def build_spatial_graph_with_weights(coordinates: np.ndarray,
expression: np.ndarray,
n_neighbors: int = 10,
spatial_weight: float = 1.0) -> pyg.data.Data:
"""Побудова графа з spatial та expression similarity"""
from sklearn.neighbors import kneighbors_graph
from scipy.sparse import csr_matrix
n_cells = coordinates.shape[0]
# Spatial neighbors
spatial_adj = kneighbors_graph(
coordinates,
n_neighbors=n_neighbors,
mode='connectivity'
)
# Expression similarity (для edge weights)
from sklearn.metrics.pairwise import cosine_similarity
expr_sim = cosine_similarity(expression)
# Combine
combined_adj = spatial_adj.multiply(expr_sim)
# Convert to edge_index
coo = combined_adj.tocoo()
edge_index = torch.tensor(np.vstack([coo.row, coo.col]), dtype=torch.long)
edge_weight = torch.tensor(coo.data, dtype=torch.float32)
# Node features
x = torch.tensor(expression, dtype=torch.float32)
return pyg.data.Data(x=x, edge_index=edge_index, edge_attr=edge_weight)
Vision Transformers для Histology Integration
from transformers import ViTModel, ViTConfig
import torch.nn as nn
class HistologyEncoder(nn.Module):
"""ViT-based encoder для histology images"""
def __init__(self, pretrained: str = "owkin/phikon"):
super().__init__()
# Foundation model для pathology
self.vit = ViTModel.from_pretrained(pretrained)
self.hidden_size = self.vit.config.hidden_size
# Projection for spatial integration
self.projection = nn.Linear(self.hidden_size, 256)
def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
# Extract features
outputs = self.vit(pixel_values=pixel_values)
cls_token = outputs.last_hidden_state[:, 0, :]
# Project
features = self.projection(cls_token)
return features
def extract_patch_features(self, wsi_image: np.ndarray,
patch_size: int = 256,
stride: int = 128) -> dict:
"""Екстракція features для всіх patches WSI"""
import cv2
from torchvision import transforms
h, w = wsi_image.shape[:2]
features_list = []
coordinates = []
transform = transforms.Compose([
transforms.ToPILImage(),
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
for y in range(0, h - patch_size, stride):
for x in range(0, w - patch_size, stride):
patch = wsi_image[y:y+patch_size, x:x+patch_size]
# Transform and extract features
patch_tensor = transform(patch).unsqueeze(0)
with torch.no_grad():
feat = self.forward(patch_tensor)
features_list.append(feat.squeeze().cpu().numpy())
coordinates.append((x + patch_size // 2, y + patch_size // 2))
return {
'features': np.array(features_list),
'coordinates': np.array(coordinates)
}
class MultiModalSpatialModel(nn.Module):
"""Multi-modal: H&E + Spatial transcriptomics"""
def __init__(self, n_genes: int, n_classes: int,
histology_encoder: str = "owkin/phikon"):
super().__init__()
# Image encoder
self.image_encoder = HistologyEncoder(histology_encoder)
# Expression encoder
self.expr_encoder = nn.Sequential(
nn.Linear(n_genes, 512),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(512, 256),
nn.ReLU(),
nn.Linear(256, 128)
)
# Spatial GNN encoder
self.gnn_encoder = SpatialGNN(
in_channels=n_genes,
hidden_channels=128,
out_channels=128,
num_layers=2
)
# Cross-attention fusion
self.cross_attention = nn.MultiheadAttention(
embed_dim=128,
num_heads=4,
batch_first=True
)
# Fusion MLP
self.fusion = nn.Sequential(
nn.Linear(128 * 3, 256),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(256, n_classes)
)
def forward(self, image_patches: torch.Tensor,
expression: torch.Tensor,
edge_index: torch.Tensor,
batch_idx: torch.Tensor = None) -> torch.Tensor:
# Image features
img_feat = self.image_encoder(image_patches) # [N, 256] -> [N, 128]
img_feat = nn.Linear(256, 128)(img_feat)
# Expression features
expr_feat = self.expr_encoder(expression) # [N, 128]
# GNN features
gnn_feat = self.gnn_encoder(expression, edge_index) # [N, 128]
# Cross-attention (expression attends to image)
expr_feat_att, _ = self.cross_attention(
expr_feat.unsqueeze(1),
img_feat.unsqueeze(1),
img_feat.unsqueeze(1)
)
expr_feat_att = expr_feat_att.squeeze(1)
# Concatenate all modalities
combined = torch.cat([img_feat, expr_feat_att, gnn_feat], dim=-1)
# Classify
output = self.fusion(combined)
return output
Tumor Microenvironment Analysis
class TumorMicroenvironmentAnalyzer:
"""Аналіз tumor microenvironment"""
def __init__(self, adata):
self.adata = adata
def identify_tumor_immune_interface(self, tumor_label: str,
immune_labels: list,
distance_threshold: float = 50):
"""Знаходження tumor-immune interface"""
from scipy.spatial.distance import cdist
# Get coordinates
tumor_mask = self.adata.obs['cell_type'] == tumor_label
immune_mask = self.adata.obs['cell_type'].isin(immune_labels)
tumor_coords = self.adata.obsm['spatial'][tumor_mask]
immune_coords = self.adata.obsm['spatial'][immune_mask]
# Calculate distances
distances = cdist(tumor_coords, immune_coords)
# Interface cells
tumor_interface = distances.min(axis=1) < distance_threshold
immune_interface = distances.min(axis=0) < distance_threshold
return {
'tumor_interface_cells': np.where(tumor_mask)[0][tumor_interface],
'immune_interface_cells': np.where(immune_mask)[0][immune_interface],
'tumor_interface_fraction': tumor_interface.mean(),
'immune_interface_fraction': immune_interface.mean()
}
def calculate_immune_infiltration_score(self, immune_labels: list) -> np.ndarray:
"""Розрахунок immune infiltration score для кожної точки"""
from sklearn.neighbors import KernelDensity
coords = self.adata.obsm['spatial']
immune_mask = self.adata.obs['cell_type'].isin(immune_labels)
immune_coords = coords[immune_mask]
# Kernel density estimation
kde = KernelDensity(bandwidth=100, kernel='gaussian')
kde.fit(immune_coords)
# Score for all points
log_density = kde.score_samples(coords)
infiltration_score = np.exp(log_density)
return infiltration_score
def predict_therapy_response(self, model: nn.Module,
features: torch.Tensor,
edge_index: torch.Tensor) -> dict:
"""Прогноз відповіді на терапію на основі TME"""
model.eval()
with torch.no_grad():
predictions = model(features, edge_index)
probs = F.softmax(predictions, dim=-1)
# Aggregate to sample level
response_prob = probs[:, 1].mean().item() # Assuming binary: 0=non-responder, 1=responder
# Identify predictive regions
high_response_regions = probs[:, 1] > 0.7
low_response_regions = probs[:, 1] < 0.3
return {
'predicted_response_probability': response_prob,
'high_response_regions': high_response_regions.numpy(),
'low_response_regions': low_response_regions.numpy(),
'spatial_heterogeneity': probs[:, 1].std().item()
}
Benchmarks та порівняння методів
| Method | Spatial Domain Detection | Cell Type Accuracy | Computational Cost |
|--------|--------------------------|--------------------|--------------------|
| SpaGCN | 0.72 ARI | N/A | Low |
| STAGATE | 0.78 ARI | N/A | Medium |
| GraphST | 0.81 ARI | N/A | Medium |
| Cell2location | N/A | 0.85 F1 | High |
| Tangram | N/A | 0.82 F1 | Medium |
| RCTD | N/A | 0.79 F1 | Low |
Ідеї для дослідження
Для бакалавра:
- Cell segmentation pipeline з Cellpose
- Spatial clustering порівняння методів
- Visualization dashboard для spatial data
Для магістра:
- GNN для spatial domain identification
- Multi-modal integration (H&E + transcriptomics)
- Transfer learning для нових тканин
Для PhD:
- Novel architectures для spatial omics
- Foundation models for spatial biology
- Causal inference from spatial data
- 3D spatial reconstruction
Інструменти
Data Analysis:
- Scanpy/Squidpy (Python)
- Seurat/Giotto (R)
- Napari (visualization)
Deep Learning:
- PyTorch Geometric
- DGL (Deep Graph Library)
- HistoEncoder
Platforms:
- 10x Genomics Loupe Browser
- Vizgen MERSCOPE
- NanoString CosMx
Spatial omics — це frontier біології. Традиційна геноміка дала нам гени. Single-cell дала нам cell types. Spatial дає нам tissue architecture — розуміння того, як клітини організуються в функціональні одиниці.
Хвороби — не просто "неправильні гени". Це "неправильна організація". Пухлина — це не тільки мутації, а й те, як імунні клітини розташовані навколо неї. Нейродегенерація — це не тільки загибель нейронів, а й порушення їх зв'язків.
Якщо вас цікавить цей напрямок — від базового аналізу Visium до custom GNN для нових задач — звертайтесь до команди SKP-Degree на skp-degree.com.ua або пишіть у Telegram: @kursovi_diplomy. Допоможемо з вибором технології, архітектурою моделі та імплементацією.
Ключові слова: spatial omics, spatial transcriptomics, GNN, Graph Neural Networks, single-cell, bioinformatics, deep learning, cancer research, neuroscience, tumor microenvironment, дипломна робота, магістерська, AI, biology