kcai-data-sampling-core
The contracts that everything else in kcai data-sampling implements.
A transformation here is one fully specified operation:
T : x ↦ x'
one input sample to one output sample, in the same space. "Fully specified" means the algorithm, its resolved parameters, a seed where the algorithm draws randomness, and a model where one is involved — four things that together determine the output, so a result can be reproduced from its own row.
This package holds no algorithms and no IO. It is deliberately generic: nothing here assumes images, and nothing here touches the disk. It provides the interface in terms of a sample's axes, dtype and value range; the pydantic configuration schema; and the plugin registry that discovers everything else.
What it ships today
api/— the transformation interface (arity, families, model roles), the batch and output contracts, and the matching used to align a transformation against a selection's value range.models/— the pydantic configuration schema, including sweep parameters.utils/— the entry-point plugin registry, the in-memory transformation runner, and model loading.
No algorithms. The methods live in their own packages — see the sibling distributions for the procedural, generative and adversarial examples.
Install
pip install kcai-data-sampling-core
This is the base library. It is also what the umbrella's extras pull in, so most
readers arrive here via pip install "kcai-data-sampling[...]" rather than
installing it directly.
Adding your own method
The rest of this README is a guide: how to decide which family a method belongs to, how to add one to an existing package, how to ship a new package, how to write a model adapter, and one complete worked example.
1. Which family does my method belong to?
You do not choose the family. It is decided by the model's role in producing the output:
| Model role | Family | Meaning |
|---|---|---|
| no model | procedural | computed from x alone |
| tool | generative | the model produces the content that becomes the output |
| target | adversarial | the model's response enters the computation, and the result is graded against it |
The role says where the model plugs in; the family says what the map does. The
two are not independent — the family is derived from the role, so declaring a
role is all you do. An algorithm may declare family explicitly when it needs
to; otherwise the role decides.
What you must not declare is the regime — perturbation, augmentation or
corruption. That is read off the input/output pair afterwards, by comparing
x and x'. It is not a property you assert about your own algorithm.
Transformations listed together are applied independently to the same source sample, not chained.
2. Adding a method to an existing package
Three files, in the shape the repository already uses for its own methods. Take
horizontal_flip as the reference — its math is one line.
1. transformations/<name>.py — the math alone.
A pure function mapping (B, *sample) to (B, *sample), importing nothing from
the framework. Keeping the math separate is what lets you test and reuse it
without instantiating anything.
def horizontal_flip(xs: np.ndarray) -> np.ndarray:
return np.ascontiguousarray(xs[:, :, ::-1, :])
2. configs.py — the parameter schema, next to the algorithm.
Subclass the core TransformationConfig and pin type to the algorithm's
Literal. That pin is how the registry-resolved validator in JobConfig picks
the right schema by type; unknown fields are refused everywhere. A parameter
that can be swept takes a SweepConfig. Enforce the algorithm's ranges in a
model validator — the check works on both a plain value and a sweep interval, so
one call covers both:
class CropResizeTransformationConfig(TransformationConfig):
type: Literal["crop_resize"] = "crop_resize"
fraction: float | SweepConfig = Field(description="Fraction (0, 1] of the image kept.")
@model_validator(mode="after")
def _parameter_bounds(self) -> Self:
self._check_parameter_bounds("fraction", minimum=0, maximum=1, exclusive_min=True)
return self
Keep each algorithm's schema beside that algorithm, in the algorithm's own package. Dependencies go the same way — with the code that needs them, never in the core.
3. api/transformations/<name>.py — the contract.
Subclass UnaryTransformation, declare the algorithm name, point Config at the
schema, set the role flags, and implement only apply:
class HorizontalFlip(UnaryTransformation):
algorithm = "horizontal_flip"
Config = HorizontalFlipTransformationConfig
reversible = True
@override
def apply(self, xs: np.ndarray, rngs: list[np.random.Generator] | None = None) -> np.ndarray:
del rngs
return horizontal_flip(xs)
The flags are declarations, and the base enforces every one of them:
clips = True— your output may leave the selection's value range, and the base clips it back. Leave it unset and an output outside the range is refused, with the offending range in the message. Set it whenever your operation can overshoot.model_role—"tool","target", or unset for no model. The base requires that exactly the model slot named by the role is filled: a tool role with a target model, or neither, is an error at construction. This is also what derives your family.model_methods— the method the role needs:("grad",)for a target,("inpaint",)for a tool. Conformance is checked against the declared role.reversible— isxdetermined byx'? A structural claim, recorded on the output row.horizontal_flipis its own inverse, so yes;fgsmthrows information away, so no.stochastic = True— your algorithm draws randomness. A stochastic algorithm requires a seed, and omitting it fails at construction; a deterministic one recordsNone. When you are stochastic,applyreceives one generator per output row viarngs, seeded from your seed, the parent ids and the resolved parameters — so a given sample draws the same value regardless of batch size or where it lands in the run. Use it rather than a global generator, and you get reproducibility for free.
apply must return one output per input in the same shape. If your map
changes the sample space, the base refuses it — because δ = x' - x has to stay
defined for the output row to mean anything.
Then the entry point, in the package's pyproject.toml:
[project.entry-points."kcai_data_sampling.transformations"]
horizontal_flip = "kcai_data_sampling_images.api.transformations.horizontal_flip:HorizontalFlip"
3. Shipping a new package instead
If your methods do not belong to an existing family package, ship your own distribution. Nothing in the core changes.
Declare an entry point under one of four groups, and the registry discovers it on first use:
kcai_data_sampling.transformationskcai_data_sampling.modelskcai_data_sampling.dataloaderskcai_data_sampling.outputwriter
Two rules follow from the design and are worth stating outright. First, each
algorithm carries its own config schema and its own dependencies — inside its
own package. Second, kcai-data-sampling-core never grows an algorithm or an
IO plugin. If your code needs a library, that library is a dependency of your
package, not of the core.
4. Writing a model adapter
An adapter is recognised by shape: a non-empty name, plus the method its role
needs — grad for a target, inpaint for a tool. name is yours to choose and
is what your config refers to.
Three routes, from least ceremony to owning your own name:
type: pythonwithpath:ormodule:— point at your own source from your config. Nothing is declared, installed or registered.register_model("my_yolo", cls)— for something already in the process; makes a plaintype: my_yoloentry valid.- An entry-point plugin under
kcai_data_sampling.models— keeps ownership of its name, and is never masked byregister_model.
The details of the first route are below, in Loading a user model without a plugin.
5. A complete minimal method
A brighten method, end to end — multiply a uint8 image batch by a factor,
sweepable across factors. Deterministic, clips, not reversible.
# transformations/brighten.py
import numpy as np
def brighten(xs: np.ndarray, factor: float) -> np.ndarray:
"""Scale a uint8 batch by `factor`, in the batch's own dtype range.
The scaling happens in float and is clipped back into `[0, 1]` before the
cast — casting an out-of-range float straight back to uint8 would wrap
around (400 -> 144) instead of saturating.
"""
info = np.iinfo(xs.dtype)
scaled = np.clip(xs.astype(np.float64) * factor / info.max, 0.0, 1.0)
return np.rint(scaled * info.max).astype(xs.dtype)
# configs.py
from typing import Literal, Self
from kcai_data_sampling_core.models.config import TransformationConfig
from kcai_data_sampling_core.models.sweep import SweepConfig
from pydantic import Field, model_validator
class BrightenTransformationConfig(TransformationConfig):
"""Configuration of the `brighten` transformation."""
type: Literal["brighten"] = "brighten"
factor: float | SweepConfig = Field(description="Multiplier applied to every value.")
@model_validator(mode="after")
def _parameter_bounds(self) -> Self:
# The upper bound is above 1 on purpose: a factor may brighten past the
# selection's value range, which is what `clips = True` is for below.
self._check_parameter_bounds("factor", minimum=0, maximum=4)
return self
# api/transformations/brighten.py
import numpy as np
from typing_extensions import override
from kcai_data_sampling_core.api.unary import UnaryTransformation
from my_package.configs import BrightenTransformationConfig
from my_package.transformations.brighten import brighten
class Brighten(UnaryTransformation):
"""Scale sample values by `factor`."""
algorithm = "brighten"
Config = BrightenTransformationConfig
# `factor > 1` overshoots the selection's value range, so declare `clips`
# and let the base clip back. (The math above clips too, but to the batch's
# *dtype* range; this flag is what covers a selection whose own value_range
# is narrower than that.) Scaling discards information: not reversible.
clips = True
reversible = False
@override
def apply(self, xs: np.ndarray, rngs: list[np.random.Generator] | None = None) -> np.ndarray:
del rngs
return brighten(xs, self.params["factor"])
# pyproject.toml
[project.entry-points."kcai_data_sampling.transformations"]
brighten = "my_package.api.transformations.brighten:Brighten"
No model_role and no stochastic: brighten uses no model and draws nothing,
so the family is derived as procedural, the recorded seed is None, and
apply ignores the generators it is handed.
That is the whole contract. The example has nothing to do with images — the same three files work for any sample space, which is why the core does not assume images.
Loading a user model without a plugin
A models: entry can reference your own code instead of a registered plugin
package. Three routes, from the least ceremony to shipping your own package:
-
The file route —
type: pythonwithpath:(ormodule:), pointers to your own source, resolved atJobConfigload:models: my_yolo: type: python path: adapters/yolo_target.py # absolute, or relative to the working directory export: YoloTarget # optional; defaults to the models: key name weights: yolov8n.pt # optional; passed to the adapter constructor
-
The
register_modelroute — in-process registration makes a plaintype: my_yoloentry valid:register_model("my_yolo", cls)from a kernel or a setup hook. -
The plugin route — a package declaring a
kcai_data_sampling.modelsentry point. An entry-point plugin keeps ownership of its name and is never masked by theregister_modelroute.
type: pythonexecutes the referenced file or module by design — that is the bring-your-own-code feature. The file is yours and is named from your own config; nothing is downloaded and nothing is sandboxed.exportis a dotted attribute path to a class, a factory callable, or an instance. Unset, the module attribute named like themodels:key is used; else the sole model-shaped symbol (a non-emptynameplus agrad/inpaintmethod); elseJobConfigvalidation fails, listing the candidates.path:resolves absolute-first, then relative to the working directory;module:(an importable dotted path) is the spelling for a package that needs relative imports.
A type: python reference is refused at JobConfig load when its file is
missing, its export names nothing, or no model-shaped symbol is exposed.
Metadata
Release files for kcai-data-sampling-core 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| kcai_data_sampling_core-0.1.0.tar.gz | 40.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| kcai_data_sampling_core-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 86.0 kB
Release files / kcai_data_sampling_core-0.1.0.tar.gz
| Download URL | kcai_data_sampling_core-0.1.0.tar.gz |
|---|---|
| Size | 40.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
435c5eab45ea6742107df11c9c1fcf7bcf611c425c516d4d7e4c096d1b237363
|
|
BLAKE2b-256 checksum How to use checksums |
6c6a6a76c83a4416f26bee8c14be38dc5666871d2b9a936346468aeead8c5a44
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.8.17
|
Release files / kcai_data_sampling_core-0.1.0-py3-none-any.whl
| Download URL | kcai_data_sampling_core-0.1.0-py3-none-any.whl |
|---|---|
| Size | 45.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1d2189ba20c6132dbec6cc7e97fd4418c076db92afeaaf25dbb65b2b2d5fd37d
|
|
BLAKE2b-256 checksum How to use checksums |
d8fc0301eaa36006566ce98bc8c406e6d7fb1375b82c75848d3a9ef988f2698d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.8.17
|