Available for opportunities

Ekaterina
Khlebova

Data Scientist & ML Engineer — building data-driven solutions from gene ontology graphs and physiological signals to large-scale LLM inference and network latency research.

Featured Projects

A curated selection of projects spanning bioinformatics, signal processing, cloud AI infrastructure, and network data analysis — showcasing diverse data-driven problem solving.

University of Helsinki — Liisa Holm Group

Automated Protein
Function Prediction

Research Assistant Position — Bioinformatics

📋
The Task

Gene–GO Annotation Analysis

Given a large file of gene–GO (Gene Ontology) annotations and a GO class hierarchy, parse and link genes to their GO terms. Then propagate annotations upward through the hierarchy — if a gene is annotated to class X, it's also annotated to all parent classes of X. Finally, rank the top 50 GO classes by how many genes they cover.

🧬
The Approach

Hierarchy Traversal & Propagation

  • Parse tab-delimited annotation file, extracting gene IDs and GO terms
  • Build bidirectional maps: genes → GO classes and GO classes → genes
  • Read the GO hierarchy, normalizing class IDs to GO:XXXXXXX format
  • For each annotated GO term, propagate all genes to every parent class
  • Recount genes per class and output the top 50 by frequency
Data Pipeline
📄
Gene–GO Annotations
→
🔍
Parse & Map Genes ↔ GO
→
🌳
Load GO Hierarchy
→
⬆️
Propagate to Parents
→
📊
Rank Top 50 Classes
Technologies
Python 3 collections.defaultdict File I/O CLI (sys, os)
Code Highlights
# Parse annotations: build gene → GO and GO → gene mappings
go_classes_per_gene = defaultdict(set)
genes_per_go_class  = defaultdict(set)

with open(annotations_file) as f:
    for line in f:
        if line.startswith("!"):
            continue
        cols = line.strip().split("\t")
        gene_id  = cols[1]
        go_class = cols[4]
        go_classes_per_gene[gene_id].add(go_class)
        genes_per_go_class[go_class].add(gene_id)
# Propagate annotations to parent GO classes
# "If a gene is annotated to class X, it is also
# annotated to all parent classes of X"

if go_class in genes_per_go_class.keys():
    for parent_id in parents.split(","):
        parent_id = parent_id.strip()
        if not parent_id.startswith("GO:"):
            parent_id = f"GO:{parent_id.zfill(7)}"
        for gene_id in genes_per_go_class[go_class]:
            go_classes_per_gene[gene_id].add(parent_id)
# Recount and rank the top 50 GO classes by gene count
for gene_id, go_class_set in go_classes_per_gene.items():
    for go_class in go_class_set:
        genes_per_go_class[go_class].add(gene_id)

top_50 = sorted(
    genes_per_go_class.items(),
    key=lambda x: len(x[1]),
    reverse=True
)[:50]

for go_id, genes in top_50:
    name = go_class_names.get(go_id, "Unknown")
    print(f"{go_id:<15} {name:<50} {len(genes)}")
View Full Source
Garmin — Data Scientist Position

Lactate Threshold Detection
from Heart Rate Variability

Physiological Signal Analysis — Sports Science

📋
The Task

Running Exercise Analysis

Analyze a runner's exercise data — R-R intervals (RRI, heartbeat timing) and GPS speed — to detect the lactate threshold: the exercise intensity where lactic acid starts accumulating faster than the body can clear it. This is a key metric in sports science for training optimization.

💓
The Approach

Signal Processing & Trend Detection

  • Clean RRI outliers (reject <300ms or >3000ms as artifacts)
  • Compute sliding-window RMSSD (root mean square of successive differences) over 5-min windows
  • Calculate heart rate from mean RRI per window
  • Detect decreasing→increasing RMSSD trend reversal as the lactate threshold point
  • Visualize RRI, HR, RMSSD, and speed as dual-axis timeseries
Analysis Pipeline
📡
Raw RRI & Speed Data
→
🧹
Outlier
Cleaning
→
📐
RMSSD & HR (5-min windows)
→
📉📈
Trend Reversal Detection
→
🎯
Lactate Threshold
5
Min Window
300–3000
RRI Valid Range (ms)
10
Min Trend Length
4
Timeseries Plotted
RMSSD and Speed Chart showing Lactate Threshold
Technologies
Python 3 pandas NumPy matplotlib dataclasses IntEnum Type Hints
Code Highlights
# RMSSD: Root Mean Square of Successive Differences
# A measure of heart rate variability (HRV)

def calculate_rmssd(rri_window):
    if len(rri_window) < 2:
        return 0
    diff = np.diff(rri_window)
    return np.sqrt(np.mean(diff ** 2))

# Clean outliers: replace physiologically impossible values
# <300ms = tachycardia/artifact, >3000ms = sinus arrest
rri_values[(rri_values >= 3000) | (rri_values <= 300)] = \
    np.median(rri_values)
@dataclass
class LactateThresholdState:
    """State for lactate threshold detection"""
    min_trend_length: int = 10

    # Previous values
    prev_rmssd: Optional[float] = None
    prev_index: int = 0

    # Decreasing trend tracking
    decrease_trend_count: int = 0
    decrease_trend_confirmed: bool = False

    # Increasing trend tracking
    increase_trend_count: int = 0
    increase_start_rmssd: Optional[float] = None
    increase_start_speed: Optional[float] = None
# Two-phase detection: first find a sustained decrease
# in RMSSD, then find the reversal (increase)

# Phase 1: Decreasing trend
if not state.decrease_trend_confirmed:
    if prev_val > current_val:
        state.decrease_trend_count += 1
    elif count >= state.min_trend_length:
        state.decrease_trend_confirmed = True

# Phase 2: Increasing trend (reversal found!)
else:
    if prev_val < current_val:
        state.increase_trend_count += 1
    elif count >= state.min_trend_length:
        return Status.THRESHOLD_FOUND, state
View Full Source
CSC — Finland's IT Center for Science

Large-Scale LLM Inference
on HPC Cloud

AI Infrastructure — Cloud GPU Computing

📋
The Task

Running 70B-Parameter LLMs on CSC Supercomputer

Set up and run inference on large-scale language models (70B+ parameters) using CSC's LUMI supercomputer — one of Europe's most powerful HPC systems. The goal was to serve a Poro 70B Instruct model via vLLM for SRT subtitle translation tasks using multi-GPU tensor parallelism.

🤖
The Approach

vLLM + SLURM + Tensor Parallelism

  • Configure SLURM batch job: 8 GPUs, 480GB RAM, 56 CPUs
  • Deploy vLLM OpenAI-compatible API server with tensor-parallel-size 8
  • Use custom Jinja chat templates for Poro model
  • Send prompts via OpenAI-compatible REST API
  • Process and translate SRT subtitle files in batch
Inference Pipeline
📝
SLURM Batch Job
→
🚀
Launch vLLM Server
→
🧠
Load 70B Model (8 GPUs)
→
💬
OpenAI API Requests
→
📄
Translated Subtitles
70B
Model Parameters
8
GPUs (Tensor Parallel)
480
GB RAM Allocated
95%
GPU Mem Utilization
Technologies
Python 3 vLLM SLURM / HPC Jinja Templates OpenAI API PyTorch Bash Scripting
Code Highlights
#!/bin/bash
#SBATCH --job-name=prompting
#SBATCH --partition=dev-g
#SBATCH --gpus-per-node=8
#SBATCH --mem=480G
#SBATCH --cpus-per-task=56
#SBATCH --time=01:00:00

module use /appl/local/csc/modulefiles/
module load pytorch/2.5

# Deepseek-R1-Distill-Llama-70B requires 132GB
export HF_HOME=hf-cache
# Launch vLLM OpenAI-compatible API server
# with tensor parallelism across 8 GPUs

MODEL="LumiOpen/Llama-Poro-2-70B-Instruct"
CHAT_TEMPLATE="poro_chat_template.jinja"

python -m vllm.entrypoints.openai.api_server \
    --model=$MODEL \
    --tensor-parallel-size 8 \
    --pipeline-parallel-size 1 \
    --gpu-memory-utilization=0.95
# Send translation prompts via OpenAI API
def get_translated_text(text, lang, url, model):
    prompt = system_prompt.format(lang=lang)
    messages = [
        {"role": "system", "content": prompt},
        {"role": "user",   "content": text}
    ]
    headers = {"Content-Type": "application/json"}
    data = {
        "model": model,
        "messages": messages,
        "temperature": 0.7
    }
    resp = requests.post(url, headers=headers,
                         data=json.dumps(data))
    return resp.json()
View Full Source
RIPE Atlas — Open Internet Measurement Network

Network Latency Analysis
for Edge Computing

Data Analysis — Networking & Geospatial

📋
The Task

Realistic Edge Computing Latency Scenarios

Analyze live network latency data from RIPE Atlas — a global internet measurement network — to generate realistic scenarios for simulating edge computing environments. Using Amsterdam as the target city, map real probe/anchor devices to city districts and measure round-trip times (RTT) between end devices and edge servers.

🌐
The Approach

Geospatial Filtering & RTT Statistics

  • Use GeoPandas to filter RIPE anchors within Amsterdam district boundaries
  • Select 5 geographically spread anchors as edge server proxies
  • Map 10 district probes as end-user devices
  • Fetch RTT measurements over 15-min intervals across multiple dates
  • Compute min, max, average, Q1, median, Q3 statistics and variance
  • Fit statistical distributions to RTT data for simulation input
Analysis Pipeline
🌍
RIPE Atlas
API
→
🗺️
GeoPandas District Filter
→
📍
Anchor & Probe Selection
→
📈
RTT Measurement
→
📊
Distribution Fitting
5
Edge Anchors
10
City Districts
15
Min Intervals
6
RTT Statistics
Technologies
Python 3 pandas GeoPandas matplotlib Shapely RIPE Atlas API Jupyter Notebook SciPy
Code Highlights
# Filter RIPE Atlas anchors within Amsterdam districts
# using GeoPandas spatial join

import geopandas as gpd

# Load Amsterdam district boundaries (WGS84 / EPSG:4326)
districts = gpd.read_file("amsterdam_gebieden.geojson")
anchors_gdf = gpd.GeoDataFrame(
    anchors,
    geometry=gpd.points_from_xy(
        anchors.longitude, anchors.latitude
    ),
    crs="EPSG:4326"
)

# Spatial join: keep only anchors inside districts
filtered = gpd.sjoin(anchors_gdf, districts,
                     how="inner",
                     predicate="within")
# Fetch RTT measurements from RIPE Atlas API
# for selected anchor-probe pairs

import requests

def fetch_rtt_measurements(
    measurement_id, start, stop
):
    url = (f"https://atlas.ripe.net/api/v2/"
           f"measurements/{measurement_id}/"
           f"results/?start={start}&stop={stop}")
    response = requests.get(url)
    results = response.json()

    rtts = []
    for result in results:
        for reply in result.get("result", []):
            if "rtt" in reply:
                rtts.append(reply["rtt"])
    return rtts
# Compute summary RTT statistics per region
import numpy as np
from scipy import stats

def compute_rtt_stats(rtt_values):
    return {
        "min":     np.min(rtt_values),
        "max":     np.max(rtt_values),
        "mean":    np.mean(rtt_values),
        "q1":      np.percentile(rtt_values, 25),
        "median":  np.median(rtt_values),
        "q3":      np.percentile(rtt_values, 75),
        "variance": np.var(rtt_values)
    }

# Fit distribution to RTT data
params = stats.lognorm.fit(rtt_values)
pdf = stats.lognorm.pdf(x, *params)
View Full Source