Skip to main content

MLInsights

68 ML models, no code, and a backend that scales to zero

Timeline
Mar 2026 – Sep 2026
Reading Time
7 min read
FastAPIscikit-learnXGBoostpandasNext.jsTailwind CSS

68

ML models available

7

Comparison views

0

Database servers

How these numbers were measured: Bundled public Diabetes sample (442 rows). A smoke test trained all 68 models before release.

The Problem: Choosing a Model Without Writing a Notebook

If you have a spreadsheet and want to know whether a column can be predicted, the honest answer is that nobody knows which algorithm will work until several have been tried. In practice that means a notebook: load the file, fix the missing values, encode the text columns, fit a dozen estimators and build a comparison table by hand. That's a real barrier for people who understand their data but don't write Python, and tedious even for those who do.

MLInsights puts that whole loop in the browser. You upload a CSV or Excel file, explore and clean it, train any of 68 models, compare them side by side and export everything.

The Constraints I Set

I shipped a first version in March 2026: Next.js 14 with a FastAPI service on a free hosting plan, writing sessions to /tmp. It worked, and it showed me what the September 2026 rebuild had to respect. Four constraints drove the design.

  • Near-zero idle cost: the API must be able to sleep when nobody is using it, which rules out always-on workers and background loops.
  • No accounts and no database: a visitor should get from the landing page to a trained model without signing up, and a database is a lot to run for data that lives for a day.
  • Privacy by expiry: uploads are deleted 24 hours after upload, and a visitor can delete everything sooner from Settings.
  • Safe on a small container: 68 algorithms with very different costs must not hang a request or saturate a CPU-limited instance.

Architecture

There are two deployables: a Next.js 16 frontend, and a FastAPI service with a persistent volume mounted at /data. The browser calls the API directly. All server state lives in per-session folders on that volume, and the browser only holds a session ID.

An upload is checked against a 50 MB limit and decoded by trying UTF-8, cp1252 and Latin-1 in turn, then saved as Parquet. Every later request loads the active dataframe (the cleaned copy, if there is one), does one unit of work and writes its results back to meta.json.

  • Frontend (Next.js 16, React 19, Tailwind CSS 4, Recharts): the workflow from upload to export, all charts, and the client-side training queue.
  • API (FastAPI): upload, insights, cleaning, models, training, report and sessions routers under /api, plus /health for wake-up checks.
  • Session store: data.parquet, cleaned.parquet and meta.json per session under /data/sessions, with an in-memory cache of the 8 most recent sessions.
  • Model registry: 16 modules that register 28 regression, 30 classification and 10 clustering estimators behind a single build_model call.
  • Trainer: scikit-learn pipelines with an optional Standard, MinMax or Robust scaler, then test metrics, 5-fold cross-validation and feature importance.
  • Report layer: a ReportLab PDF, a CSV or XLSX export of the dataset, meta.json, pickled models with a manifest, and a bundle endpoint that zips them all.

Why Parquet Files on a Volume Over a Database

I considered three places for session state: process memory, a database, or files. Memory can't survive a platform that stops the container when idle. The first version had already shown the file-based variant of that failure: /tmp on the free plan was ephemeral, so every restart deleted all sessions. A database would have meant a second always-on service to store dataframes that are deleted within a day anyway.

I chose Parquet files on a persistent volume. Parquet preserves column types, which a CSV round trip would not, and it loads quickly. Every write goes to a temporary file first and is then swapped in with os.replace, so a container stopped mid-write never leaves a half-written session. A least-recently-used cache capped at eight sessions, guarded by a re-entrant lock, replaced version one's unbounded dictionary. Session IDs must now match a UUID v4 pattern before they're used in a file path.

Expiry has no scheduler, because a scheduler would keep the container awake. Instead, expired sessions are pruned at startup, and incoming requests trigger another prune at most once an hour. The trade-off is scale: a volume attaches to a single instance, so the deployment is pinned to one replica, and the lock only works within one process. Scaling out would mean moving to object storage and a shared cache.

Why One Model per Request Over a Job Queue

Training twelve models could be one long request or a background queue with a worker. A queue conflicts with the sleep constraint, because it needs a broker and a worker process that keeps the container busy. One long request would run into proxy timeouts and couldn't report progress.

Instead, POST /api/training/{session_id}/train trains exactly one model and returns its metrics and duration. The browser owns the queue: it sends the selected models one request at a time, updates a progress modal, and offers Stop After This Model. When a model fails, the API returns a 422 with a readable reason and the run moves on to the next model.

The cost is that one slow model still ties up a request, so I capped the worst cases on the server. scikit-learn, XGBoost, LightGBM and CatBoost are imported inside the route handlers, so the server doesn't load them when it starts. On the client, the first request after four minutes of silence polls /health first. It backs off from 1 second to at most 5, shows a "waking" notice after 2.5 seconds and gives up after 90.

  • Gaussian Process and Affinity Propagation are limited to 3,000 rows, Spectral clustering to 5,000 and OPTICS to 20,000.
  • Cross-validation is skipped above 20,000 rows.
  • The silhouette score is computed on a sample of at most 10,000 rows.
  • threadpoolctl caps the BLAS maths libraries at two threads, so NumPy and the boosting libraries don't oversubscribe the CPU.

The Hardest Problem: A Server That Would Not Stay Asleep

After launch, the platform's logs showed the service stopping and starting all afternoon, even though I expected it to sleep for hours at a time. Reading the deploy logs line by line, I found that none of these wake-ups came from the app. They were requests for /.env, /.env.backup, /.git/config, /.aws/credentials and /robots.txt, plus people opening the API root in a browser. The API correctly answered 404, but on a scale-to-zero platform any inbound request wakes the container, which then stays up for about ten minutes.

With a probe arriving every 15 to 25 minutes, the service spent much of the day awake. My best explanation is that scanners found the new API subdomain through public certificate transparency logs, which list new hostnames as soon as their certificates are issued. I also confirmed that the frontend isn't the cause: it only calls the API when a visitor's browser already holds a session ID, so crawlers visiting the site never touch the backend.

Since no application code can stop a request from waking a container, the fix had two layers. In code, I added a robots.txt that disallows everything and an X-Robots-Tag: noindex, nofollow header on every response, which keeps crawlers that follow the rules away. At the infrastructure level, the fix is to retire the exposed hostname and point the frontend at the platform-generated domain.

Results

MLInsights is live, with 68 models across three task types and seven comparison views: leaderboard, bar chart, all metrics, heatmap, profile, radar and score versus speed. Each of the 15 metrics and 68 models has a written guide with its formula. Before switching to Python 3.12.10, I ran a smoke test that trained every model in the registry, and all of them passed.

On the bundled Diabetes sample (442 rows), twelve regression models ranged from an R² of 0.0776 for a single decision tree to 0.4685 for Extra Trees. The fastest was Lasso, at 15 ms. That spread is exactly why comparing models beats guessing. The production logs show the app handling its first request 0.1 to 0.4 seconds after its startup log line.

What I Would Do Differently

First, I would design the public hostname as part of the scale-to-zero system from day one. I assumed that no users meant no traffic, but the internet's background noise is traffic. Next time I would choose the API domain deliberately, track how often the service wakes from the first deploy, and consider routing API calls through the frontend's own domain so there is only one public surface.

Second, I would commit the tests. The repository has no automated test suite, and the smoke test that trained every model ran as a script outside it. With 68 estimators from four libraries pinned to exact versions, one dependency upgrade can quietly break a model. A CI job that trains each one on a tiny dataset would catch that before a user does.