Hacker News

Show HN: I implemented a neural network in SQL

Show HN: I implemented a neural network in SQL

The network architecture is 196 -> 32 tanh -> 10 softmax, with N_SAMPLES = 700, TRAIN_FRAC = 0.7, LR = 0.5, STEPS = 60, and CHUNK = 250.

Zero pixel optimization

Drop zero-valued pixels from the (dominant) layer-0 contraction. A background pixel contributes 0 * weight = 0, so skipping those rows shrinks the join exactly - the result is identical, and the speedup scales with the fraction of zeros (a dark background). On dense inputs it is a no-op.

Measured ~1.8x on real Fashion-MNIST (~50% zero pixels): 2.56 -> 1.45 s/step. SKIP_ZERO_PIXELS = True.

Data loading

def fashion_mnist():
    """The whole training set, left lazy so SQL streams and samples it.
    The real path returns a dask-backed (chunked) Dataset - nothing is pulled
    into memory here; ``from_dataset`` reads it chunk by chunk on demand, and
    the random subsample happens later in SQL. The offline fallback is a small
    synthetic set built in memory.
    """
    try:
        ds = xr.open_dataset(
            "s3://carbonplan-share/xbatcher/fashion-mnist-train.zarr",
            engine="zarr",
            chunks=None,
            backend_kwargs={"storage_options": {"anon": True}},
        )
        if "channel" in ds.dims:
            ds = ds.isel(channel=0, drop=True)
        # To float64, lazily (no full read). This zarr already stores images
        # as float in [0, 1]; only integer-encoded sources ([0, 255]) rescale.
        images = ds["images"].astype("float64")
        if not np.issubdtype(ds["images"].dtype, np.floating):
            images = images / 255.0
        ds = ds.assign(images=images, labels=ds["labels"].astype("int64"))
    except Exception:
        # Offline fallback: a separable synthetic set (per-class template +
        # noise), so the same pipeline still learns without the network. A pool
        # larger than N_SAMPLES so the SQL subsample still has something to pick.
        rng = np.random.default_rng(0)
        n = 3 * N_SAMPLES
        templates = rng.standard_normal((10, SIDE, SIDE))
        labels = rng.integers(0, 10, n).astype("int64")
        images = templates[labels] + 0.6 * rng.standard_normal((n, SIDE, SIDE))
        ds = xr.Dataset(
            {
                "images": (("sample", "height", "width"), images),
                "labels": (("sample",), labels),
            }
        )
    # Integer index coords are the SQL join keys (sample, height, width).
    return ds[["images", "labels"]].assign_coords(
        sample=np.arange(ds.sizes["sample"]),
        height=np.arange(ds.sizes["height"]),
        width=np.arange(ds.sizes["width"]),
    )

Model construction

def build_model_with_table_names(
    init_weight: Callable[[int, int], np.ndarray],
    init_bias: Callable[[int], np.ndarray],
    widths=WIDTHS,
) -> tuple[xr.Dataset, dict[tuple[str, ...], str]]:
    """The network as one Dataset that splits into tables per layer.
    Layer ``i`` is a weight matrix ``layer_i (inp_i, out_i)`` and a separate
    bias vector ``bias_i (out_i,)``.
    """
    weights = {
        f"layer_{i}": ((f"inp_{i}", f"out_{i}"), init_weight(inp, out))
        for i, (inp, out) in enumerate(zip(widths[:-1], widths[1:]))
    }
    biases = {
        f"bias_{i}": ((f"out_{i}",), init_bias(out))
        for i, out in enumerate(widths[1:])
    }
    coords = {}
    coords.update(
        {f"inp_{i}": np.arange(inp) for i, inp in enumerate(widths[:-1])}
    )
    coords.update(
        {f"out_{i}": np.arange(out) for i, out in enumerate(widths[1:])}
    )
    ds = xr.Dataset({**weights, **biases}, coords=coords)
    names: dict[tuple[str, ...], str] = {}
    for i in range(len(weights)):
        names[(f"inp_{i}", f"out_{i}")] = f"layer{i}"
        names[(f"out_{i}",)] = f"bias{i}"
    return ds, names

Main training loop

def main():
    rng = np.random.default_rng(1)
    mnist = fashion_mnist()
    ctx = xql.XarrayContext()

    # One Dataset splits into two tables: pixels (sample, height, width) and
    # labels (sample). The dim names are the join keys.
    ctx.from_dataset(
        "mnist",
        mnist,
        chunks=dict(sample=CHUNK),
        table_names={
            ("sample", "height", "width"): "pixels",
            ("sample",): "labels",
        },
    )

    # Draw a random N_SAMPLES subset in SQL (ORDER BY random() LIMIT), carrying
    # each sample's label and a train/test tag. `data` is the working label
    # table: cache() pins the chosen subset so every downstream query sees the
    # same split without rescanning the source. `ORDER BY random()` shuffles the
    # whole label column, so the subset is order-independent even if the on-disk
    # samples are class-sorted.
    data = ctx.sql(f"""
        SELECT sample, labels,
               CASE WHEN random() < {TRAIN_FRAC} THEN 'train' ELSE 'test' END AS split
        FROM labels
        ORDER BY random()
        LIMIT {N_SAMPLES}
    """).cache()
    ctx.deregister_table("data")
    ctx.register_table("data", data)

The forward pass runs over ALL samples: train rows drive learning, test rows ride along so we can score them from the same logits. Only delta2 is restricted to train, so the gradients (and the trained weights) are identical to a train-only forward - test is never backpropagated.

    fwd0 = ctx.sql(f"""
        WITH c AS (
            -- z = x @ W: matmul of the input and first weight matrix
            SELECT a.sample, w.out AS out, SUM(a.val * w.val) AS z
            FROM (
                SELECT sample, height * {SIDE} + width AS inp, images AS val
                FROM pixels {zero_where}
            ) a
            JOIN weight w ON a.inp = w.inp AND w.layer = 0
            GROUP BY a.sample, w.out
        )
        -- activation(z + b): Add in the bias term, then perform activation
        SELECT c.sample, c.out AS out, c.z + b.val AS z, tanh(c.z + b.val) AS val
        FROM c
        JOIN bias b ON c.out = b.out AND b.layer = 0
    """).cache()

The backward pass computes output error delta2 = softmax(logits) - onehot(label), then propagates through each layer using the chain rule. Weight and bias gradients are computed as averages over the training set.

    delta2 = ctx.sql(f"""
        WITH m AS (SELECT sample, MAX(z) AS m FROM logits GROUP BY sample),
             e AS (SELECT logits.sample, logits.out, exp(logits.z - m.m) AS e
                   FROM logits JOIN m ON logits.sample = m.sample),
             s AS (SELECT sample, SUM(e) AS s FROM e GROUP BY sample)
        SELECT e.sample, e.out, e.e / s.s - CASE WHEN e.out = y.labels THEN 1.0 ELSE 0.0 END AS val
        FROM e
        JOIN s ON e.sample = s.sample
        JOIN data y ON y.sample = e.sample
        -- restrict the error to train, so every downstream gradient is train-only
        WHERE e.sample IN (SELECT sample FROM data WHERE split = 'train')
    """).cache()

SGD updates are applied in a single query per relation:

    ctx.sql(f"""
        UPDATE weight SET val = val - {LR} * g.val
        FROM g0 g
        WHERE weight.inp = g.inp AND weight.out = g.out AND weight.layer = 0
    """)

Accuracy tracking

    acc = ctx.sql(f"""
        WITH m AS (SELECT sample, MAX(z) AS m FROM logits GROUP BY sample),
             p AS (SELECT logits.sample, logits.out AS pred
                   FROM logits JOIN m ON logits.sample = m.sample
                   WHERE logits.z = m.m)
        SELECT p.sample, p.pred, d.labels, d.split
        FROM p JOIN data d ON p.sample = d.sample
    """)

Final output

    print(f"Training complete - {STEPS} steps, final accuracy {acc_train*100:.1f}% / {acc_test*100:.1f}%")
    print(f"Trained weights: {dict(trained.sizes)}.")
    print(trained)
    trained.to_zarr(
        f"fashion_mnist_mlp_"
        f"{datetime.datetime.now().isoformat(timespec='seconds')}.zarr"
    )

Comments

No comments yet. Start the discussion.