Skip to content
 
 

Repository files navigation

4T CMOS Pixel -> Vector-to-Vector MLP

This project trains a neural network to reproduce what SPICE simulation says a 4-transistor (4T) CMOS image sensor pixel does over one reset/expose/readout cycle -- given the input waveforms (the light pulse and the reset control signal) plus this run's circuit parameters, predict the full output waveforms at every node in the pixel's signal chain (photodiode, floating diffusion, source follower, column bus).

It's a companion exercise to a 3T pixel version: same idea (SPICE -> fixed-length waveform vectors -> MLPRegressor -> predicted-vs-simulated plots), one more transistor and one more node in the signal chain.

Current result: test R^2 = 0.996 (real volts, pooled across all 4 output nodes, on a properly-sized 2800-run held-out test set), with every individual node (pd/fd/sf_out/col) independently at R^2 >= 0.980. X now includes 39 randomized circuit parameters (transistor sizes/thresholds, capacitances, column resistor, and inter-phase timing) trained on 14,000 simulation runs. See "How we got here" below -- it took several real fixes, not just cranking up a dial, and the story of what didn't work is arguably more instructive than the final numbers.

The circuit

A 4T Active Pixel Sensor:

  • M_TX (transfer gate) moves charge from the photodiode (pd) onto the floating-diffusion / storage node (fd) for readout.
  • M_RST (reset) resets fd (and, through TX, pd) back to VDD at the start of every cycle.
  • M_SF (source follower) buffers fd onto sf_out.
  • M_SEL (row select) gates sf_out onto the column bus col, through a load resistor R_col to ground.

One cycle: reset (RST + TX both pulse briefly, charging pd/fd to near VDD) -> exposure (a light pulse discharges pd for a while) -> **transfer

  • readout** (TX briefly reconnects pd to fd to move the accumulated signal over, then SEL connects the source follower to the column bus so col reads it out).

There was no pre-existing 4T netlist with "correct" component values to copy, so every randomized range in pixel_common.py (transistor sizes, thresholds, timing, supply voltage, column resistor) is our own reasonable choice for a teaching circuit -- physically plausible, not a calibrated real sensor model. In particular the photocurrent amplitude is derived per run (from a target voltage droop, the photodiode capacitance, and the exposure time) rather than picked independently -- see the comments in pixel_common.py for why that matters (an independently-chosen amplitude can trivially slam the photodiode hundreds of volts negative in a few nanoseconds for the wrong capacitance/exposure combination).

Pipeline

Script What it does
pixel_common.py Shared constants, the SPICE netlist template, and helper functions. Not run directly.
01_generate_dataset.py Runs NUM_RUNS randomized ngspice simulations, saves one .npz per run to runs_raw/ (raw netlists also saved to runs_netlists/ for inspection).
02_build_xy.py Interpolates every run onto a shared fixed-length time grid and builds the X/Y training matrices, saved to dataset.npz.
03_train_mlp.py Splits train/test, scales with StandardScaler (fit on train only), trains an MLPRegressor, saves it to trained_model.joblib.
04_plot_results.py Evaluates on the held-out test runs, prints R^2/MSE in real volts, saves predicted-vs-simulated plots to plots/.

Run them in order, from inside this directory:

source .venv/bin/activate
python3 01_generate_dataset.py
python3 02_build_xy.py
python3 03_train_mlp.py
python3 04_plot_results.py

01_generate_dataset.py is fast (ngspice on this tiny circuit runs in ~40ms per simulation). 03_train_mlp.py is the slow step now -- see "Runtime" below. Everything after step 1 works off saved .npz files, so you can re-run 02/03/04 as many times as you want without re-simulating.

Regenerating the dataset

Edit NUM_RUNS and RANDOM_SEED at the top of 01_generate_dataset.py, then re-run it (it wipes and rebuilds runs_netlists/ and runs_raw/ each time). To change what's randomized -- ranges, which parameters are swept, the fixed time window, the time grid resolution (N_GRID) -- edit PARAM_RANGES, TRANSISTOR_BASELINE, N_GRID, etc. in pixel_common.py.

Retraining

Just re-run 03_train_mlp.py (and then 04_plot_results.py).

Runtime

03_train_mlp.py currently takes on the order of 15-20 minutes on a laptop CPU. That's expected, not a bug -- see "How we got here" below for why early_stopping=False with a very tight tol is exactly what fixed the model, at the cost of letting the optimizer run much longer per fit.

How we got here

The very first version of this pipeline (60 simulation runs, N_GRID=150, a small (64,64) network) got test R^2 = 0.90 -- but that was measured on only 12 held-out runs, too few to trust. Chasing a genuinely better, honestly-measured result took several real, separate fixes:

  1. More data. 60 -> 200 -> 2000 -> 6000 runs. Helped a lot at first, then completely stopped helping (2000 -> 6000, i.e. 3x more data, moved R^2 by less than 0.005) -- a clear signal the bottleneck had stopped being "not enough examples."
  2. Solver/architecture matched to dataset size. lbfgs (good for tiny datasets) became impractically slow once training data grew past ~1500 examples; switching to adam (mini-batch, scales fine) was necessary just to keep iterating.
  3. Finer time grid. N_GRID=150 (~16.7ns/sample) was aliasing away real detail -- the shortest reset pulses (15ns) barely register as more than 1 sample. Raising it to N_GRID=400 (~6.25ns/sample) recovered real signal that literally hadn't been captured before, at any dataset size or model capacity.
  4. The big one: fixing an ill-posed input. Early versions of X only contained the light-pulse and reset waveforms -- NOT this run's randomized transistor sizes/thresholds or column resistor. But those values change the output too, independently of the input waveforms. Two runs with nearly identical inputs but different transistor parameters have different correct outputs -- which means the old X -> Y mapping wasn't actually a function, and no amount of data or model capacity can fit a relationship that isn't one. Adding CIRCUIT_PARAM_FEATURES (VDD, R_col, and every transistor's W/L/Vto/Kp) as extra scalar inputs fixed this at the root.
  5. Letting the optimizer actually converge. With the mapping now well-posed, early_stopping=True turned out to be quietly hurting: it stops training as soon as an internal validation slice stops improving, which was cutting training off after only ~100-200 iterations -- long before the network had converged. Turning early stopping off (early_stopping=False) and instead stopping only once training loss is flat to tol=1e-8 for 300 iterations straight (n_iter_no_change=300) let training actually finish, with alpha=1e-4 (much lighter regularization than earlier stages needed) since the ill-posedness that regularization had been partially compensating for was already gone.

Steps 4 and 5 together are what took real test R^2 from the 0.92-0.94 range up to 0.995. Neither one alone would have been enough: without fixing the ill-posed input, letting the optimizer run longer mostly would have just meant memorizing the training set faster.

The lesson worth taking from this (arguably more than the final number): a model that won't get past some ceiling no matter how much you tune it is telling you something. "More data isn't helping anymore" and "a bigger network does worse, not better" were both real, checkable signals that the problem was somewhere other than model size or dataset size -- and tracking down what was different (the missing circuit parameters) mattered far more than any hyperparameter sweep.

  1. Adding still more parameters (18 -> 33 -> 39). After the fixes above, more of the netlist's fixed constants got turned into randomized per-run parameters -- first the capacitances and per-transistor LAMBDA/GAMMA/PHI, then the inter-phase timing gaps and pulse edge speeds -- each time following the same rule from step 4 (anything randomized has to be added to CIRCUIT_PARAM_FEATURES, or it's ill-posed again). Each expansion cost a little test R^2 at first (0.995 -> 0.994 after the first round, then down to 0.994 -> also noisy per-node, particularly col, after the second) -- expected, since a richer, more varied dataset is a genuinely harder prediction problem. Re-sweeping alpha didn't recover it (a flat result across 1e-4 to 1e-3 was itself informative -- regularization wasn't the bottleneck this time). Scaling the dataset up again (6000 -> 14000 runs) did: test R^2 recovered to 0.996, better than before the parameters were added. The takeaway: more input dimensions raised the data requirement, and simply generating more (cheap, since ngspice is fast) was the fix -- not a hyperparameter.

Suggested next experiments

  1. Compare against the 3T version. The 3T pixel skips the transfer gate entirely (the photodiode is the storage node). Once you have both pipelines, compare: does the 4T pixel's extra transistor make the waveform harder for the MLP to learn (an extra nonlinearity in the chain), or does separating "integration" (pd) from "readout" (fd) via TX actually make the output waveform simpler and easier to predict?
  2. Take the circuit parameters back out of X (revert to just the two waveforms) and watch R^2 collapse back down -- a hands-on way to see what "ill-posed" actually looks like in practice, and why it's different from ordinary underfitting.
  3. Warm up with a simpler mapping. Try predicting just V(col) from V(fd) alone (edit INPUT_SIGNALS/OUTPUT_SIGNALS in pixel_common.py) -- a much easier, nearly-static mapping through just two transistors.
  4. Try widening the randomized ranges in PARAM_RANGES and TRANSISTOR_BASELINE (pixel_common.py) and see how much of the R^2 gain from this session survives a harder, more varied dataset -- and whether some parameter combinations push the circuit into a completely different regime (e.g. the runs that land in cutoff and read out a flat zero column voltage).

About

4T CMOS pixel -> vector-to-vector MLP surrogate (ngspice + scikit-learn)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages