Back to the home page

Projects

Public repositories from my GitHub: course projects at EPITA, my TIPE from classes préparatoires, and personal projects.

Small Llama-style language model

Personal project, 2026

Pretrains a small Llama-style language model, written from scratch in PyTorch, on the TinyStories dataset, then uses it to fit a scaling law and to try the Muon optimizer and a mixture of experts.

  • Built the model from the usual Llama parts (RMSNorm, rotary embeddings, grouped-query attention, SwiGLU, a KV cache for sampling), with a flag that turns each feed-forward layer into a Mixtral-style mixture of 8 half-width experts, 2 per token.
  • Pretrained a model with 9.7M parameters, not counting embeddings, on 164M tokens in under 6 minutes on an RTX 5070 Ti. It ends at a validation loss of 1.40; its stories have mostly correct grammar but do not always make sense and sometimes repeat themselves.
  • Trained five dense models from 0.1M to 9.7M non-embedding parameters on the same 65.5M tokens, with one seed each and no learning-rate tuning per size, and fitted a power law with exponent -0.104 to their validation losses. The largest model sits above the fit, probably short of data at about 7 tokens per parameter.
  • Compared Muon and the mixture of experts with an AdamW baseline that has as many active parameters: both end with a lower validation loss but train slower, the mixture of experts 7 to 8 times slower, probably because of my plain Python loop over the experts.

Stack: Python, PyTorch, Hugging Face Tokenizers, NumPy, Matplotlib

Three runs on the same 65.5M tokens, one seed each, with their final validation loss. Parameters exclude the embeddings; the mixture of experts has 14.2M, but only 4.4M are used for each token. Lower loss is better.
RunParametersLoss
Dense + AdamW4.4M1.586
Dense + Muon4.4M1.552
MoE (8 experts, top-2) + AdamW14.2M1.533
Data from README.md in the repository.

Zero-shot time series forecasting

Personal project, 2026

Treats forecasting as language modeling, like the Chronos paper: a small transformer trained on real and synthetic series predicts each next value as a token, and is tested on two real datasets it has never seen, ETTh1 and ETTm1.

  • Trained it on five public datasets from the Monash archive (electricity, traffic, Australian power demand, pedestrian counts, solar), none of them ETT, mixed with TSMixup like in Chronos, plus 10% synthetic series from my reimplementation of KernelSynth.
  • Tokenized each window by dividing it by the mean absolute value of its context and cutting [-15, 15] into 4096 uniform bins. The model is a 17.6M-parameter decoder-only transformer (8 layers, width 384, 512 context points), trained on 1.1B tokens in 38 minutes on an RTX 5070 Ti.
  • Measured a gain over a first version trained only on synthetic data: relative WQL went from 1.01 to 0.94 on ETTh1 and from 0.96 to 0.92 on ETTm1, so in geometric mean it now beats seasonal naive (repeat the last day). On ETTm1 its plain average WQL is still higher, and its point forecasts still lose, with MASE 13 to 17% worse.
  • Plotted example forecasts: it now follows the daily cycle of the LUFL load series on ETTh1 and gets the level of MUFL on ETTm1 right, where the first version predicted -10 instead of 10, but it still smooths over the sharp daily dips of HUFL and MUFL on ETTh1.

Stack: Python, PyTorch, NumPy, Matplotlib

Error relative to seasonal naive, as a geometric mean over 10 windows per series, 64 steps ahead. Lower is better, and 1 means as good as seasonal naive. v1 was trained only on synthetic data. Like seasonal naive, naive gives only a point forecast, so its two ratios are equal.
Dataset and relative errorv1v2Naive
ETTh1, MASE1.2571.1731.558
ETTh1, WQL1.0060.9371.558
ETTm1, MASE1.2201.1271.469
ETTm1, WQL0.9620.9171.469
Data from README.md in the repository.

Breast ultrasound classification and segmentation

Personal project, 2026

Classifies breast ultrasound images as normal, benign or malignant with a ResNet-34 and segments the lesion with a U-Net, then tests both models on a dataset from another hospital that they never see in training.

  • Switched from the original BUSI, where 155 of the 780 images are copies and my own check only found 18 pairs, to the Curated BUSI of Aumente-Maestro et al.: 450 images, with the duplicates and problematic cases removed by an automatic search and a manual review.
  • Evaluated with 5-fold cross-validation, so all 450 images get a prediction from a model that never trained on them: 82.9% accuracy (95% CI 79.3 to 86.0) and a ROC-AUC of 0.939 for the ResNet-34 classifier, and a mean Dice of 0.738 on the 386 lesions for the U-Net.
  • Added augmentation aimed at what changes between hospitals (crops with aspect changes, zoom out into a black frame, gamma, blur, speckle-like noise), flip test-time augmentation, and an average of the 5 fold models for the external test.
  • On BUS-BRA, 1,875 biopsy-proven images from 1,064 patients in Brazil, the malignant-versus-benign ROC-AUC rose from 0.706 in the first version to 0.805 (95% CI 0.777 to 0.831), but 26% of its lesions are now classified as normal, against 17.5% before.

Stack: Python, PyTorch, torchvision, scikit-learn, SciPy, Jupyter

Lesions only, since BUS-BRA has no normal images: the 386 curated BUSI lesions, each predicted by a model that never trained on it, and the 1,875 BUS-BRA lesions with the 5 fold models averaged. Higher is better.
MetricCurated BUSIBUS-BRA
ROC-AUC, malignant vs benign0.9320.805
Malignant sensitivity0.8230.590
Benign specificity0.8920.845
Mean Dice0.7380.670
Data from breast_cancer_detection_segmentation.ipynb in the repository.

Meaning drift in machine translation

EPITA NLP course project, 2026

Passes an English sentence through a chain of NLLB-200 translations, translates each step back to English and measures how far the meaning drifts, like a game of telephone.

  • Wrapped NLLB-200 in a translation class that runs on the GPU when available, decodes with beam search over 4 beams and forces the target language as the first token.
  • Scored every back-translation against the original sentence with six metrics: BLEU, chrF, METEOR, BERTScore F1, a character-level similarity and TF-IDF cosine.
  • Wrote a benchmark mode that runs two model sizes, NLLB-200 distilled 600M and 1.3B, over English sentences from FLORES-200 devtest and aggregates the scores per model.

Stack: Python, PyTorch, Hugging Face Transformers, sacreBLEU, NLTK, scikit-learn

Original: “Our NLP's teacher is the best teacher at EPITA !” (verbatim from nlp.py)
LanguageBack to EnglishchrFBLEU
FrenchOur NLP teacher is the best teacher in EPITA!75.746.7
GermanOur NLP professor is the best teacher in EPITA!61.132.5
ItalianOur professor of NLP is the best teacher in EPITA!54.529.0
SpanishOur NLP teacher is the best teacher in EPITA!75.746.7
One demo run: the sentence goes from English to French, German, Italian, then Spanish, and each translation is translated back to English and scored against the original. Only the wording changes: BERTScore F1 stays between 0.961 and 0.972. Data from results/results.csv in the repository.

Learned model predictive control for a race car

EPITA course project, 2026

Races a simulated car with model predictive control, then corrects the controller's imperfect car model with a neural network trained on its prediction errors, all on an autodiff engine written from scratch in NumPy.

  • Wrote a reverse-mode automatic differentiation engine using only NumPy, since the course forbids deep learning frameworks; its tests check values and gradients against PyTorch.
  • Built the controller as gradient descent through the car model: Adam tunes the next 15 actions by differentiating a dynamic bicycle model over the horizon, against a racing cost with soft track limits and anti-spin penalties.
  • Trained a 3-layer MLP, with my own layers and Adam optimizer, on one noisy run of the nominal controller to predict its model's one-step error; the learned controller adds this correction to every prediction.
  • Timed one lap for each controller, once with wrong car parameters in its model and once with the right ones, and all four laps stay on the track. Plain MPC is faster both times: 14.0 s against 14.2 s for the learned controller with the wrong parameters, and 14.0 s against 14.4 s with the right ones, which the network was not trained for.

Stack: Python, NumPy

One lap by the learned controller, 14.2 s of simulated time. The blue trail is the path driven; the yellow dots are the centerline points ahead of the car. Recording cropped from race_mlmpc.mp4 in the repository.

Genetic algorithm for Snake

TIPE, classes préparatoires, 2023 to 2024, updated in 2026

Evolves neural networks that learn to play Snake with a genetic algorithm. It started as my TIPE, the research project presented at the end of French classes préparatoires; the 2026 version adds tests and measured results.

  • Each snake is a neural network whose 540 weights are its genes: a 7 × 7 view around the head and the direction of the apple (53 inputs), two hidden layers of 8 tanh neurons and 4 outputs, one per direction.
  • Each generation of 200 keeps the best 25%, then adds children from uniform crossover and mutation, mutated copies and 10% random newcomers. Fitness is the number of moves times 2 to the power of the apples eaten, averaged over 5 games.
  • After 300 generations, 8 minutes on 14 cores, the saved snake eats 38.1 apples per game on 100 games never used for selection, against 0.08 for random moves.
  • In an ablation with the 7 × 7 view alone, the best snake eats 0.12 apples per game after 150 generations, about as many as random moves; with the apple direction it eats 12.3.

Stack: Python, NumPy, pygame, pytest

Apples eaten per game. The saved snake is the final choice, tested on 100 new games.
PlayerApples per game
Random moves, on the same 100 games0.08
Generation 0, population average0.03
Generation 299, population average20.1
Saved snake, 100 new games38.1
Data from README.md in the repository.

Absenteeism at work

EPITA time series course project, team of two, 2026

Asks whether monthly absence hours at a courier company in Brazil can be forecast (740 absences, July 2007 to July 2010), then explains and classifies absence durations instead.

  • Rebuilt a 37-month series from records that have no date column, then studied trend, seasonality and stationarity with moving averages, three decompositions (additive, multiplicative, STL) and ADF and KPSS tests.
  • Chose AR, MA and ARMA orders from the ACF, PACF and BIC; the best model, AR(2), beat the training-mean baseline by only about 2% on the 9 test months.
  • Checked the residuals with Ljung-Box tests (p = 0.94 at lag 6, 0.87 at lag 12): they are compatible with white noise, so the monthly series holds little to forecast.
  • My teammate did the second half: day-of-week effects and a three-class classifier of absence duration (best macro-F1 0.61 in cross-validation, with logistic regression).

Stack: Python, pandas, statsmodels, scikit-learn, Jupyter, LaTeX

Forecast error on the 9 test months, in hours of absence per month. Lower is better.
ModelMAERMSE
AR(2)70.696.3
MA(2), lowest BIC70.697.7
Mean of the training months71.598.5
Same month a year earlier83.2108.3
Last training month73.8114.1
Data from main.ipynb in the repository; the same values appear in the report.

2D liquid simulator

Personal project, 2026

Simulates liquids on a grid with a cellular automaton in C and SDL3: draw walls, pour 20 liquids with realistic densities, and watch them flow, mix and build up pressure.

  • Simulates a 45 × 45 grid: the left mouse button draws the selected liquid or walls, the right button erases, and each liquid has its own key.
  • Runs five rounds of three passes per frame: flow (down, sideways, then up under pressure, with slight compression), mixing of neighboring liquids, and density sorting. By default, two liquids mix when their densities differ by at most 10, so every pair mixes except those with mercury, which is the only liquid that sinks through the others.
  • A pressure view, toggled with P, shades each cell by the pressure of the liquid column above it.
  • Follows the flow rules of a single-liquid cellular-automaton tutorial for Unity, rewritten in C; the 20 liquids, mixing and density sorting are my additions.

Stack: C, SDL3

  1. Gasolinedensity 0.72
  2. Ethanoldensity 0.79
  3. Kerosenedensity 0.81
  4. Olive oildensity 0.91
  5. Oildensity 0.92
  6. Coconut oildensity 0.93
  7. Waterdensity 1.00
  8. Vinegardensity 1.01
  9. Coffeedensity 1.01
  10. Milkdensity 1.03
  11. Sea waterdensity 1.03
  12. Orange juicedensity 1.04
  13. Sodadensity 1.04
  14. Blooddensity 1.05
  15. Glycerindensity 1.26
  16. Syrupdensity 1.33
  17. Honeydensity 1.42
  18. Sulfuric aciddensity 1.84
  19. Brominedensity 3.12
  20. Mercurydensity 13.56
The 20 liquids from lightest to densest, with the densities set in the code (water = 1). Log scale: each step doubles. Data from main.c in the repository.

Chess engine in Rust

Personal project, 2026

A chess game in Rust with its own engine and no chess library: negamax search with alpha-beta pruning behind a macroquad interface.

  • Implements the full rules on a plain 64-square board: legal moves, castling, en passant, the four promotions, checkmate, stalemate, the 50-move rule, insufficient material and threefold repetition.
  • The engine scores material and piece-square tables, searches with negamax and alpha-beta pruning, orders captures by MVV-LVA, extends leaves with a quiescence search and deepens iteratively for about 1 second per move, in its own thread.
  • Reaches depth 6 in 1 second from the start position, about 1 million nodes per second in release mode.
  • Tested with perft against the reference node counts, and with rule and engine tests such as mate in 1 and 2 and avoiding a repetition when ahead.

Stack: Rust, macroquad

Perft counts the leaf positions of the tree of legal moves. Each count matches the published reference value.
Position and depthNodes
Start position, depth 54,865,609
Kiwipete, depth 397,862
Position 3, depth 443,238
Position 4, depth 39,467
Data from src/moves.rs in the repository.

Sandboxed Ruby playground

Personal project, 2025, updated in 2026

Runs Ruby code from a browser editor on the local machine, starting a new Docker or Podman container with the chosen Ruby version for each run and deleting it afterwards.

  • Built a React interface around the Monaco editor, with five example programs and a Ruby version menu. The backend reads a list of 97 Docker images, Ruby 2.2.5 to 3.4.7, and the menu shows only the ones pulled on the machine, newest first.
  • Locked down each run: the code runs as user nobody in a new container with no network, no capabilities and a read-only root filesystem with a 16 MB /tmp. The container is limited to 256 MB of memory, half a CPU and 64 processes and threads, and is killed after 15 seconds or 64 KB of output.
  • Kept it on the local machine, since the containers share the host kernel: both servers listen on 127.0.0.1, and the backend rejects requests whose Host header is not localhost, 127.0.0.1 or [::1], which blocks DNS rebinding. At most 2 programs run at once.
  • Stored the code as .rb files in a workspace folder, with a sidebar to create, import, open and delete them. File names and workspace ids that would leave the folder, such as ../x.rb, are rejected.

Stack: JavaScript, React, Monaco Editor, Node.js, Express, Docker

Smaller projects

  • Snake

    Snake on a 40 × 30 grid with five apples that reappear on free cells; the snake grows with each apple and dies on a wall or on itself.

    C, SDL2, 2025

  • Pong

    Two-player Pong with paddles on the Z and S keys and on the arrow keys; each hit adds 4% of the starting speed to the ball and the first player to 10 points wins.

    C, SDL2, 2025

  • Game of Life

    Conway's Game of Life on a 96 × 54 grid: click cells to toggle them, press Space to run or pause, and read the generation and population counters.

    C, SDL2, SDL2_ttf, 2025

  • 3D cube

    A cube drawn as 1,200 points along its 12 edges, turned every frame by a 3D rotation matrix and projected onto the screen.

    C, SDL2, 2025

  • Bouncing ball

    A ball falling under gravity and bouncing off the window edges while it draws its trail; Space switches to random colors.

    C, SDL2, 2025

  • WLED image sender

    A web page that scales an image down to an LED matrix and sends it to a WLED controller through its JSON API.

    JavaScript, HTML, CSS, 2025

For research and compiler work, see selected work on the home page. School projects whose code stays private are listed with the projects there.