Skip to content
SR-Forge

IO Binding

IO binding connects your components to Entry fields. A model with a forward(image) parameter can read from entry["satellite_rgb"] in one experiment and entry["camera_input"] in another — same model, different data.

This page is the comprehensive reference for all binding syntax. For concept guides, see: Model | DataTransform | EntryTransform | SequentialModel


The Big Idea, Step by Step

If you've never used a routing layer like this before, here's the mental model in five short steps. Skip ahead if you already grok it.

1. The problem: rigid coupling

A naive model signature couples the parameter name to a specific data field:

def forward(self, satellite_rgb):
    return self.net(satellite_rgb)

Now this model only works on entry["satellite_rgb"]. Plug it into a different dataset (say one with entry["camera_input"]) and you have to rewrite the model — or rewrite the dataset to use the model's preferred name. Neither scales.

2. The two-name idea

Decouple them. Give the component two layers of naming:

  • Parameter names — what the component's code uses internally. Stable across experiments.
  • Field names — what's in Entry. Specific to whatever dataset is loaded.

A small routing layer between them maps "the value behind this parameter name" to "the value at this Entry field name." That's IO binding.

3. What that looks like in practice

The same Upscaler with both names made explicit:

class Upscaler(Model):
    def forward(self, image):                # ← parameter name
        return self.net(image)

model = Upscaler()
model.set_io({"inputs": {"image": "satellite_rgb"}})   # ← routing

Read this as: "When you need image, read it from entry["satellite_rgb"]."

In a different experiment, the same model class with different routing:

model.set_io({"inputs": {"image": "camera_input"}})    # ← different field, same model

The model's code doesn't change. Only the routing does.

4. Reading the syntax

The routing dict is always:

{"inputs": {param_name: field_name, ...}, "outputs": ...}
  • inputs maps parameter names (LHS) to Entry field names (RHS).
  • outputs says where to write results (a string or list of strings — for Models, where the prediction goes; some components don't have outputs and can omit it).

The LHS (parameter name) is determined by your component's code — you can't change it without editing the code. The RHS (field name) is determined by your data / experiment — you change it via YAML.

5. Where the routing lives in YAML

In a config, the routing goes under an io: block next to the component's _target + params:

model:
  _target: my_models.Upscaler
  params:
    channels: 64
  io:
    inputs: {image: satellite_rgb}      # ← routing for this run
    outputs: prediction

Want a different routing? Change the YAML, not the Python.

That's the entire concept. The rest of this page is the syntax reference for each component type — Model, DataTransform, EntryTransform, Loss, SequentialModel — and the edge cases (multiple applications, dict-valued fields, nested wrappers).


All Binding Methods

Method Where When to Use
set_io(...) Python code Programmatic setup, tests
io: key in YAML Config files Config-driven instantiation
Flow DSL: x -> module -> y SequentialModel Multi-stage pipelines

The One Format

Model, DataTransform, and Loss use the same set_io() format. EntryTransform uses constructor _key params instead (see below).

component.set_io({
    "inputs": {param: entry_field, ...},
    "outputs": <string | list>,
})
  • inputs: A dict mapping method parameter names to Entry field names, or a list of such dicts for multiple applications.
  • outputs: A string, list, or omitted. Determines where results are written.

When can you omit outputs?

Only for DataTransform with a single input that returns a single value. The result is written back to the input field (in-place). If there are multiple inputs or multiple return values, outputs is required. Model always requires outputs.


Model

Model inputs are inferred from forward() parameter names. Output field names come from the outputs key.

set_io() in Python

Single output:

class Upscaler(Model):
    def forward(self, image):
        return self.net(image)

model = Upscaler()
model.set_io({"inputs": {"image": "image_rgb"}, "outputs": "prediction"})
# "image" matches forward() param → reads entry["image_rgb"]
# "prediction" → writes entry["prediction"]

YAML io: Config

model:
  _target: Upscaler
  params:
    channels: 128
  io:
    inputs:
      image: image_rgb
    outputs: prediction

Multiple Inputs

Models with multiple forward() parameters:

class FusionModel(Model):
    def forward(self, image, guide):
        return self.net(image, guide)

model.set_io({
    "inputs": {"image": "x", "guide": "edges"},
    "outputs": "result",
})
  io:
    inputs:
      image: x
      guide: edges
    outputs: result

Multiple Outputs

When forward() returns multiple values, use a list of output names or positional mapping in the flow DSL:

With set_io() — list of output field names:

class FusionModel(Model):
    def forward(self, image, guide):
        result, attn = self.net(image, guide)
        return result, attn

model = FusionModel()
model.set_io({
    "inputs": {"image": "x", "guide": "edges"},
    "outputs": ["result", "attn"],
})
# 1st return value → entry["result"]
# 2nd return value → entry["attn"]

With flow DSL — positional mapping:

flow = ["(x, edges) -> fuse -> (result, attn)"]
# 1st return value → entry["result"]
# 2nd return value → entry["attn"]

Optional Inputs

Parameters with defaults in forward() are optional — they can be omitted from the IO config, and the default value applies:

class MyModel(Model):
    def forward(self, image, guide=None):
        if guide is not None:
            return self.guided_net(image, guide)
        return self.net(image)

# OK — guide omitted from inputs, default (None) applies
model.set_io({"inputs": {"image": "x"}, "outputs": "y"})
result = model(Entry(x=tensor))  # guide=None

# OK — guide mapped and field exists
model.set_io({"inputs": {"image": "x", "guide": "edges"}, "outputs": "y"})
result = model(Entry(x=tensor, edges=edge_tensor))  # guide=edge_tensor

# FAILS — guide mapped but field missing
model.set_io({"inputs": {"image": "x", "guide": "edges"}, "outputs": "y"})
result = model(Entry(x=tensor))  # KeyError: field 'edges' not found

The rule is the same for both Model and DataTransform: if you map it, it must exist.

SequentialModel Flow DSL

Syntax Example Notes
Single input/output x -> m1 -> y Inputs by signature order
Multi-input (x, edges) -> m1 -> y Positional by signature order
Multi-output (x, g) -> m1 -> (result, conf) Positional return values
Named inputs (image=x, guide=edges) -> m1 -> y Explicit parameter mapping
Reuse x -> enc -> h1 then h1 -> enc -> h2 Shared weights, different fields

Models are fully reusable — the same instance can appear in multiple flow lines with shared weights. Each flow line provides its own IO mapping.


DataTransform

DataTransform inputs are inferred from transform() or transform_unbatched() parameter names. Type annotations control recursion into containers.

Single-Parameter Transforms

For transforms with one parameter (e.g., transform(self, image: Tensor)):

In-place — transform and write back to the same field:

t.set_io({"inputs": {"image": "x"}})
# Reads entry["x"], writes result back to entry["x"]

With new output name:

t.set_io({"inputs": {"image": "x"}, "outputs": "x_scaled"})
# Reads entry["x"], writes to entry["x_scaled"]

Multiple fields — apply the same transform to each field independently:

t.set_io({"inputs": [{"image": "x"}, {"image": "y"}]})
# In-place on both: entry["x"] and entry["y"]

t.set_io({
    "inputs": [{"image": "x"}, {"image": "y"}],
    "outputs": ["x_scaled", "y_scaled"],
})
# Reads entry["x"] → writes entry["x_scaled"]
# Reads entry["y"] → writes entry["y_scaled"]

YAML equivalents:

# In-place on single field
io:
  inputs:
    image: x

# With new output name
io:
  inputs:
    image: x
  outputs: x_scaled

# Multiple fields, in-place
io:
  inputs:
    - {image: x}
    - {image: y}

# Multiple fields with different output names
io:
  inputs: [{image: x}, {image: y}]
  outputs: [x_scaled, y_scaled]

Multi-Parameter Transforms

When transform() takes multiple parameters, map each to an Entry field:

# MatchReference: transform(self, image: Tensor, reference: Tensor)
t.set_io({
    "inputs": {"image": "lrs", "reference": "hr"},
    "outputs": "matched",
})
io:
  inputs:
    image: lrs
    reference: hr
  outputs: matched

Multiple Outputs

When transform() returns a tuple, map each value to a field using a list of output names:

class SplitChannels(DataTransform):
    def transform(self, image: torch.Tensor):
        return image[:, :3], image[:, 3:]

t = SplitChannels()
t.set_io({"inputs": {"image": "x"}, "outputs": ["rgb", "extra"]})
io:
  inputs:
    image: x
  outputs: [rgb, extra]   # Unpacked from the returned tuple

Multiple Applications

Apply the same transform to different fields independently:

# Single-parameter, multiple fields
t.set_io({"inputs": [{"image": "x"}, {"image": "y"}]})  # In-place on both

# Multi-parameter, multiple applications
t.set_io({
    "inputs": [
        {"image": "a1", "reference": "b1"},
        {"image": "a2", "reference": "b2"},
    ],
    "outputs": ["o1", "o2"],
})
# Application 1: transform(image=entry["a1"], reference=entry["b1"]) → entry["o1"]
# Application 2: transform(image=entry["a2"], reference=entry["b2"]) → entry["o2"]

Multiple applications with multiple outputs per application:

t.set_io({
    "inputs": [
        {"image": "a1", "reference": "b1"},
        {"image": "a2", "reference": "b2"},
    ],
    "outputs": [["o1a", "o1b"], ["o2a", "o2b"]],
})

In-Place Transforms

DataTransforms can overwrite the source field — unlike Models, which cannot overwrite existing Entry fields:

t.set_io({"inputs": {"image": "x"}})  # entry["x"] replaced with transformed result

In SequentialModel:

x -> mul -> x           # entry["x"] overwritten

Optional Parameters

Parameters with defaults in transform() are optional — they can be omitted from the IO config:

class AddNoise(DataTransform):
    def transform(self, image: Tensor, mask: Tensor = None):
        ...

noise = AddNoise()

# OK — mask omitted, default (None) applies
noise.set_io({"inputs": {"image": "x"}, "outputs": "noisy"})

# OK — mask mapped and field exists
noise.set_io({"inputs": {"image": "x", "mask": "m"}, "outputs": "noisy"})

# FAILS — mask mapped but field missing → KeyError
noise.set_io({"inputs": {"image": "x", "mask": "m"}, "outputs": "noisy"})
noise(Entry(x=tensor))  # KeyError: field 'm' not found

SequentialModel Flow DSL

Syntax Example Effect
Single input/output x -> mul -> y Inputs by signature order
Multi-input (x, ref) -> t -> y Positional by signature order
Multi-output x -> t -> (a, b) Positional return values
Named inputs (image=x, reference=ref) -> t -> out Explicit parameter mapping
In-place x -> mul -> x Overwrites entry["x"]
Reuse x -> mul -> x2 then y -> mul -> y2 Same instance, different fields

DataTransforms are fully reusable — the same instance can appear in multiple flow lines. No instance state is mutated; applications are computed externally per step.


Model vs DataTransform

Model and DataTransform share the same IO binding interface. The table below highlights what's identical and where they differ.

Feature Model DataTransform
set_io() format {"inputs": {...}, "outputs": ...} Same
YAML io: key Same Same
Flow DSL (positional) x -> m -> y Same
Flow DSL (named) (image=x) -> m -> y Same
Optional parameters Omit from inputs, default applies Same
Multiple outputs "outputs": ["a", "b"] Same
Reusable in flow Yes (shared weights) Yes (stateless)
Omit outputs (in-place) No — outputs always required Yes — single-input writes back
Multiple applications No Yes — inputs as list of dicts
Container recursion No Yes (annotation-driven)
Field overwrite protection Yes — cannot overwrite existing fields No — can overwrite (in-place)

Both use the same config format and flow DSL syntax. The key differences are that DataTransform supports in-place overwrites, multiple applications, and annotation-driven container recursion — features that don't apply to neural network forward passes.


EntryTransform

EntryTransform uses constructor-based field keys — field names are passed as _key parameters in __init__(). No set_io() needed.

Constructor Field Keys

Field name parameters use the _key suffix to distinguish them from data:

from srforge.transform import EntryTransform

class SelectBands(EntryTransform):
    def __init__(self, bands, *, multispectral_key: str, selected_key: str):
        self.bands = bands
        self.multispectral_key = multispectral_key
        self.selected_key = selected_key
        super().__init__()

    def transform_unbatched(self, entry):
        ms = entry[self.multispectral_key]
        entry[self.selected_key] = ms[self.bands]
        return entry

Use it:

t = SelectBands([0, 1, 2], multispectral_key="raw_ms", selected_key="rgb_only")
entry = t(entry)
# reads entry["raw_ms"], writes entry["rgb_only"]

Optional Field Keys

Use Optional[str] = None for optional fields. Check before accessing:

class MaskTransform(EntryTransform):
    def __init__(self, *, field_key: str, mask_key: str = None):
        self.field_key = field_key
        self.mask_key = mask_key
        super().__init__()

    def transform_unbatched(self, entry):
        value = entry[self.field_key]
        if self.mask_key and self.mask_key in entry:
            value = value * entry[self.mask_key]
        entry[self.field_key] = value
        return entry

YAML Config

Field keys go in params: — no io: key:

select:
  _target: SelectBands
  params:
    bands: [0, 1, 2]
    multispectral_key: raw_ms
    selected_key: rgb_only

No io: key for EntryTransforms

Using io: with an EntryTransform raises TypeError. Move field keys into params:.

SequentialModel Flow DSL

EntryTransforms are opaque steps — the flow DSL just marks when to run the transform. Field mapping is handled by the constructor:

seq = SequentialModel(
    modules={"select": SelectBands([0, 1, 2], multispectral_key="raw_ms", selected_key="rgb")},
    flow=[" -> select -> "],
)

For different field mappings, use separate instances:

seq = SequentialModel(
    modules={
        "copy1": CopyFields(field_key="y", output_key="z"),
        "copy2": CopyFields(field_key="z", output_key="w"),
    },
    flow=[" -> copy1 -> ", " -> copy2 -> "],
)

Loss

Losses use set_io() with inputs only — they read from Entry fields and return MetricScores, never writing back. See the Losses guide for full details.

from srforge.loss.metrics import L1

loss = L1(weight=1.0)
loss.set_io({"inputs": {"x": "sr", "y": "hr"}})

scores = loss(entry)  # reads entry["sr"] → x, entry["hr"] → y

Identity default — when set_io() is not called, parameter names map to same-named Entry fields:

loss = L1()
scores = loss(Entry(x=pred, y=target))  # x → x, y → y

No outputs — passing outputs raises TypeError. Losses don't write to Entry.

No multiple applicationsinputs must be a single dict, not a list.

YAML

- _target: L1
  params:
    weight: 1.0
  io:
    inputs: {x: sr, y: hr}

SequentialModel: All Module Types

Inside a SequentialModel, Models and DataTransforms get IO from the flow DSL. EntryTransforms get their field keys from the constructor:

model:
  _target: srforge.models.SequentialModel
  params:
    modules:
      m1:
        _target: MyModel
        params: {}
      t1:
        _target: srforge.transform.entry.CopyFields
        params:
          field_key: "y"
          output_key: "z"
      mul:
        _target: srforge.transform.data.Multiply
        params:
          value: 2.0
    flow:
      - "x -> m1 -> y"           # Model: positional mapping
      - " -> t1 -> "             # EntryTransform: opaque
      - "z -> mul -> out"        # DataTransform: positional mapping

Key differences between module types in flow

  • Model: Supports named input mapping ((image=x) -> m1). Outputs are always positional.
  • EntryTransform: Opaque — field keys set via constructor _key params.
  • DataTransform: Positional or named inputs. Outputs are always positional.

Standalone Use (No Entry)

Model and DataTransform can be called directly with raw values, bypassing IO binding:

# Model — no set_io() needed
output = model(some_tensor)               # calls forward() directly

# DataTransform (batched)
downsampled = Downsample()(some_tensor)    # calls transform() directly

# DataTransform (per-sample)
result = MyPerSampleTransform()(some_tensor)  # calls transform() which dispatches

EntryTransform always requires an Entry — it operates on Entry structure.


Validation and Errors

Missing Required Constructor Key

# EntryTransform missing required field key
SelectBands(bands=[0, 1, 2], multispectral_key="x")
# TypeError: missing required keyword argument 'selected_key'

Unbound DataTransform

transform = Multiply(2.0)
transform(entry)  # No set_io called
# ValueError: Multiply requires io mappings before it can run.

Dict Outputs Not Supported (for plain Model)

# Dict outputs are not allowed for plain Model — use a string or list instead
model.set_io({
    "inputs": {"image": "x"},
    "outputs": {"output": "y"},  # TypeError!
})

# Correct:
model.set_io({"inputs": {"image": "x"}, "outputs": "y"})
model.set_io({"inputs": {"image": "x"}, "outputs": ["y", "z"]})

GANModel is the exception

GANModel.set_io requires outputs to be a dict mapping its four fixed port names (real_score_d, fake_score_d, real_score_g, fake_score_g) to Entry field names — or omitted entirely (identity default). Passing a string or list raises TypeError. See GAN Model for details.

# GANModel — outputs is a dict (or omitted for identity defaults):
gan.set_io({
    "inputs": {"real_field": "hr", "fake_field": "sr"},
    "outputs": {
        "real_score_d": "d_real",
        "fake_score_d": "d_fake",
        "real_score_g": "g_real",
        "fake_score_g": "g_fake",
    },
})

Wrong Format

# OLD (removed): t.set_io("x")
# OLD (removed): t.set_io(["x", "y"])
# OLD (removed): t.set_io({"x": "y"})
# These all raise TypeError — use the structured format instead.

t.set_io({"inputs": {"image": "x"}})          # Correct
t.set_io({"inputs": {"image": "x"}, "outputs": "y"})  # Correct

Summary

Scenario What to Do
Writing a test or script component.set_io({"inputs": {...}, "outputs": ...})
Defining an experiment config Add io: with inputs/outputs keys in YAML
Building a SequentialModel Let the flow DSL handle mapping
Prototyping in a notebook Call the component directly with raw values

Next: Models — Neural network components that use IO binding