VyntriVyntri
Notebooks

04 — Continual Learning

Incremental updates with new classes using sufficient statistics.

Vyntri's defining feature: update() adds new data — new examples of known classes and brand-new classes — analytically, from sufficient statistics.

Setup

imports
from pathlib import Path
import numpy as np
from PIL import Image
from vyntri import Vyntri
from vyntri.data import split

def make_solid(root, classes, per_class, seed=0):
    colors = {'cats': (200, 90, 40), 'dogs': (40, 180, 70), 'birds': (50, 80, 200)}
    root = Path(root)
    rng = np.random.default_rng(seed)
    for name in classes:
        folder = root / name
        folder.mkdir(parents=True, exist_ok=True)
        for j in range(per_class):
            noise = rng.normal(0, 6, (32, 32, 3))
            base = np.asarray(colors[name], dtype=np.float64).reshape(1, 1, 3)
            img = (base + noise).clip(0, 255).astype(np.uint8)
            Image.fromarray(img).save(folder / f'{name}_{j:02d}.png')
    return root

DATA = Path('continual_data')
initial = make_solid(DATA / 'initial', ['cats', 'dogs'], per_class=12, seed=1)
new_data = make_solid(DATA / 'new_data', ['cats', 'birds'], per_class=10, seed=2)
test = make_solid(DATA / 'test', ['cats', 'dogs', 'birds'], per_class=8, seed=99)
print('initial classes : cats, dogs')
print('update data     : more cats + NEW class birds')

1. Fit on initial data

fit
s = split(str(initial), train=0.7, val=0.1, test=0.2, seed=42)
model = Vyntri(seed=42)
model.fit(train=s.train, val=s.val, progress=False)
print('classes after fit   :', list(model.classes_))
print('validation accuracy :', round(model.validation_accuracy_, 3))

2. Update with old class + new class

update() accepts mixed batches — new examples of cats and a brand-new birds class in one call.

update
result = model.update(str(new_data))
print('classes before :', result.classes_before)
print('classes after  :', result.classes_after)
print('new classes    :', result.new_classes)
print('samples added  :', result.n_samples)

3. Class registry is stable

Existing class ids are never renumbered; new classes get new ids.

registry
print('class -> id mapping:', model.class_to_idx_)
img = str(test / 'birds' / 'birds_00.png')
print(model.predict(img))

4. Sequential == joint (measured)

An updated model matches a model refit on all the data. The sufficient-statistics update re-solves the same joint solution.

sequential_joint
import shutil
from vyntri.data import FolderDataset

# Create joint dataset (all data combined)
joint_dir = DATA / 'joint'
if joint_dir.exists():
    shutil.rmtree(joint_dir)
for name in ['cats', 'dogs']:
    shutil.copytree(initial / name, joint_dir / name)
shutil.copytree(new_data / 'cats', joint_dir / 'cats', dirs_exist_ok=True)
shutil.copytree(new_data / 'birds', joint_dir / 'birds')

# Fit joint model on the same split
joint = Vyntri(seed=42)
joint.fit(train=s.train, val=s.val, progress=False)

# Compare
test_ds = FolderDataset(str(test))
seq_acc = model.evaluate(test_ds).accuracy
joint_acc = joint.evaluate(test_ds).accuracy
print(f'updated model (sequential): test acc = {seq_acc:.3f}')
print(f'joint refit model          : test acc = {joint_acc:.3f}')

agree = sum(
    model.predict(p).label == joint.predict(p).label
    for p in test.rglob('*.png')
)
print(f'predictions agree on {agree}/{len(list(test.rglob("*.png")))} test images')

5. Save, load, and update again

The sufficient statistics travel with the saved model, so a loaded model can keep learning without any historical raw data.

save_load_update
model.save('continual.vyntri')
loaded = Vyntri.load('continual.vyntri')
more = make_solid(DATA / 'even_more', ['birds'], per_class=6, seed=3)
res = loaded.update(str(more))
print('after load + update:', res.classes_after, '| new:', res.new_classes)