Contributing a New Layer¶
PyC layers are the semantic primitives of the Low-Level API. Every layer extends
BaseConceptLayer — a thin PyTorch
nn.Module
wrapper that standardises how inputs and outputs are described in terms of concepts,
embeddings, and their cardinalities. New layers slot directly into both the Low-Level API and
the mid-level ParametricCPD system.
Expand each block below for a step-by-step walkthrough.
Layer Interface
All layers extend BaseConceptLayer. The constructor accepts three optional dimension
descriptors — pass an Annotations object instead of an int
to make the layer semantics-aware:
class BaseConceptLayer(ABC, torch.nn.Module):
def __init__(
self,
out_concepts: Union[int, Annotations],
in_concepts: Union[int, Annotations] = None,
in_embeddings: Union[int, Annotations] = None,
): ...
After super().__init__(...) three resolved-integer attributes are available:
self.in_concepts_shape—int(orNoneif not passed)self.in_embeddings_shape—int(orNoneif not passed)self.out_concepts_shape—int
These always hold plain integers regardless of whether you passed an Annotations
object or an int, so you can use them directly in nn.Linear / nn.Conv.
Naming convention. Layer class names follow
<OperationType><InputType>To<OutputType>, e.g.:
LinearEmbeddingToConcept— linear map from embeddings to concept scoresLinearConceptToConcept— linear map from concept scores to concept scoresMixConceptEmbeddingToConcept— mixes concept scores with their embeddings
forward keyword arguments mirror the input types:
use concepts= for concept-score tensors and embeddings= for embedding tensors.
This lets callers be explicit and lets ParametricCPD route inputs correctly.
Minimal Example
Below is a complete new layer — a concept-score normaliser that applies layer-norm before passing concepts through a linear map:
# torch_concepts/nn/modules/low/predictors/normed.py
from typing import Union
import torch
import torch.nn as nn
from torch_concepts import Annotations, AnnotatedTensor
from torch_concepts.nn.modules.low.base.layer import BaseConceptLayer
class NormedConceptToConcept(BaseConceptLayer):
"""Layer-normalised linear concept predictor.
Applies layer-norm to the input concept scores before the linear map.
Useful when concept activations vary widely in scale.
Args:
in_concepts: Input concept dimension (int or Annotations).
out_concepts: Output concept dimension (int or Annotations).
"""
def __init__(
self,
in_concepts: Union[int, Annotations],
out_concepts: Union[int, Annotations],
):
super().__init__(in_concepts=in_concepts, out_concepts=out_concepts)
self.norm = nn.LayerNorm(self.in_concepts_shape)
self.predictor = nn.Linear(self.in_concepts_shape, self.out_concepts_shape)
def forward(self, concepts: torch.Tensor) -> torch.Tensor:
x = self.norm(concepts)
x = self.predictor(x)
# If out_concepts is Annotations, wrap output as AnnotatedTensor
return self.annotate(x)
Instantiation and forward pass:
import torch
import torch_concepts as pyc
annotations = pyc.Annotations(
labels=["smoking", "genotype", "tar"],
cardinalities=[1, 3, 1],
types=["binary", "categorical", "continuous"],
)
# Plain int — no annotation on output
layer = NormedConceptToConcept(in_concepts=5, out_concepts=2)
out = layer(concepts=torch.randn(8, 5)) # (8, 2)
# Annotations — output is an AnnotatedTensor
layer = NormedConceptToConcept(in_concepts=annotations, out_concepts=2)
out = layer(concepts=torch.randn(8, 5)) # AnnotatedTensor (8, 2)
print(out["genotype"]) # slice by name
Semantics-Aware Layers
Passing an Annotations object for in_concepts or
out_concepts unlocks semantics-awareness:
self.in_concepts(orout_concepts) holds the fullAnnotationsobject.self.in_concepts_shapestill holds the corresponding integer (annotations.size).Call
self.annotate(x)(no arguments) to wrap the output as anAnnotatedTensor— this usesself.out_conceptsif it is anAnnotations.
Use the annotations inside the layer when you need per-concept branching, e.g. grouping columns by cardinality or type:
class TypeAwareConceptLayer(BaseConceptLayer):
def __init__(self, in_concepts: Annotations, out_concepts: int):
super().__init__(in_concepts=in_concepts, out_concepts=out_concepts)
# Build one head per concept type
binary_size = sum(
c for c, t in zip(in_concepts.cardinalities, in_concepts.types)
if t == "binary"
)
self.binary_head = nn.Linear(binary_size, out_concepts)
# ... other heads
def forward(self, concepts: torch.Tensor) -> torch.Tensor:
ann: Annotations = self.in_concepts
binary = concepts[:, [i for i, t in enumerate(ann.types) if t == "binary"]]
return self.binary_head(binary)
MixConceptEmbeddingToConcept is the canonical example —
it requires Annotations for in_concepts so it can split columns by cardinality
and concept type.
Using a Layer inside a ParametricCPD
Layers integrate directly into the Mid-Level API through
ParametricCPD. Pass your layer as the value under the
distribution-parameter key that the target variable’s distribution expects:
import torch_concepts as pyc
from torch_concepts import ConceptVariable
from torch.distributions import Bernoulli
from torch_concepts.nn import ParametricCPD
from normed import NormedConceptToConcept # the layer above
latent_var = pyc.EmbeddingVariable("latent", distribution=pyc.distributions.Delta, size=64)
concept = ConceptVariable("smoking", distribution=Bernoulli)
cpd = ParametricCPD(
concept,
parents=[latent_var],
parametrization={
"logits": NormedConceptToConcept(in_concepts=64, out_concepts=1),
},
)
The parametrization dict key must match the distribution’s constructor parameter
(logits or probs for Bernoulli/OneHotCategorical, loc/scale for
Normal).
Registering the Layer
Add the file. Place your layer under the appropriate subdirectory:
torch_concepts/nn/modules/low/encoders/— if it maps from embeddingstorch_concepts/nn/modules/low/predictors/— if it maps from concept scoresCreate a new subdirectory if neither fits.
Export it. Add the class to
torch_concepts/nn/__init__.py:from .modules.low.predictors.normed import NormedConceptToConcept __all__ = [..., "NormedConceptToConcept"]
Document it. Add an
autoclassentry indoc/modules/low_level_api.rst:.. autoclass:: torch_concepts.nn.NormedConceptToConcept :members:
Test it. Add a test in
tests/nn/modules/low/that checks the output shape for bothintandAnnotationsinputs, and thatAnnotatedTensoris returned whenout_conceptsis anAnnotations.
Next Steps¶
See how layers compose into probabilistic models: Contributing a New Model.
Browse the Low-Level API reference: Low-Level API.
Check existing layer implementations in
torch_concepts/nn/modules/low/.