Repository: aphp/edsnlp (language: python)

Issue:
Feature request: better API for adding pipes to a pipeline
## Feature type

Adding a pipe to a pipeline has quite a few limitations at the moment:

```python
import edsnlp

nlp = edsnlp.blank('eds')
nlp.add_pipe('eds.matcher', config={"terms": {"key": ["expr 1", "expr 2"]}})
...
```

- there is no easy way of knowing which pipes are available from the notebook / IDE, and there is no autocompletion
- all pipe parameters are nested in a configuration dict, which is cumbersome
- there is no autocompletion of these parameters, since they are passed via a configuration dict

We can deviate from spacy iconic API and think of something better along these lines:

```python
import edsnlp
import edsnlp.pipes as eds

nlp = edsnlp.blank('eds')
nlp.add_pipe(eds.matcher(terms={"key": ["expr 1", "expr 2"]}))
```

The problem is, some pipes (like `eds.matcher`) requires an nlp object at init time which is given by `add_pipe`. We could ask the user to provide the nlp argument `nlp.add_pipe(eds.matcher(nlp=nlp, terms={"key": ["expr 1", "expr 2"]}))` but this feels redundant.

Another option is to have `promise = eds.matcher(terms={"key": ["expr 1", "expr 2"]})` return a "promise"/"curried" component *if a required nlp attribute is missing*, which is actually instantiated when it is added to the pipeline (via `promise.instantiate(nlp=self)`). This feels like an anti-pattern, and therefore should be extensively documented, and produce warnings whenever a user tries to use a non-initialized pipe outside a pipeline.

<!-- Add a clear description of what you'd like EDS-NLP to handle. -->



Discussion / hints:

Fix patch:
diff --git a/changelog.md b/changelog.md
index b44cc54fc..f18e50ab3 100644
--- a/changelog.md
+++ b/changelog.md
@@ -7,30 +7,46 @@
 - Support for a `filesystem` parameter in every `edsnlp.data.read_*` and `edsnlp.data.write_*` functions
 - Pipes of a pipeline are now easily accessible with `nlp.pipes.xxx` instead of `nlp.get_pipe("xxx")`
 - Support builtin Span attributes in converters `span_attributes` parameter, e.g.
-    ```python
-    import edsnlp
+  ```python
+  import edsnlp
 
-    nlp = ...
-    nlp.add_pipe("eds.sentences")
+  nlp = ...
+  nlp.add_pipe("eds.sentences")
 
-    data = edsnlp.data.from_xxx(...)
-    data = data.map_pipeline(nlp)
-    data.to_pandas(converters={"ents": {"span_attributes": ["sent.text", "start", "end"]}})
-    ```
+  data = edsnlp.data.from_xxx(...)
+  data = data.map_pipeline(nlp)
+  data.to_pandas(converters={"ents": {"span_attributes": ["sent.text", "start", "end"]}})
+  ```
 - Support assigning Brat AnnotatorNotes as span attributes: `edsnlp.data.read_standoff(...,  notes_as_span_attribute="cui")`
 - Support for mapping full batches in `edsnlp.processing` pipelines with `map_batches` lazy collection method:
-    ```python
-    import edsnlp
+  ```python
+  import edsnlp
+
+  data = edsnlp.data.from_xxx(...)
+  data = data.map_batches(lambda batch: do_something(batch))
+  data.to_pandas()
+  ```
+- New `data.map_gpu` method to map a deep learning operation on some data and take advantage of edsnlp multi-gpu inference capabilities
+- Added average precision computation in edsnlp span_classification scorer
+- You can now add pipes to your pipeline by instantiating them directly, which comes with many advantages, such as auto-completion, introspection and type checking !
+
+  ```python
+  import edsnlp, edsnlp.pipes as eds
+
+  nlp = edsnlp.blank("eds")
+  nlp.add_pipe(eds.sentences())
+  # instead of nlp.add_pipe("eds.sentences")
+  ```
 
-    data = edsnlp.data.from_xxx(...)
-    data = data.map_batches(lambda batch: do_something(batch))
-    data.to_pandas()
-    ```
+  *The previous way of adding pipes is still supported.*
+- New `eds.span_linker` deep-learning component to match entities with their concepts in a knowledge base, in synonym-similarity or concept-similarity mode.
 
 ### Changed
 
 - `nlp.preprocess_many` now uses lazy collections to enable parallel processing
 - :warning: Breaking change. Improved and simplified `eds.span_qualifier`: we didn't support combination groups before, so this feature was scrapped for now. We now also support splitting values of a single qualifier between different span labels.
+- Optimized edsnlp.data batching, especially for large batch sizes (removed a quadratic loop)
+- :warning: Breaking change. By default, the name of components added to a pipeline is now the default name defined in their class `__init__` signature. For most components of EDS-NLP, this will change the name from "eds.xxx" to "xxx".
 
 ### Fixed
 
@@ -38,7 +54,7 @@
 - Allow span pooler to choose between multiple base embedding spans (as likely produced by `eds.transformer`) by sorting them by Dice overlap score.
 - EDS-NLP does not raise an error anymore when saving a model to an already existing, but empty directory
 
-## v0.10.7
+## v0.10.7 (2024-03-12)
 
 ### Added
 
@@ -57,7 +73,7 @@
 - Fix automatic `batch_size` estimation in `eds.transformer` when `max_tokens_per_device` is set to `auto` and multiple GPUs are used
 - Fix JSONL file parsing
 
-## v0.10.6
+## v0.10.6 (2024-02-24)
 
 ### Added
 
@@ -92,13 +108,13 @@
 - Disabled pipes are now correctly ignored in the `Pipeline.preprocess` method
 - Add "eventuel*" patterns to `eds.hyphothesis`
 
-## v0.10.5
+## v0.10.5 (2024-01-29)
 
 ### Fixed
 
 - Allow non-url paths when parquet filesystem is given
 
-## v0.10.4
+## v0.10.4 (2024-01-19)
 
 ### Changed
 
@@ -116,7 +132,7 @@
 - Both edsnlp & spacy factories are now listed when a factory lookup fails
 - Fixed some GPU OOM errors with the `eds.transformer` pipe when processing really long documents
 
-## v0.10.3
+## v0.10.3 (2024-01-11)
 
 ### Added
 
@@ -130,7 +146,7 @@
 - Spaces and digits are now supported in BRAT entity labels
 - Fixed missing 'permet pas + verb' false positive negation patterns
 
-## v0.10.2
+## v0.10.2 (2023-12-20)
 
 ### Changed
 
@@ -143,7 +159,7 @@
 - `AsList` errors are now correctly reported
 - `eds.span_qualifier` saved configuration during `to_disk` is now longer null
 
-## v0.10.1
+## v0.10.1 (2023-12-15)
 
 ### Changed
 
@@ -161,7 +177,7 @@
 - `RegexMatcher` now supports all alignment modes (`strict`, `expand`, `contract`) and better handles partial doc matching (#201).
 - `on_ent_only=False/True` is now supported again in qualifier pipes (e.g., "eds.negation", "eds.hypothesis", ...)
 
-## v0.10.0
+## v0.10.0 (2023-12-04)
 
 ### Added
 
@@ -185,7 +201,7 @@
 - Support for pydantic v2
 - Support for python 3.11 (not ci-tested yet)
 
-## v0.10.0beta1
+## v0.10.0beta1 (2023-12-04)
 
 Large refacto of EDS-NLP to allow training models and performing inference using PyTorch
 as the deep-learning backend. Rather than a mere wrapper of Pytorch using spaCy, this is
@@ -220,13 +236,13 @@ with `nlp = spacy.blank('eds')`. To benefit from the new features, users should
   to avoid having to wrap every import torch statement for pure rule-based use cases.
   Hence, torch is not a required dependency
 
-## v0.9.2
+## v0.9.2 (2023-12-04)
 
 ### Changed
 
 - Fix matchers to skip pipes with assigned extensions that are not required by the matcher during the initialization
 
-## v0.9.1
+## v0.9.1 (2023-09-22)
 
 ### Changed
 
@@ -239,7 +255,7 @@ with `nlp = spacy.blank('eds')`. To benefit from the new features, users should
 - `span_getter` is not incompatible with on_ents_only anymore
 - `ContextualMatcher` now supports empty matches (e.g. lookahead/lookbehind) in `assign` patterns
 
-## v0.9.0
+## v0.9.0 (2023-09-15)
 
 ### Added
 
@@ -458,7 +474,7 @@ Fix release to allow installation from source
 - [TNM](https://en.wikipedia.org/wiki/TNM_staging_system) detection and normalisation with the `eds.TNM` pipeline
 - Support for arbitrary callback for Pandas multiprocessing, with the `callback` argument
 
-## v0.5.2 (2022-04-29)
+## v0.5.2 (2022-05-04)
 
 ### Added
 
@@ -487,7 +503,7 @@ Fix release to allow installation from source
 - Complete revamp of the date detection pipeline, with better parsing and more exhaustive matching
 - Reimplementation of the EDSPhraseMatcher in Cython, leading to a x15 speed increase
 
-## v0.4.4
+## v0.4.4 (2022-03-31)
 
 - Add `measures` pipeline
 - Cap Jinja2 version to fix mkdocs
@@ -495,25 +511,25 @@ Fix release to allow installation from source
 - Improve the speed of char replacement pipelines (accents and quotes)
 - Improve the speed of the regex matcher
 
-## v0.4.3
+## v0.4.3 (2022-03-18)
 
 - Fix regex matching on spans.
 - Add fast_parse in date pipeline.
 - Add relative_date information parsing
 
-## v0.4.2
+## v0.4.2 (2022-03-16)
 
 - Fix issue with `dateparser` library (see scrapinghub/dateparser#1045)
 - Fix `attr` issue in the `advanced-regex` pipelin
 - Add documentation for `eds.covid`
 - Update the demo with an explanation for the regex
 
-## v0.4.1
+## v0.4.1 (2022-03-14)
 
 - Added support to Koalas DataFrames in the `edsnlp.processing` pipe.
 - Added `eds.covid` NER pipeline for detecting COVID19 mentions.
 
-## v0.4.0
+## v0.4.0 (2022-02-22)
 
 - Profound re-write of the normalisation :
     - The custom attribute `CUSTOM_NORM` is completely abandoned in favour of a more _spacyfic_ alternative
@@ -547,7 +563,7 @@ Fix release to allow installation from source
 - Add a `eds.covid` component, that identifies mentions of COVID
 - Change the demo, to include NER components
 
-## v0.3.2
+## v0.3.2 (2021-11-24)
 
 - Major revamp of the normalisation.
     - The `normalizer` pipeline **now adds atomic components** (`lowercase`, `accents`, `quotes`, `pollution` & `endlines`) to the processing pipeline, and compiles the results into a
@@ -561,14 +577,14 @@ Fix release to allow installation from source
 - Add an `endlines` pipeline, that classifies newline characters between spaces and actual ends of line.
 - Add possibility to annotate within entities for qualifiers (`negation`, `hypothesis`, etc), ie if the cue is within the entity. Disabled by default.
 
-## v0.3.1
+## v0.3.1 (2021-10-13)
 
 - Update `dates` to remove miscellaneous bugs.
 - Add `isort` pre-commit hook.
 - Improve performance for `negation`, `hypothesis`, `antecedents`, `family` and `rspeech` by using spaCy's `filter_spans` and our `consume_spans` methods.
 - Add proposition segmentation to `hypothesis` and `family`, enhancing results.
 
-## v0.3.0
+## v0.3.0 (2021-09-29)
 
 - Renamed `generic` to `matcher`. This is a non-breaking change for the average user, adding the pipeline is still :
 
@@ -581,7 +597,7 @@ Fix release to allow installation from source
 - Add `advanced-regex` pipeline
 - Corrected bugs in the `negation` pipeline
 
-## v0.2.0
+## v0.2.0 (2021-09-13)
 
 - Add `negation` pipeline
 - Add `family` pipeline
@@ -593,7 +609,7 @@ Fix release to allow installation from source
     - Add a `pipelines` folder, containing one subdirectory per component
     - Every component subdirectory contains a module defining the component, and a module defining a factory, plus any other utilities (eg `terms.py`)
 
-## v0.1.0
+## v0.1.0 (2021-09-29)
 
 First working version. Available pipelines :
 
diff --git a/demo/app.py b/demo/app.py
index 24b9aa4db..9462ae314 100644
--- a/demo/app.py
+++ b/demo/app.py
@@ -1,10 +1,11 @@
 from typing import Any
 
 import pandas as pd
-import spacy
 import streamlit as st
 from spacy import displacy
 
+import edsnlp
+import edsnlp.pipes as eds
 from edsnlp.utils.filter import filter_spans
 
 DEFAULT_TEXT = """\
@@ -30,8 +31,7 @@
 REGEX = """
 # RegEx and terms matcher
 nlp.add_pipe(
-    "eds.matcher",
-    config=dict(
+    eds.matcher(
         regex=dict(custom=r"{custom_regex}"),
         attr="NORM",
     ),
@@ -39,20 +39,20 @@
 """
 
 CODE = """
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 # Declare the pipeline
 nlp = edsnlp.blank("eds")
 
 # General-purpose components
-nlp.add_pipe("eds.normalizer")
-nlp.add_pipe("eds.sentences")
+nlp.add_pipe(eds.normalizer())
+nlp.add_pipe(eds.sentences())
 {pipes}
 # Qualifier pipes
-nlp.add_pipe("eds.negation")
-nlp.add_pipe("eds.family")
-nlp.add_pipe("eds.hypothesis")
-nlp.add_pipe("eds.reported_speech")
+nlp.add_pipe(eds.negation())
+nlp.add_pipe(eds.family())
+nlp.add_pipe(eds.hypothesis())
+nlp.add_pipe(eds.rspeech())
 
 # Define the note text
 text = {text}
@@ -104,35 +104,31 @@ def load_model(custom_regex: str, **enabled):
     pipes = []
 
     # Declare the pipeline
-    nlp = spacy.blank("eds")
-    nlp.add_pipe("eds.normalizer")
-    nlp.add_pipe("eds.sentences")
+    nlp = edsnlp.blank("eds")
+    nlp.add_pipe(eds.normalizer())
+    nlp.add_pipe(eds.sentences())
 
     for title, name in PIPES.items():
         if name == "drugs":
             if enabled["drugs"]:
                 if enabled["fuzzy_drugs"]:
-                    nlp.add_pipe("eds.drugs", config=dict(term_matcher="simstring"))
-                    pipes.append(
-                        'nlp.add_pipe("eds.drugs", '
-                        'config=dict(term_matcher="simstring"))'
-                    )
+                    nlp.add_pipe(eds.drugs(term_matcher="simstring"))
+                    pipes.append('nlp.add_pipe(eds.drugs(term_matcher="simstring"))')
                 else:
-                    nlp.add_pipe("eds.drugs")
-                    pipes.append('nlp.add_pipe("eds.drugs")')
+                    nlp.add_pipe(eds.drugs())
+                    pipes.append("nlp.add_pipe(eds.drugs())")
 
         else:
             if enabled[name]:
                 nlp.add_pipe(f"eds.{name}")
-                pipes.append(f'nlp.add_pipe("eds.{name}")')
+                pipes.append(f"nlp.add_pipe(eds.{name}())")
 
     if pipes:
         pipes.insert(0, "# Entity extraction pipes")
 
     if custom_regex:
         nlp.add_pipe(
-            "eds.matcher",
-            config=dict(
+            eds.matcher(
                 regex=dict(custom=custom_regex),
                 attr="NORM",
             ),
@@ -143,10 +139,10 @@ def load_model(custom_regex: str, **enabled):
     else:
         regex = ""
 
-    nlp.add_pipe("eds.negation")
-    nlp.add_pipe("eds.family")
-    nlp.add_pipe("eds.hypothesis")
-    nlp.add_pipe("eds.reported_speech")
+    nlp.add_pipe(eds.negation())
+    nlp.add_pipe(eds.family())
+    nlp.add_pipe(eds.hypothesis())
+    nlp.add_pipe(eds.rspeech())
 
     return nlp, pipes, regex
 
diff --git a/docs/advanced-tutorials/fastapi.md b/docs/advanced-tutorials/fastapi.md
index 711acc95d..a555b5009 100644
--- a/docs/advanced-tutorials/fastapi.md
+++ b/docs/advanced-tutorials/fastapi.md
@@ -12,13 +12,12 @@ Let's create a simple NLP model, that can:
 You know the drill:
 
 ```python title="pipeline.py"
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
-nlp = edsnlp.blank('fr')
-
-nlp.add_pipe("eds.sentences")
-
-config = dict(
+nlp = edsnlp.blank('eds')
+nlp.add_pipe(eds.sentences())
+nlp.add_pipe(
+   eds.matcher(
     regex=dict(
         covid=[
             "covid",
@@ -28,13 +27,12 @@ config = dict(
         ],
     ),
     attr="LOWER",
+   ),
 )
-nlp.add_pipe('eds.matcher', config=config)
-
-nlp.add_pipe("eds.negation")
-nlp.add_pipe("eds.family")
-nlp.add_pipe("eds.hypothesis")
-nlp.add_pipe("eds.reported_speech")
+nlp.add_pipe(eds.negation())
+nlp.add_pipe(eds.family())
+nlp.add_pipe(eds.hypothesis())
+nlp.add_pipe(eds.rspeech())
 ```
 
 ## Creating the FastAPI app
diff --git a/docs/assets/images/class_span_linker.png b/docs/assets/images/class_span_linker.png
new file mode 100644
index 000000000..168471c6c
Binary files /dev/null and b/docs/assets/images/class_span_linker.png differ
diff --git a/docs/assets/images/synonym_span_linker.png b/docs/assets/images/synonym_span_linker.png
new file mode 100644
index 000000000..ed3fb27d8
Binary files /dev/null and b/docs/assets/images/synonym_span_linker.png differ
diff --git a/docs/assets/overrides/main.html b/docs/assets/overrides/main.html
new file mode 100644
index 000000000..afefad73b
--- /dev/null
+++ b/docs/assets/overrides/main.html
@@ -0,0 +1,7 @@
+{% extends "base.html" %}
+
+{% block announce %}
+  EDS-NLP v0.11 introduces <a href="/concepts/pipeline#creating-a-pipeline">a new way</a>
+  to add pipes to your models, an <a href="/pipes/trainable/span-linker">entity linker</a>
+  and many other <a href="/changelog">features</a> !
+{% endblock %}
diff --git a/docs/concepts/inference.md b/docs/concepts/inference.md
index 8a3e9403f..21fb8e85d 100644
--- a/docs/concepts/inference.md
+++ b/docs/concepts/inference.md
@@ -47,7 +47,7 @@ A lazy collection contains :
 - an optional `writer`: the destination of the data (e.g., a file, a database, a list of strings, etc.)
 - the execution `config`, containing the backend to use and its configuration such as the number of workers, the batch size, etc.
 
-All methods (`.map`, `.map_batches`, `.map_pipeline`, `.set_processing`) of the lazy collection are chainable, meaning that they return a new object (no in-place modification).
+All methods (`.map`, `.map_batches`, `.map_gpu`, `.map_pipeline`, `.set_processing`) of the lazy collection are chainable, meaning that they return a new lazy collection object (no in-place modification).
 
 For instance, the following code will load a model, read a folder of JSON files, apply the model to each document and write the result in a Parquet folder, using 4 CPUs and 2 GPUs.
 
@@ -94,7 +94,9 @@ To apply an operation to a lazy collection in batches, you can use the `.map_bat
 
 To apply a model, you can use the `.map_pipeline` method. It takes a model as input and will add every pipe of the model to the scheduled operations.
 
-In both cases, the operations will not be executed immediately but will be scheduled to be executed when iterating of the collection, or calling the `.execute`, `.to_*` or `.write_*` methods.
+To run a specific function on a GPU (for advanced users, otherwise `map_pipeline` should accommodate most use cases), you can use the `.map_gpu` method. It takes two or three callables as input: the first on (`prepare_batches`) takes a batch of inputs and should return some tensors that will be sent to the GPU and passed to the second callable (`forward`), which will apply the deep learning ops and return the results. The third callable (`postprocess`) and gets the batch of inputs as well as the `forward` results and should return the final results (for instance, the input documents annotated with the predictions).
+
+In each cases, the operations will not be executed immediately but will be scheduled to be executed when iterating of the collection, or calling the `.execute`, `.to_*` or `.write_*` methods.
 
 ### Execution of a lazy collection {: #edsnlp.core.lazy_collection.LazyCollection.set_processing }
 
diff --git a/docs/concepts/pipeline.md b/docs/concepts/pipeline.md
index b29d93909..095c6baa8 100644
--- a/docs/concepts/pipeline.md
+++ b/docs/concepts/pipeline.md
@@ -22,20 +22,74 @@ Trainable components, on the other hand, are built on top of the [PyTorch](https
 
 A pipeline is composed of multiple pipes, i.e., callable processing blocks, like a function, that apply a transformation on a Doc object, such as adding annotations, and return the modified object.
 
-To create your first EDS-NLP pipeline, run the following code:
+To create your first EDS-NLP pipeline, run the following code. We provide several ways to create a pipeline:
 
-=== "EDS-NLP pipeline"
+=== "EDS-NLP API"
+
+    This is the recommended way to create a pipeline, as it allows auto-completion, type checking and introspection (you can click on the component or its arguments to see the documentation in most IDEs).
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
+    nlp.add_pipe(eds.sentences())
+    nlp.add_pipe(eds.matcher(regex={"smoker": ["fume", "clope"]}))
+    nlp.add_pipe(eds.negation())
+    ```
+
+    !!! note "Curried components"
+
+        Most components (like `eds.matcher`) require an `nlp` argument initialization.
+        The above `eds.matcher(regex={"smoker": ["fume", "clope"]})` actually returns
+        a ["curried"](https://en.wikipedia.org/wiki/Currying) component, that will be
+        instantiated when added to the pipeline. To create the actual component directly
+        and use it outside of a pipeline (not recommended), you can use
+        `eds.matcher(nlp, regex={"smoker": ["fume", "clope"]})`, or use the result of
+        the `nlp.add_pipe` call.
+
+=== "SpaCy-like API"
+
+    Pipes can be dynamically added to the pipeline using the `add_pipe` method, with a string matching their factory name and an optional configuration dictionary.
+
+    ```python
+    import edsnlp  # or import spacy
+
+    nlp = edsnlp.blank("eds")  # or spacy.blank("eds")
     nlp.add_pipe("eds.sentences")
     nlp.add_pipe("eds.matcher", config=dict(regex={"smoker": ["fume", "clope"]}))
     nlp.add_pipe("eds.negation")
     ```
 
-This pipeline can then be run on one or more PDF documents.
+=== "From a config file"
+
+    You can also create a pipeline from a configuration file. This is useful when you plan on changing the pipeline configuration often.
+
+    ```{ .cfg title="config.cfg" }
+    [nlp]
+    lang = "eds"
+    pipeline = ["sentences", "matcher", "negation"]
+
+    [components.sentences]
+    @factory = "eds.sentences"
+
+    [components.matcher]
+    @factory = "eds.matcher"
+    regex = {"smoker": ["fume", "clope"]}
+
+    [components.negation]
+    @factory = "eds.negation"
+    ```
+
+    and then load the pipeline with:
+
+    ```{ .python .no-check }
+    import edsnlp
+
+    nlp = edsnlp.load("config.cfg")
+    ```
+
+
+This pipeline can then be run on one or more texts documents.
 As the pipeline process documents, components will be called in the order
 they were added to the pipeline.
 
diff --git a/docs/concepts/torch-component.md b/docs/concepts/torch-component.md
index 029942fe7..f946868e6 100644
--- a/docs/concepts/torch-component.md
+++ b/docs/concepts/torch-component.md
@@ -79,21 +79,21 @@ In EDS-NLP, sharing a subcomponent is simply done by sharing the object between
 
     ```{ .python .no-check }
     nlp.add_pipe(
-        "my-component-1",
+        eds.ner_crf(
+            ...,
+            embedding=eds.transformer(
+                model_name="bert-base-uncased",
+                window=128,
+                stride=96,
+            ),
+        ),
         name="first",
-        config={
-            "embedding": {
-                "@factory": "eds.embeddings",
-                # ...
-            }
-        },
     )
     nlp.add_pipe(
-        "my-component-2",
+        some_other_task(
+            embedding=nlp.pipes.first.embedding,
+        ),
         name="second",
-        config={
-            "embedding": pipeline.components.first.embedding,
-        },
     )
     ```
 
@@ -101,14 +101,15 @@ In EDS-NLP, sharing a subcomponent is simply done by sharing the object between
 
     ```toml
     [components.first]
-    @factory = "my-component-1"
+    @factory = "eds.ner_crf"
+    ...
 
     [components.first.embedding]
     @factory = "eds.embeddings"
     ...
 
     [components.second]
-    @factory = "my-component-2"
+    @factory = "some_other_task"
     embedding = ${components.first.embedding}
     ```
 
diff --git a/docs/data/converters.md b/docs/data/converters.md
index 00f89d499..5bac80cd7 100644
--- a/docs/data/converters.md
+++ b/docs/data/converters.md
@@ -15,7 +15,7 @@ You can always define your own converter functions to convert between your data
 ### Reading from a custom schema
 
 ```{ .python .no-check }
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from spacy.tokens import Doc
 from edsnlp.data.converters import get_current_tokenizer
 from typing import Dict
@@ -29,8 +29,8 @@ def convert_row_to_dict(row: Dict) -> Doc:
     return doc
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer")
-nlp.add_pipe("eds.covid")
+nlp.add_pipe(eds.normalizer())
+nlp.add_pipe(eds.covid())
 
 # Any kind of reader (`edsnlp.data.read/from_...`) can be used here
 docs = edsnlp.data.from_pandas(
diff --git a/docs/index.md b/docs/index.md
index 1c5445dd2..76e43cf16 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -29,7 +29,7 @@ pip install "edsnlp[ml]==0.10.7"
 Once you've installed the library, let's begin with a very simple example that extracts mentions of COVID19 in a text, and detects whether they are negated.
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")  # (1)
 
@@ -38,11 +38,11 @@ terms = dict(
 )
 
 # Sentencizer component, needed for negation detection
-nlp.add_pipe("eds.sentences")  # (3)
+nlp.add_pipe(eds.sentences())  # (3)
 # Matcher component
-nlp.add_pipe("eds.matcher", config=dict(terms=terms))  # (4)
+nlp.add_pipe(eds.matcher(terms=terms))  # (4)
 # Negation detection
-nlp.add_pipe("eds.negation")
+nlp.add_pipe(eds.negation())
 
 # Process your text in one call !
 doc = nlp("Le patient n'est pas atteint de covid")
diff --git a/docs/pipes/core/contextual-matcher.md b/docs/pipes/core/contextual-matcher.md
index 9ac5d7970..8f7e21c29 100644
--- a/docs/pipes/core/contextual-matcher.md
+++ b/docs/pipes/core/contextual-matcher.md
@@ -145,17 +145,14 @@ This parameter can be se to `True` **only for a single assign key per dictionary
 ## Examples
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
 
-nlp.add_pipe("sentences")
-nlp.add_pipe("normalizer")
-
+nlp.add_pipe(eds.sentences())
+nlp.add_pipe(eds.normalizer())
 nlp.add_pipe(
-    "eds.contextual-matcher",
-    name="Cancer",
-    config=dict(
+    eds.contextual_matcher(
         patterns=patterns,
         label="cancer",
     ),
diff --git a/docs/pipes/core/normalizer.md b/docs/pipes/core/normalizer.md
index 4ad69060b..95d063c3e 100644
--- a/docs/pipes/core/normalizer.md
+++ b/docs/pipes/core/normalizer.md
@@ -34,11 +34,11 @@ The normaliser can act on the input text in five dimensions :
 The normalisation is handled by the single `eds.normalizer` pipeline component. The following code snippet is complete, and should run as is.
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from edsnlp.matchers.utils import get_text
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer")
+nlp.add_pipe(eds.normalizer())
 
 # Notice the special character used for the apostrophe and the quotes
 text = "Le patient est admis à l'hôpital le 23 août 2021 pour une douleur ʺaffreuse” à l`estomac."
@@ -60,8 +60,8 @@ Moreover, every span exposes a `normalized_variant` extension getter, which comp
 The pipeline component can be configured using the following parameters :
 
 ::: edsnlp.pipes.core.normalizer.factory.create_component
-    options:
-       only_parameters: true
+options:
+only_parameters: true
 
 ## Pipes
 
@@ -74,19 +74,19 @@ The `eds.lowercase` pipeline component transforms every token to lowercase. It i
 Consider the following example :
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from edsnlp.matchers.utils import get_text
 
-config = dict(
-    lowercase=True,
-    accents=False,
-    quotes=False,
-    spaces=False,
-    pollution=False,
-)
-
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer", config=config)
+nlp.add_pipe(
+    eds.normalizer(
+        lowercase=True,
+        accents=False,
+        quotes=False,
+        spaces=False,
+        pollution=False,
+    ),
+)
 
 text = "Pneumopathie à NBNbWbWbNbWbNBNbNbWbW `coronavirus'"
 
@@ -105,19 +105,19 @@ making it more predictable than using a library such as `unidecode`.
 Consider the following example :
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from edsnlp.matchers.utils import get_text
 
-config = dict(
-    lowercase=False,
-    accents=True,
-    quotes=False,
-    spaces=False,
-    pollution=False,
-)
-
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer", config=config)
+nlp.add_pipe(
+    eds.normalizer(
+        lowercase=False,
+        accents=True,
+        quotes=False,
+        spaces=False,
+        pollution=False,
+    ),
+)
 
 text = "Pneumopathie à NBNbWbWbNbWbNBNbNbWbW `coronavirus'"
 
@@ -127,7 +127,6 @@ get_text(doc, attr="NORM", ignore_excluded=False)
 # Out: Pneumopathie a NBNbWbWbNbWbNBNbNbWbW `coronavirus'
 ```
 
-
 ### Apostrophes and quotation marks
 
 Apostrophes and quotation marks can be encoded using unpredictable special characters. The `eds.quotes` component transforms every such special character to `'` and `"`, respectively.
@@ -135,19 +134,19 @@ Apostrophes and quotation marks can be encoded using unpredictable special chara
 Consider the following example :
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from edsnlp.matchers.utils import get_text
 
-config = dict(
-    lowercase=False,
-    accents=False,
-    quotes=True,
-    spaces=False,
-    pollution=False,
-)
-
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer", config=config)
+nlp.add_pipe(
+    eds.normalizer(
+        lowercase=False,
+        accents=False,
+        quotes=True,
+        spaces=False,
+        pollution=False,
+    ),
+)
 
 text = "Pneumopathie à NBNbWbWbNbWbNBNbNbWbW `coronavirus'"
 
@@ -169,18 +168,18 @@ matching.
       `ignore_space_tokens` parameter token to True in a downstream component.
 
 ```python
-import edsnlp
-
-config = dict(
-    lowercase=False,
-    accents=False,
-    quotes=False,
-    spaces=True,
-    pollution=False,
-)
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer", config=config)
+nlp.add_pipe(
+    eds.normalizer(
+        lowercase=False,
+        accents=False,
+        quotes=False,
+        spaces=True,
+        pollution=False,
+    ),
+)
 
 doc = nlp("Phrase    avec des espaces \n et un retour à la ligne")
 [t.tag_ for t in doc]
@@ -189,24 +188,25 @@ doc = nlp("Phrase    avec des espaces \n et un retour à la ligne")
 
 ### Pollution
 
-The pollution pipeline component uses a set of regular expressions to detect pollutions (irrelevant non-medical text that hinders text processing). Corresponding tokens are marked as excluded (by setting `Token._.excluded` to `True`), enabling the use of the phrase matcher.
+The pollution pipeline component uses a set of regular expressions to detect pollutions (irrelevant non-medical text that hinders text processing). Corresponding tokens are marked as excluded (by
+setting `Token._.excluded` to `True`), enabling the use of the phrase matcher.
 
 Consider the following example :
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from edsnlp.matchers.utils import get_text
 
-config = dict(
-    lowercase=False,
-    accents=True,
-    quotes=False,
-    spaces=False,
-    pollution=True,
-)
-
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer", config=config)
+nlp.add_pipe(
+    eds.normalizer(
+        lowercase=False,
+        accents=True,
+        quotes=False,
+        spaces=False,
+        pollution=True,
+    ),
+)
 
 text = "Pneumopathie à NBNbWbWbNbWbNBNbNbWbW `coronavirus'"
 
@@ -231,15 +231,12 @@ Pollution can come in various forms in clinical texts. We provide a small set of
 For instance, if we consider biology tables as pollution, we only need to instantiate the `normalizer` pipe as follows:
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
 nlp.add_pipe(
-    "eds.normalizer",
-    config=dict(
-        pollution=dict(
-            biology=True,
-        ),
+    eds.normalizer(
+        pollution=dict(biology=True),
     ),
 )
 ```
@@ -260,15 +257,12 @@ If you want to exclude specific patterns, you can provide them as a RegEx (or a
 For instance, to consider text between "AAA" and "ZZZ" as pollution you might use:
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
 nlp.add_pipe(
-    "eds.normalizer",
-    config=dict(
-        pollution=dict(
-            custom_pollution=r"AAA.*ZZZ",
-        ),
+    eds.normalizer(
+        pollution=dict(custom_pollution=r"AAA.*ZZZ"),
     ),
 )
 ```
diff --git a/docs/pipes/index.md b/docs/pipes/index.md
index b6785d03c..96e7b8000 100644
--- a/docs/pipes/index.md
+++ b/docs/pipes/index.md
@@ -41,12 +41,12 @@ EDS-NLP provides easy-to-use pipeline components (aka pipes).
 You can add them to your pipeline by simply calling `add_pipe`, for instance:
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer")
-nlp.add_pipe("eds.sentences")
-nlp.add_pipe("eds.tnm")
+nlp.add_pipe(eds.normalizer())
+nlp.add_pipe(eds.sentences())
+nlp.add_pipe(eds.tnm())
 ```
 
 ## Basic architecture
diff --git a/docs/pipes/ner/behaviors/index.md b/docs/pipes/ner/behaviors/index.md
index 64b945bfa..e5aef2126 100644
--- a/docs/pipes/ner/behaviors/index.md
+++ b/docs/pipes/ner/behaviors/index.md
@@ -12,8 +12,7 @@ Some general considerations about those components:
 - Those components work on **normalized** documents. Please use the `eds.normalizer` pipeline with the following parameters:
   ```{ .python .no-check }
   nlp.add_pipe(
-      "eds.normalizer",
-      config=dict(
+      eds.normalizer(
           accents=True,
           lowercase=True,
           quotes=True,
@@ -40,13 +39,12 @@ Some general considerations about those components:
 ## Usage
 
 ```{ .python .no-check }
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.sentences")
+nlp.add_pipe(eds.sentences())
 nlp.add_pipe(
-    "eds.normalizer",
-    config=dict(
+    eds.normalizer(
         accents=True,
         lowercase=True,
         quotes=True,
@@ -62,8 +60,8 @@ nlp.add_pipe(
         ),
     ),
 )
-nlp.add_pipe("eds.tobacco")
-nlp.add_pipe("eds.diabetes")
+nlp.add_pipe(eds.tobacco())
+nlp.add_pipe(eds.diabetes())
 
 text = """
 Compte-rendu de consultation.
diff --git a/docs/pipes/ner/disorders/index.md b/docs/pipes/ner/disorders/index.md
index 971db10ad..c7b672096 100644
--- a/docs/pipes/ner/disorders/index.md
+++ b/docs/pipes/ner/disorders/index.md
@@ -12,9 +12,11 @@ Some general considerations about those components:
 - Those components work on **normalized** documents. Please use the `eds.normalizer` pipeline with the following parameters:
 
     ```{ .python .no-check }
+    import edsnlp, edsnlp.pipes as eds
+    ...
+
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
diff --git a/docs/pipes/qualifiers/index.md b/docs/pipes/qualifiers/index.md
index d4901777b..a2d9cd30b 100644
--- a/docs/pipes/qualifiers/index.md
+++ b/docs/pipes/qualifiers/index.md
@@ -88,7 +88,10 @@ Albeit simple, this algorithm can achieve very good performance depending on the
     You may use EDS-NLP's:
 
     ```{ .python .no-check }
-    nlp.add_pipe("eds.sentences")
+    import edsnlp, edsnlp.pipes as eds
+
+    ...
+    nlp.add_pipe(eds.sentences())
     ```
 
 ## Persisting the results
diff --git a/docs/pipes/trainable/span-linker.md b/docs/pipes/trainable/span-linker.md
new file mode 100644
index 000000000..5aa3bf290
--- /dev/null
+++ b/docs/pipes/trainable/span-linker.md
@@ -0,0 +1,8 @@
+# Trainable Span Linker {: #edsnlp.pipes.trainable.span_linker.factory.create_component }
+
+::: edsnlp.pipes.trainable.span_linker.factory.create_component
+    options:
+        heading_level: 2
+        show_bases: false
+        show_source: false
+        only_class_level: true
diff --git a/docs/references.bib b/docs/references.bib
index 4ab94c031..de5efdec1 100644
--- a/docs/references.bib
+++ b/docs/references.bib
@@ -60,6 +60,16 @@ @inproceedings{dalloux2017ESSAI
   year        = {2017}
 }
 
+@article{wajsburt2021medical,
+  title={Medical concept normalization in French using multilingual terminologies and contextual embeddings},
+  author={Wajsbürt, Perceval and Sarfati, Arnaud and Tannier, Xavier},
+  journal={Journal of Biomedical Informatics},
+  volume={114},
+  pages={103684},
+  year={2021},
+  url = {https://doi.org/10.1016/j.jbi.2021.103684},
+  publisher={Elsevier}
+}
 
 @phdthesis{wajsburt:tel-03624928,
   TITLE = {{Extraction and normalization of simple and structured entities in medical documents}},
diff --git a/docs/scripts/bibtex.py b/docs/scripts/bibtex.py
index 6064b8741..fcbb6c294 100644
--- a/docs/scripts/bibtex.py
+++ b/docs/scripts/bibtex.py
@@ -104,25 +104,26 @@ def formatReference(self, ref):
         year = ref.fields.get("year")
         pages = ref.fields.get("pages")
         doi = ref.fields.get("doi")
+        url = ref.fields.get("url")
 
         ref_id = self.referenceID(ref.key)
-        reference = f"<p id={repr(ref_id)}>{authors}, {year}. {title}."
+        ref = f"<p id={repr(ref_id)}>{authors}, {year}. {title}."
         if journal:
-            reference += f" <i>{journal}</i>."
+            ref += f" <i>{journal}</i>."
             if volume:
-                reference += f" <i>{volume}</i>"
+                ref += f" <i>{volume}</i>"
             if issue:
-                reference += f"({issue})"
+                ref += f"({issue})"
             if pages:
-                reference += f", pp.{pages}"
-            reference += "."
+                ref += f", pp.{pages}"
+            ref += "."
         if doi:
-            reference += (
-                f' <a href="https://dx.doi.org/{doi}" target="_blank">{doi}</a>'
-            )
-        reference += "</p>"
+            ref += f' <a href="https://dx.doi.org/{doi}" target="_blank">{doi}</a>'
+        elif url:
+            ref += f' <a href="{url}" target="_blank">{url}</a>'
+        ref += "</p>"
 
-        return etree.fromstring(reference)
+        return etree.fromstring(ref)
 
     def formatCitation(self, ref):
         author_list = list(map(self.formatAuthorSurname, ref.persons["author"]))
diff --git a/docs/scripts/plugin.py b/docs/scripts/plugin.py
index 900a6e4f7..3d3867967 100644
--- a/docs/scripts/plugin.py
+++ b/docs/scripts/plugin.py
@@ -152,7 +152,15 @@ def on_page_read_source(page, config):
     r'(?:"([^"]*)"|\'([^\']*)|[ ]*([^ =>]*)(?![a-z]+=))'
 )
 # Maybe find something less specific ?
-PIPE_REGEX = r"(?<=[^a-zA-Z0-9._-])eds[.][a-zA-Z0-9._-]*(?=[^a-zA-Z0-9._-])"
+PIPE_REGEX = r"(?<![a-zA-Z0-9._-])eds[.]([a-zA-Z0-9._-]*)(?![a-zA-Z0-9._-])"
+
+HTML_PIPE_REGEX = r"""(?x)
+(?<![a-zA-Z0-9._-])
+<span[^>]*>eds<\/span>
+<span[^>]*>[.]<\/span>
+<span[^>]*>([a-zA-Z0-9._-]*)<\/span>
+(?![a-zA-Z0-9._-])
+"""
 
 
 @mkdocs.plugins.event_priority(-1000)
@@ -187,7 +195,8 @@ def on_post_page(
     }
 
     def replace_component(match):
-        name = match.group(0)
+        full_group = match.group(0)
+        name = "eds." + match.group(1)
         ep = spacy_factories_entry_points.get(name)
         preceding = output[match.start(0) - 50 : match.start(0)]
         if ep is not None and "DEFAULT:" not in preceding:
@@ -196,8 +205,8 @@ def replace_component(match):
             except KeyError:
                 pass
             else:
-                return "<a href={href}>{name}</a>".format(href=url, name=name)
-        return name
+                return f"<a href={url}>{name}</a>"
+        return full_group
 
     def replace_link(match):
         relative_url = url = match.group(1) or match.group(2) or match.group(3)
@@ -208,6 +217,7 @@ def replace_link(match):
 
     # Replace absolute paths with path relative to the rendered page
     output = regex.sub(PIPE_REGEX, replace_component, output)
+    output = regex.sub(HTML_PIPE_REGEX, replace_component, output)
     output = regex.sub(HREF_REGEX, replace_link, output)
 
     return output
diff --git a/docs/tutorials/detecting-dates.md b/docs/tutorials/detecting-dates.md
index 9ea8a8c44..e3b576828 100644
--- a/docs/tutorials/detecting-dates.md
+++ b/docs/tutorials/detecting-dates.md
@@ -44,10 +44,10 @@ Clinical notes contain many different types of dates. To name a few examples:
 The followings snippet adds the `eds.dates` component to the pipeline:
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.dates")  # (1)
+nlp.add_pipe(eds.dates())  # (1)
 
 text = (
     "Le patient est admis le 21 janvier pour une douleur dans le cou.\n"
@@ -114,18 +114,18 @@ and we will post-process the output `Doc` object to determine
 whether a given entity can be linked to a date.
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from datetime import datetime
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.sentences")
-nlp.add_pipe("eds.dates")
-
-config = dict(
-    regex=dict(admission=["admissions?", "admise?", "prise? en charge"]),
-    attr="LOWER",
+nlp.add_pipe(eds.sentences())
+nlp.add_pipe(eds.dates())
+nlp.add_pipe(
+    eds.matcher(
+        regex=dict(admission=["admissions?", "admise?", "prise? en charge"]),
+        attr="LOWER",
+    )
 )
-nlp.add_pipe("eds.matcher", config=config)
 
 text = (
     "Le patient est admis le 12 avril pour une douleur "
@@ -220,19 +220,19 @@ def get_event_date(ent: Span) -> Optional[Span]:
 We can apply this simple function:
 
 ```{ .python .no-check }
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from utils import get_event_date
 from datetime import datetime
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.sentences")
-nlp.add_pipe("eds.dates")
-
-config = dict(
-    regex=dict(admission=["admissions?", "admise?", "prise? en charge"]),
-    attr="LOWER",
+nlp.add_pipe(eds.sentences())
+nlp.add_pipe(eds.dates())
+nlp.add_pipe(
+    eds.matcher(
+        regex=dict(admission=["admissions?", "admise?", "prise? en charge"]),
+        attr="LOWER",
+    )
 )
-nlp.add_pipe("eds.matcher", config=config)
 
 text = (
     "Le patient est admis le 12 avril pour une douleur "
diff --git a/docs/tutorials/endlines.md b/docs/tutorials/endlines.md
index cafa04cf0..d93213310 100644
--- a/docs/tutorials/endlines.md
+++ b/docs/tutorials/endlines.md
@@ -77,13 +77,13 @@ endlines.save(PATH)
 ## Use a trained model for inference
 
 ```{ .python .no-check }
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
 
 PATH = "/path_to_model"
-nlp.add_pipe("eds.endlines", config=dict(model_path=PATH))  # (1)
-nlp.add_pipe("eds.sentences")  # (1)
+nlp.add_pipe(eds.endlines(model_path=PATH))  # (1)
+nlp.add_pipe(eds.sentences())  # (1)
 
 docs = list(nlp.pipe([text1, text2, text3]))
 
diff --git a/docs/tutorials/make-a-training-script.md b/docs/tutorials/make-a-training-script.md
index ecc3fa885..99f2fe6d4 100644
--- a/docs/tutorials/make-a-training-script.md
+++ b/docs/tutorials/make-a-training-script.md
@@ -14,29 +14,27 @@ the NER prediction task using a Conditional Random Field (CRF) token classifier.
 the `@factory` key will be used to select the class of the module.
 
 ```{ .python .annotate }
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from confit.utils.random import set_seed
 
 set_seed(42)
 
 nlp = edsnlp.blank("eds")
 nlp.add_pipe(
-    "eds.ner_crf",  # (1)
+    eds.ner_crf(  # (1)
+        mode="joint",  # (2)
+        target_span_getter="ml-ner",  # (3)
+        window=20,
+        embedding=eds.text_cnn(  # (4)
+            kernel_sizes=[3],
+            embedding=eds.transformer(  # (5)
+                model="prajjwal1/bert-tiny",  # (6)
+                window=128,
+                stride=96,
+            ),
+        ),
+    ),
     name="ner",
-    config={
-        "mode": "joint",  # (2)
-        "target_span_getter": "ml-ner", # (3)
-        "embedding": {
-            "@factory": "eds.text_cnn",  # (4)
-            "kernel_sizes": [3],
-            "embedding": {
-                "@factory": "eds.transformer",  # (5)
-                "model": "prajjwal1/bert-tiny",  # (6)
-                "window": 128,
-                "stride": 96,
-            },
-        },
-    },
 )
 ```
 
@@ -219,7 +217,7 @@ Let's wrap the training code in a function, and make it callable from the comman
     from spacy.tokens import Doc
     from tqdm import tqdm
 
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
     from edsnlp import registry, Pipeline
     from edsnlp.scorers.ner import create_ner_exact_scorer
 
@@ -317,23 +315,20 @@ Let's wrap the training code in a function, and make it callable from the comman
     if __name__ == "__main__":
         nlp = edsnlp.blank("eds")
         nlp.add_pipe(
-            "eds.ner_crf",
+            eds.ner_crf(
+                mode="joint",
+                target_span_getter="ml-ner",
+                window=20,
+                embedding=eds.text_cnn(
+                    kernel_sizes=[3],
+                    embedding=eds.transformer(
+                        model="prajjwal1/bert-tiny",
+                        window=128,
+                        stride=96,
+                    ),
+                ),
+            ),
             name="ner",
-            config={
-                "mode": "joint",
-                "target_span_getter": "ml-ner",
-                "window": 20,
-                "embedding": {
-                    "@factory": "eds.text_cnn",
-                    "kernel_sizes": [3],
-                    "embedding": {
-                        "@factory": "eds.transformer",
-                        "model": "prajjwal1/bert-tiny",
-                        "window": 128,
-                        "stride": 96,
-                    },
-                },
-            },
         )
         train(
             nlp=nlp,
diff --git a/docs/tutorials/matching-a-terminology.md b/docs/tutorials/matching-a-terminology.md
index e7a7f871f..fc8ee2d6c 100644
--- a/docs/tutorials/matching-a-terminology.md
+++ b/docs/tutorials/matching-a-terminology.md
@@ -22,7 +22,7 @@ You should consider reading the [matcher's specific documentation](../pipes/core
 Let's try to find mentions of COVID19 and references to patients within a clinical note.
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 text = (
     "Motif de prise en charge : probable pneumopathie a COVID19, "
@@ -36,7 +36,7 @@ terms = dict(
 )
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.matcher", config=dict(terms=terms))
+nlp.add_pipe(eds.matcher(terms=terms))
 
 doc = nlp(text)
 
@@ -80,7 +80,7 @@ Let's focus on two:
 Matching on the lowercased version is extremely easy:
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 text = (
     "Motif de prise en charge : probable pneumopathie a COVID19, "
@@ -95,8 +95,7 @@ terms = dict(
 
 nlp = edsnlp.blank("eds")
 nlp.add_pipe(
-    "eds.matcher",
-    config=dict(
+    eds.matcher(
         terms=terms,
         attr="LOWER",  # (1)
     ),
@@ -137,7 +136,7 @@ It handles:
 You can activate it like any other component.
 
 ```python hl_lines="4 10 17 23 24"
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 text = (
     "Motif de prise en charge : probable pneumopathie a ===== COVID19, "  # (1)
@@ -153,11 +152,10 @@ terms = dict(
 nlp = edsnlp.blank("eds")
 
 # Add the normalisation component
-nlp.add_pipe("eds.normalizer")  # (3)
+nlp.add_pipe(eds.normalizer())  # (3)
 
 nlp.add_pipe(
-    "eds.matcher",
-    config=dict(
+    eds.matcher(
         terms=terms,
         attr="NORM",  # (4)
         ignore_excluded=True,  # (5)
@@ -202,7 +200,7 @@ Of course, we _could_ write out every imaginable possibility, but this will quic
 Let us redefine the pipeline once again, this time using regular expressions:
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 text = (
     "Motif de prise en charge : probable pneumopathie a COVID19, "
@@ -218,8 +216,7 @@ terms = dict(respiratoire="asthmatique")
 
 nlp = edsnlp.blank("eds")
 nlp.add_pipe(
-    "eds.matcher",
-    config=dict(
+    eds.matcher(
         regex=regex,  # (1)
         terms=terms,  # (2)
         attr="LOWER",  # (3)
diff --git a/docs/tutorials/multiple-texts.md b/docs/tutorials/multiple-texts.md
index 88b373e4b..d8958c530 100644
--- a/docs/tutorials/multiple-texts.md
+++ b/docs/tutorials/multiple-texts.md
@@ -9,26 +9,27 @@ Then, we'll explore methods that EDS-NLP provides to perform inference on multip
 Consider this simple pipeline:
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
 
-nlp.add_pipe("eds.sentences")
-nlp.add_pipe("eds.normalizer")
+nlp.add_pipe(eds.sentences())
+nlp.add_pipe(eds.normalizer())
 
-config = dict(
-    terms=dict(patient=["patient", "malade"]),
-    attr="NORM",
+nlp.add_pipe(
+    eds.matcher(
+        terms=dict(patient=["patient", "malade"]),
+        attr="NORM",
+    ),
 )
-nlp.add_pipe("eds.matcher", config=config)
 
 # Add qualifiers
-nlp.add_pipe("eds.negation")
-nlp.add_pipe("eds.hypothesis")
-nlp.add_pipe("eds.family")
+nlp.add_pipe(eds.negation())
+nlp.add_pipe(eds.hypothesis())
+nlp.add_pipe(eds.family())
 
 # Add date detection
-nlp.add_pipe("eds.dates")
+nlp.add_pipe(eds.dates())
 ```
 
 Let's deploy it on a large number of documents.
@@ -97,9 +98,11 @@ There are a few issues with this approach:
 
 ## Lazy inference and parallelization
 
-To efficiently perform the same operations on multiple documents at once, EDS-NLP uses [lazy collections][edsnlp.core.lazy_collection.LazyCollection], which record the operations to perform on the documents without actually executing them directly, similar to the way Spark does, or polars with its LazyFrame.
+To efficiently perform the same operations on multiple documents at once, EDS-NLP uses [lazy collections][edsnlp.core.lazy_collection.LazyCollection], which record the operations to perform on the
+documents without actually executing them directly, similar to the way Spark does, or polars with its LazyFrame.
 
-This allows EDS-NLP to distribute these operations on multiple cores or machines when it is time to execute them. We can configure how the collection operations are run (how many jobs/workers, how many gpus, whether to use the spark engine) via the lazy collection [`.set_processing(...)`][edsnlp.core.lazy_collection.LazyCollection.set_processing] method.
+This allows EDS-NLP to distribute these operations on multiple cores or machines when it is time to execute them. We can configure how the collection operations are run (how many jobs/workers, how
+many gpus, whether to use the spark engine) via the lazy collection [`.set_processing(...)`][edsnlp.core.lazy_collection.LazyCollection.set_processing] method.
 
 For instance,
 
@@ -109,7 +112,8 @@ print(docs)
 # <edsnlp.core.lazy_collection.LazyCollection object at 0x7f3e3c3e3d90>
 ```
 
-as well as any `edsnlp.data.read_*` or `edsnlp.data.from_*` return a lazy collection, that we can iterate over or complete with more operations. To apply the model on our collection of documents, we can simply do:
+as well as any `edsnlp.data.read_*` or `edsnlp.data.from_*` return a lazy collection, that we can iterate over or complete with more operations. To apply the model on our collection of documents, we
+can simply do:
 
 ```python
 docs = docs.map_pipeline(nlp)
@@ -129,9 +133,11 @@ docs = docs.map_pipeline(nlp)
     docs = docs.map_pipeline(nlp)
     ```
 
-Finally, we can convert the documents to a DataFrame (or other formats / files) using the `edsnlp.data.to_*` or `edsnlp.data.write_*` methods. This triggers the execution of the operations scheduled in the lazy collection and produces the rows of the DataFrame.
+Finally, we can convert the documents to a DataFrame (or other formats / files) using the `edsnlp.data.to_*` or `edsnlp.data.write_*` methods. This triggers the execution of the operations scheduled
+in the lazy collection and produces the rows of the DataFrame.
 
-We can pass our previous Doc to Dict code as a function to the `converter` parameter of the `to_pandas` method. Note that this specific conversion is already implemented in EDS-NLP, so you can just pass the string `"ents"` to the `converter` parameter, and customize the conversion by passing more parameters to the `to_pandas` method, as described [here](/data/converters#ents).
+We can pass our previous Doc to Dict code as a function to the `converter` parameter of the `to_pandas` method. Note that this specific conversion is already implemented in EDS-NLP, so you can just
+pass the string `"ents"` to the `converter` parameter, and customize the conversion by passing more parameters to the `to_pandas` method, as described [here](/data/converters#ents).
 
 ```python
 def convert_doc_to_rows(doc):
@@ -311,7 +317,8 @@ note_nlp = docs.to_pandas(
 
 ### In a distributed fashion with spark
 
-To use the Spark engine to distribute the computation, we create our lazy collection from the Spark dataframe directly and write the result to a new Spark dataframe. EDS-NLP will automatically distribute the operations on the cluster (setting `backend="spark"` behind the scenes), but you can change the backend (for instance to `multiprocessing` to run locally).
+To use the Spark engine to distribute the computation, we create our lazy collection from the Spark dataframe directly and write the result to a new Spark dataframe. EDS-NLP will automatically
+distribute the operations on the cluster (setting `backend="spark"` behind the scenes), but you can change the backend (for instance to `multiprocessing` to run locally).
 
 ```{ .python hl_lines="2 9" .no-check }
 # Read from the pyspark dataframe & use the omop converter
diff --git a/docs/tutorials/qualifying-entities.md b/docs/tutorials/qualifying-entities.md
index df8e6a62c..8d9afe097 100644
--- a/docs/tutorials/qualifying-entities.md
+++ b/docs/tutorials/qualifying-entities.md
@@ -1,9 +1,7 @@
 # Qualifying entities
 
-In the previous tutorial, we saw how to match a terminology on a text. Using the `#!python doc.ents` attribute, we can check whether a document mentions a concept of interest to build a cohort or describe patients.
-
-
-
+In the previous tutorial, we saw how to match a terminology on a text. Using the `#!python doc.ents` attribute, we can check whether a document mentions a concept of interest to build a cohort or
+describe patients.
 
 ## The issue
 
@@ -25,7 +23,8 @@ However, consider the classical example where we look for the `diabetes` concept
     The patient's father is diabetic.
     ```
 
-None of these expressions should be used to build a cohort: the detected entity is either negated, speculative, or does not concern the patient themself. That's why we need to **qualify the matched entities**.
+None of these expressions should be used to build a cohort: the detected entity is either negated, speculative, or does not concern the patient themself. That's why we need to **qualify the matched
+entities**.
 
 !!! warning
 
@@ -41,7 +40,7 @@ We can use EDS-NLP's qualifier pipes to achieve that. Let's add specific compone
 Adding qualifier pipes is straightforward:
 
 ```python hl_lines="25-29"
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 text = (
     "Motif de prise en charge : probable pneumopathie à COVID19, "
@@ -55,21 +54,19 @@ regex = dict(
 )
 terms = dict(respiratoire="asthmatique")
 
-nlp = edsnlp.blank("fr")
+nlp = edsnlp.blank("eds")
 nlp.add_pipe(
-    "eds.matcher",
-    config=dict(
+    eds.matcher(
         regex=regex,
         terms=terms,
         attr="LOWER",
     ),
 )
 
-nlp.add_pipe("eds.sentences")  # (1)
-
-nlp.add_pipe("eds.negation")  # Negation component
-nlp.add_pipe("eds.hypothesis")  # Speculation pipe
-nlp.add_pipe("eds.family")  # Family context detection
+nlp.add_pipe(eds.sentences())  # (1)
+nlp.add_pipe(eds.negation())  # Negation component
+nlp.add_pipe(eds.hypothesis())  # Speculation pipe
+nlp.add_pipe(eds.family())  # Family context detection
 ```
 
 1. Qualifiers pipes need sentence boundaries to be set (see the [specific documentation](../pipes/qualifiers/index.md) for detail).
@@ -81,7 +78,7 @@ This code is complete, and should run as is.
 Let's output the results as a pandas DataFrame for better readability:
 
 ```python hl_lines="2 34-48"
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 import pandas as pd
 
 text = (
@@ -96,21 +93,20 @@ regex = dict(
 )
 terms = dict(respiratoire="asthmatique")
 
-nlp = edsnlp.blank("fr")
+nlp = edsnlp.blank("eds")
 nlp.add_pipe(
-    "eds.matcher",
-    config=dict(
+    eds.matcher(
         regex=regex,
         terms=terms,
         attr="LOWER",
     ),
 )
 
-nlp.add_pipe("eds.sentences")
+nlp.add_pipe(eds.sentences())
 
-nlp.add_pipe("eds.negation")  # Negation component
-nlp.add_pipe("eds.hypothesis")  # Speculation pipe
-nlp.add_pipe("eds.family")  # Family context detection
+nlp.add_pipe(eds.negation())  # Negation component
+nlp.add_pipe(eds.hypothesis())  # Speculation pipe
+nlp.add_pipe(eds.family())  # Family context detection
 
 doc = nlp(text)
 
@@ -134,7 +130,7 @@ This code is complete, and should run as is.
 We get the following result:
 
 | lexical_variant | label        | negation | hypothesis | family |
-| :-------------- | :----------- | -------- | ---------- | ------ |
+|:----------------|:-------------|----------|------------|--------|
 | COVID19         | covid        | False    | True       | False  |
 | respiratoires   | respiratoire | True     | False      | False  |
 | asthmatique     | respiratoire | False    | False      | True   |
diff --git a/docs/tutorials/reason.md b/docs/tutorials/reason.md
index 69b5cda2d..e87e155f4 100644
--- a/docs/tutorials/reason.md
+++ b/docs/tutorials/reason.md
@@ -7,7 +7,7 @@ In this tutorial we will use the pipe `eds.reason` to :
 - Check for all named entities if they are tagged `is_reason`
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 text = """COMPTE RENDU D'HOSPITALISATION du 11/07/2018 au 12/07/2018
 MOTIF D'HOSPITALISATION
@@ -22,22 +22,19 @@ nlp = edsnlp.blank("eds")
 
 # Extraction d'entités nommées
 nlp.add_pipe(
-    "eds.matcher",
-    config=dict(
+    eds.matcher(
         terms=dict(
             respiratoire=[
                 "asthmatique",
                 "asthme",
                 "toux",
             ]
-        )
+        ),
     ),
 )
-
-
-nlp.add_pipe("eds.normalizer")
-nlp.add_pipe("eds.sections")
-nlp.add_pipe("eds.reason", config=dict(use_sections=True))
+nlp.add_pipe(eds.normalizer())
+nlp.add_pipe(eds.sections())
+nlp.add_pipe(eds.reason(use_sections=True))
 
 doc = nlp(text)
 ```
diff --git a/docs/tutorials/spacy101.md b/docs/tutorials/spacy101.md
index eee466635..21111c4d4 100644
--- a/docs/tutorials/spacy101.md
+++ b/docs/tutorials/spacy101.md
@@ -63,12 +63,12 @@ Span of text are represented by the `Span` object and represent slices of the `D
 - `doc.spans` is dict of groups of spans (that can overlap)
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 
 nlp = edsnlp.blank("eds")
 
-nlp.add_pipe("eds.sentences")  # (1)
-nlp.add_pipe("eds.dates")  # (2)
+nlp.add_pipe(eds.sentences())  # (1)
+nlp.add_pipe(eds.dates())  # (2)
 
 text = "Le 5 mai 2005, Jimothé a été invité à une fête organisée par Michel."
 
diff --git a/docs/utilities/connectors/labeltool.md b/docs/utilities/connectors/labeltool.md
index 7435b0c94..d26ef311f 100644
--- a/docs/utilities/connectors/labeltool.md
+++ b/docs/utilities/connectors/labeltool.md
@@ -5,7 +5,7 @@ LabelTool is an in-house module enabling rapid annotation of pre-extracted entit
 We provide a ready-to-use function that converts a list of annotated spaCy documents into a `pandas` DataFrame that is readable to LabelTool.
 
 ```python
-import spacy
+import edsnlp, edsnlp.pipes as eds
 
 from edsnlp.connectors.labeltool import docs2labeltool
 
@@ -15,10 +15,10 @@ corpus = [
 ]
 
 # Instantiate the spacy pipeline
-nlp = spacy.blank("fr")
-nlp.add_pipe("eds.sentences")
-nlp.add_pipe("eds.matcher", config=dict(terms=dict(medical="médical", malade="malade")))
-nlp.add_pipe("eds.negation")
+nlp = edsnlp.blank("fr")
+nlp.add_pipe(eds.sentences())
+nlp.add_pipe(eds.matcher(terms=dict(medical="médical", malade="malade")))
+nlp.add_pipe(eds.negation())
 
 # Convert all BRAT files to a list of documents
 docs = nlp.pipe(corpus)
diff --git a/docs/utilities/matchers.md b/docs/utilities/matchers.md
index 1ef5121e9..a7c4c5c00 100644
--- a/docs/utilities/matchers.md
+++ b/docs/utilities/matchers.md
@@ -17,11 +17,11 @@ have reimplemented the matching algorithm in Cython, like the original `spacy.Ph
 You can use it as described in the code below.
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from edsnlp.matchers.phrase import EDSPhraseMatcher
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer")
+nlp.add_pipe(eds.normalizer())
 doc = nlp("On ne relève pas de signe du Corona =============== virus.")
 
 matcher = EDSPhraseMatcher(nlp.vocab, attr="NORM")
@@ -48,11 +48,11 @@ lexical variations of the target phrases and feed them to the `PhraseMatcher` in
 You can use it as described in the code below.
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from edsnlp.matchers.regex import RegexMatcher
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer")
+nlp.add_pipe(eds.normalizer())
 doc = nlp("On ne relève pas de signe du Corona =============== virus.")
 
 matcher = RegexMatcher(attr="NORM", ignore_excluded=True)
@@ -88,11 +88,11 @@ and feed them to the `PhraseMatcher` instead.
 You can use it as described in the code below.
 
 ```python
-import edsnlp
+import edsnlp, edsnlp.pipes as eds
 from edsnlp.matchers.simstring import SimstringMatcher
 
 nlp = edsnlp.blank("eds")
-nlp.add_pipe("eds.normalizer")
+nlp.add_pipe(eds.normalizer())
 doc = nlp(
     "On ne relève pas de signe du corona-virus. Historique d'un hepatocellulaire carcinome."
 )
diff --git a/edsnlp/core/lazy_collection.py b/edsnlp/core/lazy_collection.py
index a4c23b2f9..10aeed58f 100644
--- a/edsnlp/core/lazy_collection.py
+++ b/edsnlp/core/lazy_collection.py
@@ -14,6 +14,7 @@
     List,
     Optional,
     Tuple,
+    Union,
 )
 
 from typing_extensions import Literal
@@ -41,6 +42,23 @@ def wrapper(self, **kwargs):
 Batchable = namedtuple("Batchable", ["batch_process"])
 
 
+class GPUOp:
+    def __init__(self, prepare_batch, forward, postprocess):
+        self.prepare_batch = prepare_batch
+        self.module_forward = forward
+        self.postprocess = postprocess
+
+    def batch_process(self, docs):
+        res = self.module_forward(self.prepare_batch(docs, None))
+        return self.postprocess(docs, res) if self.postprocess is not None else res
+
+    def enable_cache(self, cache_id=None):
+        pass
+
+    def disable_cache(self, cache_id=None):
+        pass
+
+
 class MetaLazyCollection(type):
     def __getattr__(self, item):
         if item in edsnlp.data.__all__:
@@ -80,7 +98,7 @@ def sort_chunks(self):
 
     @property
     def split_into_batches_after(self):
-        return self.config.get("split_into_batches_after")
+        return self.config.get("split_into_batches_after", None)
 
     @property
     def chunk_size(self):
@@ -254,6 +272,7 @@ def map(self, pipe, name: Optional[str] = None, kwargs={}) -> "LazyCollection":
         -------
         LazyCollection
         """
+        name = name or f"{repr(pipe)}-{id(kwargs)}"
         return LazyCollection(
             reader=self.reader,
             writer=self.writer,
@@ -283,6 +302,38 @@ def map_batches(
         """
         return self.map(Batchable(pipe), name, kwargs)
 
+    def map_gpu(
+        self,
+        prepare_batch: Callable[[List, Union[str, torch.device]], Any],
+        forward: Callable[[Any], Any],
+        postprocess: Optional[Callable[[List, Any], Any]] = None,
+        name: Optional[str] = None,
+    ) -> "LazyCollection":
+        """
+        Maps a deep learning operation to a batch of documents, on a GPU worker.
+
+        Parameters
+        ----------
+        prepare_batch: Callable[[List, Union[str, torch.device]], Any]
+            A callable that takes a list of documents and a device and returns a batch
+            of tensors (or anything that can be passed to the `forward` callable). This
+            will be called on a CPU-bound worker, and may be parallelized.
+        forward: Callable[[Any], Any]
+            A callable that takes the output of `prepare_batch` and returns the output
+            of the deep learning operation. This will be called on a GPU-bound worker.
+        postprocess: Optional[Callable[[List, Any], Any]]
+            An optional callable that takes the list of documents and the output of the
+            deep learning operation, and returns the final output. This will be called
+            on the same CPU-bound worker that called the `prepare_batch` function.
+        name: Optional[str]
+            The name of the pipeline step.
+
+        Returns
+        -------
+        LazyCollection
+        """
+        return self.map(GPUOp(prepare_batch, forward, postprocess), name, {})
+
     def map_pipeline(self, model: Pipeline) -> "LazyCollection":
         new_steps = []
         tokenizer = model.tokenizer
diff --git a/edsnlp/core/pipeline.py b/edsnlp/core/pipeline.py
index a2555e227..07885d159 100644
--- a/edsnlp/core/pipeline.py
+++ b/edsnlp/core/pipeline.py
@@ -199,13 +199,17 @@ def create_pipe(
         Pipe
         """
         try:
-            curried_factory: CurriedFactory = Config(
+            curried: CurriedFactory = Config(
                 {
                     "@factory": factory,
                     **(config if config is not None else {}),
                 }
             ).resolve(registry=registry)
-            pipe = curried_factory.instantiate(nlp=self, path=(name,))
+            if name is None:
+                name = inspect.signature(curried.factory).parameters.get("name").default
+            if name is None or name == inspect.Parameter.empty:
+                name = factory
+            pipe = curried.instantiate(nlp=self, path=(name,))
         except ConfitValidationError as e:
             raise e.with_traceback(None)
         return pipe
@@ -252,20 +256,25 @@ def add_pipe(
             The component that was added to the pipeline.
         """
         if isinstance(factory, str):
-            if name is None:
-                name = factory
             pipe = self.create_pipe(factory, name, config)
+            name = name or getattr(pipe, "name", factory)
         else:
             if config is not None:
                 raise ValueError(
                     "Can't pass config or name with an instantiated component",
                 )
+            if isinstance(factory, CurriedFactory):
+                name = name or factory.kwargs.get("name")
+                factory = factory.instantiate(nlp=self, path=(name,))
+
             pipe = factory
-            name = getattr(pipe, "name", None) or name
+            name = name or getattr(pipe, "name", None)
             if name is None:
                 raise ValueError(
                     "The component does not have a name, so you must provide one",
                 )
+        if hasattr(pipe, "name") and name != pipe.name:
+            pipe.name = name
         assert sum([before is not None, after is not None, first]) <= 1, (
             "You can only use one of before, after, or first",
         )
@@ -960,10 +969,10 @@ def blank(
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.covid")
+    nlp.add_pipe(eds.covid())
     ```
 
     Parameters
diff --git a/edsnlp/core/registries.py b/edsnlp/core/registries.py
index c61b39e2b..2b8c5cfd8 100644
--- a/edsnlp/core/registries.py
+++ b/edsnlp/core/registries.py
@@ -1,5 +1,7 @@
 import inspect
+import types
 from dataclasses import dataclass
+from functools import wraps
 from typing import Any, Callable, Dict, Iterable, Optional, Sequence
 from weakref import WeakKeyDictionary
 
@@ -60,7 +62,7 @@ def __init__(self, func, kwargs):
     def instantiate(
         obj: Any,
         nlp: "edsnlp.Pipeline",
-        path: Sequence[str] = (),
+        path: Optional[Sequence[str]] = (),
     ):
         """
         To ensure compatibility with spaCy's API, we need to support
@@ -74,14 +76,18 @@ def instantiate(
             if obj.instantiated is not None:
                 return obj.instantiated
 
-            name = ".".join(path)
+            name = path[0] if len(path) == 1 else None
             parameters = (
                 inspect.signature(obj.factory.__init__).parameters
                 if isinstance(obj.factory, type)
                 else inspect.signature(obj.factory).parameters
             )
             kwargs = {
-                key: CurriedFactory.instantiate(value, nlp, (*path, key))
+                key: CurriedFactory.instantiate(
+                    obj=value,
+                    nlp=nlp,
+                    path=(*path, key),
+                )
                 for key, value in obj.kwargs.items()
             }
             try:
@@ -107,7 +113,9 @@ def instantiate(
             for key, value in obj.items():
                 try:
                     instantiated[key] = CurriedFactory.instantiate(
-                        value, nlp, (*path, key)
+                        obj=value,
+                        nlp=nlp,
+                        path=(*path, key),
                     )
                 except ConfitValidationError as e:
                     errors.extend(e.raw_errors)
@@ -130,6 +138,22 @@ def instantiate(
         else:
             return obj
 
+    def _raise_curried_factory_error(self):
+        raise TypeError(
+            f"This component ({self}) has not been instantiated yet, likely because it "
+            f"was missing an `nlp` pipeline argument. You should either:\n"
+            f"- add it to a pipeline: `pipe = nlp.add_pipe(pipe)`\n"
+            f"- or fill its `nlp` argument: `pipe = factory(nlp=nlp, ...)`"
+        )
+
+    def __call__(self, *args, **kwargs):
+        self._raise_curried_factory_error()
+
+    def __getattr__(self, name):
+        if name.startswith("__"):
+            raise AttributeError(name)
+        self._raise_curried_factory_error()
+
 
 glob = []
 
@@ -312,10 +336,10 @@ def invoke(validated_fn, kwargs):
                 if not isinstance(fn, type)
                 else fn.__init__.__annotations__
             )
-            nlp_was_spacy_language = False
-            if "nlp" in annotations and annotations["nlp"] is spacy.Language:
-                nlp_was_spacy_language = True
-                annotations["nlp"] = edsnlp.core.PipelineProtocol
+            old_annotation = None
+            if "nlp" in annotations:  # annotations["nlp"] is spacy.Language:
+                old_annotation = annotations["nlp"]
+                annotations["nlp"] = Optional[edsnlp.core.PipelineProtocol]
             registered_fn = Registry.register(
                 self,
                 name=name,
@@ -325,8 +349,8 @@ def invoke(validated_fn, kwargs):
                 invoker=invoke,
                 deprecated=deprecated,
             )
-            if nlp_was_spacy_language:
-                annotations["nlp"] = spacy.Language
+            if old_annotation is not None:
+                annotations["nlp"] = old_annotation
 
             if spacy_compatible:
                 for spacy_pipe_name in (name, *deprecated):
@@ -340,7 +364,31 @@ def invoke(validated_fn, kwargs):
                         func=registered_fn,
                     )
 
-            return registered_fn
+            @wraps(fn)
+            def curried_registered_fn(
+                nlp=inspect.Signature.empty,
+                name=inspect.Signature.empty,
+                **kwargs,
+            ):
+                sig = inspect.signature(fn)
+                if nlp is not inspect.Signature.empty:
+                    kwargs["nlp"] = nlp
+                if name is not inspect.Signature.empty:
+                    kwargs["name"] = name
+                bound = sig.bind_partial(**kwargs)
+                if (
+                    "nlp" in sig.parameters
+                    and sig.parameters["nlp"].default is sig.empty
+                    and bound.arguments.get("nlp", sig.empty) is sig.empty
+                ):
+                    return CurriedFactory(registered_fn, dict(bound.arguments))
+                return registered_fn(**bound.arguments)
+
+            return (
+                curried_registered_fn
+                if isinstance(fn, types.FunctionType)
+                else registered_fn
+            )
 
         return register(func) if func is not None else register
 
diff --git a/edsnlp/core/torch_component.py b/edsnlp/core/torch_component.py
index aa07c91da..8f16e4752 100644
--- a/edsnlp/core/torch_component.py
+++ b/edsnlp/core/torch_component.py
@@ -1,5 +1,4 @@
 import os
-from abc import ABCMeta
 from enum import Enum
 from functools import wraps
 from typing import (
@@ -20,7 +19,7 @@
 import torch
 from spacy.tokens import Doc
 
-from edsnlp.pipes.base import BaseComponent
+from edsnlp.pipes.base import BaseComponent, BaseComponentMeta
 from edsnlp.utils.collections import batch_compress_dict, batchify, decompress_dict
 
 BatchInput = TypeVar("BatchInput", bound=Dict[str, Any])
@@ -153,7 +152,7 @@ def wrapped(self: "TorchComponent", batch, device):
     return wrapped
 
 
-class TorchComponentMeta(ABCMeta):
+class TorchComponentMeta(BaseComponentMeta):
     def __new__(mcs, name, bases, class_dict):
         if "preprocess" in class_dict:
             class_dict["preprocess"] = cached_preprocess(class_dict["preprocess"])
@@ -312,10 +311,11 @@ def batch_to_device(
         """
         return {
             name: (
-                value.to(device)
+                (value.to(device) if device is not None else value)
                 if hasattr(value, "to")
                 else getattr(self, name).batch_to_device(value, device=device)
                 if hasattr(self, name)
+                and hasattr(getattr(self, name), "batch_to_device")
                 else value
             )
             for name, value in batch.items()
@@ -344,11 +344,12 @@ def module_forward(self, *args, **kwargs):
         """
         return torch.nn.Module.__call__(self, *args, **kwargs)
 
-    def make_batch(
+    def prepare_batch(
         self,
         docs: Sequence[Doc],
         supervision: bool = False,
-    ) -> Dict[str, Sequence[Any]]:
+        device: Optional[Union[str, torch.device]] = None,
+    ) -> BatchInput:
         """
         Convenience method to preprocess a batch of documents and collate them
         Features corresponding to the same path are grouped together in a list,
@@ -360,6 +361,8 @@ def make_batch(
             Batch of documents
         supervision: bool
             Whether to extract supervision features or not
+        device: Optional[Union[str, torch.device]]
+            Device to move the tensors to
 
         Returns
         -------
@@ -369,7 +372,10 @@ def make_batch(
             (self.preprocess_supervised(doc) if supervision else self.preprocess(doc))
             for doc in docs
         ]
-        return decompress_dict(list(batch_compress_dict(batch)))
+        batch = decompress_dict(list(batch_compress_dict(batch)))
+        batch = self.collate(batch)
+        batch = self.batch_to_device(batch, device=device)
+        return batch
 
     def batch_process(self, docs: Sequence[Doc]) -> Sequence[Doc]:
         """
@@ -387,15 +393,12 @@ def batch_process(self, docs: Sequence[Doc]) -> Sequence[Doc]:
         Sequence[Doc]
             Batch of updated documents
         """
-        device = next((p.device for p in self.parameters()), "cpu")
         with torch.no_grad():
-            batch = self.make_batch(docs)
-            inputs = self.collate(batch)
-            inputs = self.batch_to_device(inputs, device=device)
+            batch = self.prepare_batch(docs, device=self.device)
             if hasattr(self, "compiled"):
-                res = self.compiled(inputs)
+                res = self.compiled(batch)
             else:
-                res = self.module_forward(inputs)
+                res = self.module_forward(batch)
             docs = self.postprocess(docs, res)
             return docs
 
diff --git a/edsnlp/data/base.py b/edsnlp/data/base.py
index 0c0414460..134c706d4 100644
--- a/edsnlp/data/base.py
+++ b/edsnlp/data/base.py
@@ -130,7 +130,7 @@ def from_iterable(
     -------
     LazyCollection
     """
-    data = LazyCollection(reader=IterableReader(data))
+    data = LazyCollection.ensure_lazy(data)
     if converter:
         converter, kwargs = get_dict2doc_converter(converter, kwargs)
         data = data.map(converter, kwargs=kwargs)
diff --git a/edsnlp/data/converters.py b/edsnlp/data/converters.py
index 63de2e0be..c94cf5da1 100644
--- a/edsnlp/data/converters.py
+++ b/edsnlp/data/converters.py
@@ -307,15 +307,17 @@ class StandoffDoc2DictConverter:
     --------
 
     ```{ .python .no-check }
-    # Any kind of reader (`edsnlp.data.read/from_...`) can be used here
-    docs = edsnlp.data.write_standoff(
-        "path/to/standoff",
+    # Any kind of writer (`edsnlp.data.read/from_...`) can be used here
+    edsnlp.data.write_standoff(
+        docs,
         converter="standoff",  # set by default
 
         # Optional parameters
         span_getter={"ents": True},
         span_attributes={"negation": "negated"},
     )
+    # or docs.to_standoff(...) if it's already a
+    # [LazyCollection][edsnlp.core.lazy_collection.LazyCollection]
     ```
 
     Parameters
@@ -492,8 +494,8 @@ class OmopDoc2DictConverter:
     --------
 
     ```{ .python .no-check }
-    # Any kind of reader (`edsnlp.data.read/from_...`) can be used here
-    docs = edsnlp.data.to_pandas(
+    # Any kind of writer (`edsnlp.data.write/to_...`) can be used here
+    df = edsnlp.data.to_pandas(
         docs,
         converter="omop",
 
@@ -502,6 +504,8 @@ class OmopDoc2DictConverter:
         doc_attributes=["note_datetime"],
         span_attributes=["negation", "family"],
     )
+    # or docs.to_pandas(...) if it's already a
+    # [LazyCollection][edsnlp.core.lazy_collection.LazyCollection]
     ```
 
     Parameters
diff --git a/edsnlp/data/json.py b/edsnlp/data/json.py
index 499cfc94c..7d3811f0b 100644
--- a/edsnlp/data/json.py
+++ b/edsnlp/data/json.py
@@ -202,7 +202,7 @@ def write_main(self, fragments: Iterable[Union[List[Path], List[str]]]):
 @registry.readers.register("json")
 def read_json(
     path: Union[str, Path],
-    converter: Union[str, Callable],
+    converter: Optional[Union[str, Callable]] = None,
     *,
     keep_ipynb_checkpoints: bool = False,
     read_in_worker: bool = False,
diff --git a/edsnlp/data/pandas.py b/edsnlp/data/pandas.py
index f046f772d..d250705d3 100644
--- a/edsnlp/data/pandas.py
+++ b/edsnlp/data/pandas.py
@@ -38,7 +38,7 @@ def read_worker(self, fragments):
 @registry.readers.register("pandas")
 def from_pandas(
     data,
-    converter: Union[str, Callable],
+    converter: Optional[Union[str, Callable]] = None,
     **kwargs,
 ) -> LazyCollection:
     """
diff --git a/edsnlp/data/parquet.py b/edsnlp/data/parquet.py
index fd2b97227..e58beb95b 100644
--- a/edsnlp/data/parquet.py
+++ b/edsnlp/data/parquet.py
@@ -159,7 +159,7 @@ def write_main(self, fragments: Iterable[List[Union[pyarrow.Table, Path]]]):
 @registry.readers.register("parquet")
 def read_parquet(
     path: Union[str, Path],
-    converter: Union[str, Callable],
+    converter: Optional[Union[str, Callable]] = None,
     *,
     read_in_worker: bool = False,
     filesystem: Optional[FileSystem] = None,
diff --git a/edsnlp/data/polars.py b/edsnlp/data/polars.py
index a8c542630..f19e76e62 100644
--- a/edsnlp/data/polars.py
+++ b/edsnlp/data/polars.py
@@ -40,7 +40,7 @@ def read_worker(self, fragments):
 @registry.readers.register("polars")
 def from_polars(
     data: Union[pl.DataFrame, pl.LazyFrame],
-    converter: Union[str, Callable],
+    converter: Optional[Union[str, Callable]] = None,
     **kwargs,
 ) -> LazyCollection:
     """
diff --git a/edsnlp/data/spark.py b/edsnlp/data/spark.py
index 7824fd998..8486456cf 100644
--- a/edsnlp/data/spark.py
+++ b/edsnlp/data/spark.py
@@ -39,7 +39,7 @@ def read_worker(self, fragment):
 @registry.readers.register("spark")
 def from_spark(
     data,
-    converter: Union[str, Callable],
+    converter: Optional[Union[str, Callable]] = None,
     **kwargs,
 ) -> LazyCollection:
     """
@@ -151,10 +151,10 @@ def to_spark(
     -------
     ```{ .python .no-check }
 
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.covid")
+    nlp.add_pipe(eds.covid())
 
     note_df = sql('''
         select note_id, note_text from note
diff --git a/edsnlp/pipes/__init__.py b/edsnlp/pipes/__init__.py
index e69de29bb..dea40bc5c 100644
--- a/edsnlp/pipes/__init__.py
+++ b/edsnlp/pipes/__init__.py
@@ -0,0 +1,81 @@
+from typing import TYPE_CHECKING
+
+from edsnlp.utils.lazy_module import lazify
+
+lazify()
+
+if TYPE_CHECKING:
+    from .core.contextual_matcher.factory import create_component as contextual_matcher
+    from .core.endlines.factory import create_component as endlines
+    from .core.matcher.factory import create_component as matcher
+    from .core.normalizer.accents.factory import create_component as accents
+    from .core.normalizer.factory import create_component as normalizer
+    from .core.normalizer.pollution.factory import create_component as pollution
+    from .core.normalizer.quotes.factory import create_component as quotes
+    from .core.normalizer.remove_lowercase.factory import (
+        create_component as remove_lowercase,
+    )
+    from .core.normalizer.spaces.factory import create_component as spaces
+    from .core.sentences.factory import create_component as sentences
+    from .core.terminology.factory import create_component as terminology
+    from .misc.consultation_dates.factory import create_component as consultation_dates
+    from .misc.dates.factory import create_component as dates
+    from .misc.measurements.factory import create_component as measurements
+    from .misc.reason.factory import create_component as reason
+    from .misc.sections.factory import create_component as sections
+    from .misc.tables.factory import create_component as tables
+    from .ner.adicap.factory import create_component as adicap
+    from .ner.behaviors.alcohol.factory import create_component as alcohol
+    from .ner.behaviors.tobacco.factory import create_component as tobacco
+    from .ner.cim10.factory import create_component as cim10
+    from .ner.covid.factory import create_component as covid
+    from .ner.disorders.aids.factory import create_component as aids
+    from .ner.disorders.cerebrovascular_accident.factory import (
+        create_component as cerebrovascular_accident,
+    )
+    from .ner.disorders.ckd.factory import create_component as ckd
+    from .ner.disorders.congestive_heart_failure.factory import (
+        create_component as congestive_heart_failure,
+    )
+    from .ner.disorders.connective_tissue_disease.factory import (
+        create_component as connective_tissue_disease,
+    )
+    from .ner.disorders.copd.factory import create_component as copd
+    from .ner.disorders.dementia.factory import create_component as dementia
+    from .ner.disorders.diabetes.factory import create_component as diabetes
+    from .ner.disorders.hemiplegia.factory import create_component as hemiplegia
+    from .ner.disorders.leukemia.factory import create_component as leukemia
+    from .ner.disorders.liver_disease.factory import create_component as liver_disease
+    from .ner.disorders.lymphoma.factory import create_component as lymphoma
+    from .ner.disorders.myocardial_infarction.factory import (
+        create_component as myocardial_infarction,
+    )
+    from .ner.disorders.peptic_ulcer_disease.factory import (
+        create_component as peptic_ulcer_disease,
+    )
+    from .ner.disorders.peripheral_vascular_disease.factory import (
+        create_component as peripheral_vascular_disease,
+    )
+    from .ner.disorders.solid_tumor.factory import create_component as solid_tumor
+    from .ner.drugs.factory import create_component as drugs
+    from .ner.scores.charlson.factory import create_component as charlson
+    from .ner.scores.elston_ellis.factory import create_component as elston_ellis
+    from .ner.scores.emergency.ccmu.factory import create_component as emergency_ccmu
+    from .ner.scores.emergency.gemsa.factory import create_component as emergency_gemsa
+    from .ner.scores.emergency.priority.factory import create_component as emergency_priority
+    from .ner.scores.factory import create_component as score
+    from .ner.scores.sofa.factory import create_component as sofa
+    from .ner.tnm.factory import create_component as tnm
+    from .ner.umls.factory import create_component as umls
+    from .qualifiers.family.factory import create_component as family
+    from .qualifiers.history.factory import create_component as history
+    from .qualifiers.hypothesis.factory import create_component as hypothesis
+    from .qualifiers.negation.factory import create_component as negation
+    from .qualifiers.reported_speech.factory import create_component as reported_speech
+    from .qualifiers.reported_speech.factory import create_component as rspeech
+    from .trainable.ner_crf.factory import create_component as ner_crf
+    from .trainable.span_qualifier.factory import create_component as span_qualifier
+    from .trainable.span_linker.factory import create_component as span_linker
+    from .trainable.embeddings.span_pooler.factory import create_component as span_pooler
+    from .trainable.embeddings.transformer.factory import create_component as transformer
+    from .trainable.embeddings.text_cnn.factory import create_component as text_cnn
diff --git a/edsnlp/pipes/base.py b/edsnlp/pipes/base.py
index c22f2f6d3..d77718527 100644
--- a/edsnlp/pipes/base.py
+++ b/edsnlp/pipes/base.py
@@ -1,3 +1,4 @@
+import inspect
 import warnings
 from operator import attrgetter
 from typing import (
@@ -9,6 +10,7 @@
 from spacy.tokens import Doc, Span
 
 from edsnlp.core import PipelineProtocol
+from edsnlp.core.registries import CurriedFactory
 from edsnlp.utils.span_getters import (
     SpanGetter,  # noqa: F401
     SpanGetterArg,  # noqa: F401
@@ -28,7 +30,32 @@ def value_getter(span: Span):
     return span._.get(span.label_) if span._.has(span.label_) else None
 
 
-class BaseComponent:
+class BaseComponentMeta(type):
+    def __init__(cls, name, bases, dct):
+        super().__init__(name, bases, dct)
+
+        sig = inspect.signature(cls.__init__)
+        sig = sig.replace(parameters=tuple(sig.parameters.values())[1:])
+        cls.__signature__ = sig
+
+    def __call__(cls, nlp=inspect.Signature.empty, *args, **kwargs):
+        # If this component is missing the nlp argument, we curry it with the
+        # provided arguments and return a CurriedFactory object.
+        sig = inspect.signature(cls.__init__)
+        bound = sig.bind_partial(None, nlp, *args, **kwargs)
+        bound.arguments.pop("self", None)
+        if (
+            "nlp" in sig.parameters
+            and sig.parameters["nlp"].default is sig.empty
+            and bound.arguments.get("nlp", sig.empty) is sig.empty
+        ):
+            return CurriedFactory(cls, bound.arguments)
+        if nlp is inspect.Signature.empty:
+            bound.arguments.pop("nlp", None)
+        return super().__call__(**bound.arguments)
+
+
+class BaseComponent(metaclass=BaseComponentMeta):
     """
     The `BaseComponent` adds a `set_extensions` method,
     called at the creation of the object.
@@ -47,6 +74,7 @@ def __init__(
         **kwargs,
     ):
         super().__init__(*args, **kwargs)
+        self.name = name
         self.set_extensions()
 
     def set_extensions(self):
diff --git a/edsnlp/pipes/core/contextual_matcher/contextual_matcher.py b/edsnlp/pipes/core/contextual_matcher/contextual_matcher.py
index 116be6b48..a68f343e7 100644
--- a/edsnlp/pipes/core/contextual_matcher/contextual_matcher.py
+++ b/edsnlp/pipes/core/contextual_matcher/contextual_matcher.py
@@ -73,7 +73,7 @@ class ContextualMatcher(BaseNERComponent):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol],
-        name: Optional[str] = None,
+        name: Optional[str] = "contextual_matcher",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]],
         assign_as_span: bool = False,
diff --git a/edsnlp/pipes/core/endlines/endlines.py b/edsnlp/pipes/core/endlines/endlines.py
index a2fb2edf6..42003017d 100644
--- a/edsnlp/pipes/core/endlines/endlines.py
+++ b/edsnlp/pipes/core/endlines/endlines.py
@@ -69,14 +69,14 @@ class EndLinesMatcher(GenericMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
     from spacy.tokens import Span
     from spacy import displacy
 
     nlp = edsnlp.blank("eds")
 
     PATH = "/tmp/path_to_save"
-    nlp.add_pipe("eds.endlines", config=dict(model_path=PATH))
+    nlp.add_pipe(eds.endlines(model_path=PATH))
 
     docs = list(nlp.pipe(texts))
 
@@ -120,7 +120,7 @@ class EndLinesMatcher(GenericMatcher):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = "eds.endlines",
+        name: Optional[str] = "endlines",
         *,
         model_path: Optional[Union[str, EndLinesModel]] = None,
     ):
diff --git a/edsnlp/pipes/core/matcher/matcher.py b/edsnlp/pipes/core/matcher/matcher.py
index a6645bfe3..e824e9f9d 100644
--- a/edsnlp/pipes/core/matcher/matcher.py
+++ b/edsnlp/pipes/core/matcher/matcher.py
@@ -21,7 +21,7 @@ class GenericMatcher(BaseNERComponent):
     Let us redefine the pipeline :
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
 
@@ -35,8 +35,7 @@ class GenericMatcher(BaseNERComponent):
     )
 
     nlp.add_pipe(
-        "eds.matcher",
-        config=dict(
+        eds.matcher(
             terms=terms,
             regex=regex,
             attr="LOWER",
@@ -93,7 +92,7 @@ class GenericMatcher(BaseNERComponent):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = "eds.matcher",
+        name: Optional[str] = "matcher",
         *,
         terms: Optional[Patterns] = None,
         regex: Optional[Patterns] = None,
diff --git a/edsnlp/pipes/core/normalizer/accents/accents.py b/edsnlp/pipes/core/normalizer/accents/accents.py
index 688880adc..155f683a3 100644
--- a/edsnlp/pipes/core/normalizer/accents/accents.py
+++ b/edsnlp/pipes/core/normalizer/accents/accents.py
@@ -25,7 +25,7 @@ class AccentsConverter(BaseComponent):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol] = None,
-        name: Optional[str] = "eds.spaces",
+        name: Optional[str] = "spaces",
         *,
         accents: List[Tuple[str, str]] = patterns.accents,
     ):
diff --git a/edsnlp/pipes/core/normalizer/factory.py b/edsnlp/pipes/core/normalizer/factory.py
index 691def732..d85e8f4e5 100644
--- a/edsnlp/pipes/core/normalizer/factory.py
+++ b/edsnlp/pipes/core/normalizer/factory.py
@@ -25,7 +25,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: str = "eds.normalizer",
+    name: str = "normalizer",
     *,
     accents: Union[bool, Dict[str, Any]] = True,
     lowercase: Union[bool, Dict[str, Any]] = True,
diff --git a/edsnlp/pipes/core/normalizer/normalizer.py b/edsnlp/pipes/core/normalizer/normalizer.py
index 6383ee322..1c60c1c92 100644
--- a/edsnlp/pipes/core/normalizer/normalizer.py
+++ b/edsnlp/pipes/core/normalizer/normalizer.py
@@ -44,7 +44,7 @@ class Normalizer(BaseComponent):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol],
-        name: Optional[str] = "eds.normalizer",
+        name: Optional[str] = "normalizer",
         *,
         lowercase: bool = False,
         accents: Optional[AccentsConverter] = None,
diff --git a/edsnlp/pipes/core/normalizer/pollution/pollution.py b/edsnlp/pipes/core/normalizer/pollution/pollution.py
index e7a0b61db..8db1e3177 100644
--- a/edsnlp/pipes/core/normalizer/pollution/pollution.py
+++ b/edsnlp/pipes/core/normalizer/pollution/pollution.py
@@ -38,7 +38,7 @@ class PollutionTagger(BaseComponent):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = "eds.pollution",
+        name: Optional[str] = "pollution",
         *,
         pollution: Dict[str, Union[bool, str, List[str]]] = default_enabled,
     ):
diff --git a/edsnlp/pipes/core/normalizer/quotes/quotes.py b/edsnlp/pipes/core/normalizer/quotes/quotes.py
index 24b5d6a91..bb5a690c8 100644
--- a/edsnlp/pipes/core/normalizer/quotes/quotes.py
+++ b/edsnlp/pipes/core/normalizer/quotes/quotes.py
@@ -26,7 +26,7 @@ class QuotesConverter(BaseComponent):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol] = None,
-        name: Optional[str] = "eds.spaces",
+        name: Optional[str] = "spaces",
         *,
         quotes: List[Tuple[str, str]] = quotes_and_apostrophes,
     ):
diff --git a/edsnlp/pipes/core/normalizer/spaces/spaces.py b/edsnlp/pipes/core/normalizer/spaces/spaces.py
index 20737fa7f..8a0afc75c 100644
--- a/edsnlp/pipes/core/normalizer/spaces/spaces.py
+++ b/edsnlp/pipes/core/normalizer/spaces/spaces.py
@@ -24,7 +24,7 @@ class SpacesTagger(BaseComponent):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol] = None,
-        name: Optional[str] = "eds.spaces",
+        name: Optional[str] = "spaces",
         *,
         newline: bool = True,
     ):
diff --git a/edsnlp/pipes/core/sentences/factory.py b/edsnlp/pipes/core/sentences/factory.py
index 76d860751..01e480cee 100644
--- a/edsnlp/pipes/core/sentences/factory.py
+++ b/edsnlp/pipes/core/sentences/factory.py
@@ -18,7 +18,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: str = "eds.sentences",
+    name: str = "sentences",
     *,
     punct_chars: Optional[List[str]] = None,
     use_endlines: Optional[bool] = None,
@@ -43,10 +43,10 @@ def create_component(
     === "EDS-NLP"
 
         ```{ .python .no-check }
-        import edsnlp
+        import edsnlp, edsnlp.pipes as eds
 
         nlp = edsnlp.blank("eds")
-        nlp.add_pipe("eds.sentences")
+        nlp.add_pipe(eds.sentences())  # same as nlp.add_pipe("eds.sentences")
 
         text = """Le patient est admis le 23 août 2021 pour une douleur à l'estomac
         Il lui était arrivé la même chose il y a deux ans."
@@ -64,7 +64,7 @@ def create_component(
     === "spaCy sentencizer"
 
         ```{ .python .no-check }
-        import edsnlp
+        import edsnlp, edsnlp.pipes as eds
 
         nlp = edsnlp.blank("eds")
         nlp.add_pipe("sentencizer")
diff --git a/edsnlp/pipes/core/sentences/sentences.py b/edsnlp/pipes/core/sentences/sentences.py
index b91425a4c..38401a110 100644
--- a/edsnlp/pipes/core/sentences/sentences.py
+++ b/edsnlp/pipes/core/sentences/sentences.py
@@ -22,7 +22,7 @@ class SentenceSegmenter(BaseComponent):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = None,
+        name: Optional[str] = "sentences",
         punct_chars: Optional[List[str]] = None,
         use_endlines: bool = None,
         ignore_excluded: bool = True,
diff --git a/edsnlp/pipes/core/terminology/terminology.py b/edsnlp/pipes/core/terminology/terminology.py
index bc9cda7e2..f5fe456f6 100644
--- a/edsnlp/pipes/core/terminology/terminology.py
+++ b/edsnlp/pipes/core/terminology/terminology.py
@@ -38,7 +38,7 @@ class TerminologyMatcher(BaseNERComponent):
     Let us redefine the pipeline :
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
 
@@ -52,8 +52,7 @@ class TerminologyMatcher(BaseNERComponent):
     )
 
     nlp.add_pipe(
-        "eds.terminology",
-        config=dict(
+        eds.terminology(
             label="disease",
             terms=terms,
             regex=regex,
@@ -106,7 +105,7 @@ class TerminologyMatcher(BaseNERComponent):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = None,
+        name: Optional[str] = "terminology",
         *,
         terms: Optional[Patterns] = None,
         regex: Optional[Patterns] = None,
diff --git a/edsnlp/pipes/factories.py b/edsnlp/pipes/factories.py
deleted file mode 100644
index e5ba60044..000000000
--- a/edsnlp/pipes/factories.py
+++ /dev/null
@@ -1,76 +0,0 @@
-from typing import TYPE_CHECKING
-
-from edsnlp.utils.lazy_module import lazify
-
-lazify()
-
-if TYPE_CHECKING:
-    from .core.contextual_matcher.factory import create_component as contextual_matcher
-    from .core.endlines.factory import create_component as endlines
-    from .core.matcher.factory import create_component as matcher
-    from .core.normalizer.accents.factory import create_component as accents
-    from .core.normalizer.factory import create_component as normalizer
-    from .core.normalizer.pollution.factory import create_component as pollution
-    from .core.normalizer.quotes.factory import create_component as quotes
-    from .core.normalizer.remove_lowercase.factory import (
-        create_component as remove_lowercase,
-    )
-    from .core.normalizer.spaces.factory import create_component as spaces
-    from .core.sentences.factory import create_component as sentences
-    from .core.terminology.factory import create_component as terminology
-    from .misc.consultation_dates.factory import create_component as consultation_dates
-    from .misc.dates.factory import create_component as dates
-    from .misc.measurements.factory import create_component as measurements
-    from .misc.reason.factory import create_component as reason
-    from .misc.sections.factory import create_component as sections
-    from .misc.tables.factory import create_component as tables
-    from .ner.adicap.factory import create_component as adicap
-    from .ner.behaviors.alcohol.factory import create_component as alcohol
-    from .ner.behaviors.tobacco.factory import create_component as tobacco
-    from .ner.cim10.factory import create_component as cim10
-    from .ner.covid.factory import create_component as covid
-    from .ner.disorders.aids.factory import create_component as aids
-    from .ner.disorders.cerebrovascular_accident.factory import (
-        create_component as cerebrovascular_accident,
-    )
-    from .ner.disorders.ckd.factory import create_component as ckd
-    from .ner.disorders.congestive_heart_failure.factory import (
-        create_component as congestive_heart_failure,
-    )
-    from .ner.disorders.connective_tissue_disease.factory import (
-        create_component as connective_tissue_disease,
-    )
-    from .ner.disorders.copd.factory import create_component as copd
-    from .ner.disorders.dementia.factory import create_component as dementia
-    from .ner.disorders.diabetes.factory import create_component as diabetes
-    from .ner.disorders.hemiplegia.factory import create_component as hemiplegia
-    from .ner.disorders.leukemia.factory import create_component as leukemia
-    from .ner.disorders.liver_disease.factory import create_component as liver_disease
-    from .ner.disorders.lymphoma.factory import create_component as lymphoma
-    from .ner.disorders.myocardial_infarction.factory import (
-        create_component as myocardial_infarction,
-    )
-    from .ner.disorders.peptic_ulcer_disease.factory import (
-        create_component as peptic_ulcer_disease,
-    )
-    from .ner.disorders.peripheral_vascular_disease.factory import (
-        create_component as peripheral_vascular_disease,
-    )
-    from .ner.disorders.solid_tumor.factory import create_component as solid_tumor
-    from .ner.drugs.factory import create_component as drugs
-    from .ner.scores.charlson.factory import create_component as charlson
-    from .ner.scores.elston_ellis.factory import create_component as elston_ellis
-    from .ner.scores.emergency.ccmu.factory import create_component as ccmu
-    from .ner.scores.emergency.gemsa.factory import create_component as gemsa
-    from .ner.scores.emergency.priority.factory import create_component as priority
-    from .ner.scores.factory import create_component as score
-    from .ner.scores.sofa.factory import create_component as sofa
-    from .ner.tnm.factory import create_component as tnm
-    from .ner.umls.factory import create_component as umls
-    from .qualifiers.family.factory import create_component as family
-    from .qualifiers.history.factory import create_component as history
-    from .qualifiers.hypothesis.factory import create_component as hypothesis
-    from .qualifiers.negation.factory import create_component as negation
-    from .qualifiers.reported_speech.factory import create_component as rspeech
-    from .trainable.ner_crf.factory import create_component as ner_crf
-    from .trainable.span_qualifier.factory import create_component as span_qualifier
diff --git a/edsnlp/pipes/misc/consultation_dates/consultation_dates.py b/edsnlp/pipes/misc/consultation_dates/consultation_dates.py
index 5df1a7dec..a28820b37 100644
--- a/edsnlp/pipes/misc/consultation_dates/consultation_dates.py
+++ b/edsnlp/pipes/misc/consultation_dates/consultation_dates.py
@@ -26,20 +26,19 @@ class ConsultationDatesMatcher(GenericMatcher):
         (`CR-CONS` at APHP), so please filter accordingly before proceeding.
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             lowercase=True,
             accents=True,
             quotes=True,
             pollution=False,
         ),
     )
-    nlp.add_pipe("eds.consultation_dates")
+    nlp.add_pipe(eds.consultation_dates())
 
     text = """
     XXX
@@ -103,7 +102,7 @@ class ConsultationDatesMatcher(GenericMatcher):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = "eds.consultation_dates",
+        name: Optional[str] = "consultation_dates",
         *,
         consultation_mention: Union[bool, List[str]] = True,
         town_mention: Union[bool, List[str]] = False,
diff --git a/edsnlp/pipes/misc/dates/dates.py b/edsnlp/pipes/misc/dates/dates.py
index 5ed7893ba..fa10b1b60 100644
--- a/edsnlp/pipes/misc/dates/dates.py
+++ b/edsnlp/pipes/misc/dates/dates.py
@@ -45,12 +45,12 @@ class DatesMatcher(BaseNERComponent):
     ## Usage
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     import pendulum
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.dates")
+    nlp.add_pipe(eds.dates())
 
     text = (
         "Le patient est admis le 23 août 2021 pour une douleur à l'estomac. "
@@ -163,7 +163,7 @@ class DatesMatcher(BaseNERComponent):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: str = "eds.dates",
+        name: str = "dates",
         *,
         absolute: Optional[List[str]] = None,
         relative: Optional[List[str]] = None,
diff --git a/edsnlp/pipes/misc/measurements/measurements.py b/edsnlp/pipes/misc/measurements/measurements.py
index c162327d9..45fa60778 100644
--- a/edsnlp/pipes/misc/measurements/measurements.py
+++ b/edsnlp/pipes/misc/measurements/measurements.py
@@ -295,12 +295,11 @@ class MeasurementsMatcher(BaseNERComponent):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
     nlp.add_pipe(
-        "eds.measurements",
-        config=dict(
+        eds.measurements(
             measurements=["size", "weight", "bmi"],
             extract_ranges=True,
         ),
@@ -370,12 +369,11 @@ class MeasurementsMatcher(BaseNERComponent):
     You can declare custom measurements by altering the patterns:
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
     nlp.add_pipe(
-        "eds.measurements",
-        config=dict(
+        eds.measurements(
             measurements={
                 "my_custom_surface_measurement": {
                     # This measurement unit is homogenous to square meters
@@ -476,7 +474,7 @@ class MeasurementsMatcher(BaseNERComponent):
     def __init__(
           self,
           nlp: PipelineProtocol,
-          name: str = "eds.measurements",
+          name: str = "measurements",
           *,
           measurements: Union[str, List[Union[str, MsrConfig]], Dict[str, MsrConfig]] = list(patterns.common_measurements.keys()),  # noqa: E501
           units_config: Dict[str, UnitConfig] = patterns.units_config,
diff --git a/edsnlp/pipes/misc/reason/reason.py b/edsnlp/pipes/misc/reason/reason.py
index 74cbb23cd..632c26cf2 100644
--- a/edsnlp/pipes/misc/reason/reason.py
+++ b/edsnlp/pipes/misc/reason/reason.py
@@ -21,7 +21,7 @@ class ReasonMatcher(GenericMatcher):
     hospitalisation reasons. It is complete and can be run _as is_.
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     text = """COMPTE RENDU D'HOSPITALISATION du 11/07/2018 au 12/07/2018
     MOTIF D'HOSPITALISATION
@@ -36,8 +36,7 @@ class ReasonMatcher(GenericMatcher):
 
     # Extraction of entities
     nlp.add_pipe(
-        "eds.matcher",
-        config=dict(
+        eds.matcher(
             terms=dict(
                 respiratoire=[
                     "asthmatique",
@@ -47,10 +46,8 @@ class ReasonMatcher(GenericMatcher):
             )
         ),
     )
-
-
-    nlp.add_pipe("eds.normalizer")
-    nlp.add_pipe("eds.reason", config=dict(use_sections=True))
+    nlp.add_pipe(eds.normalizer())
+    nlp.add_pipe(eds.reason(use_sections=True))
     doc = nlp(text)
 
     reason = doc.spans["reasons"][0]
@@ -107,7 +104,7 @@ class ReasonMatcher(GenericMatcher):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: str = "eds.reason",
+        name: str = "reason",
         *,
         reasons: Optional[Dict[str, Union[List[str], str]]] = None,
         attr: Union[Dict[str, str], str] = "TEXT",
diff --git a/edsnlp/pipes/misc/sections/sections.py b/edsnlp/pipes/misc/sections/sections.py
index 3baf13713..a026dfa7e 100644
--- a/edsnlp/pipes/misc/sections/sections.py
+++ b/edsnlp/pipes/misc/sections/sections.py
@@ -61,11 +61,11 @@ class SectionsMatcher(GenericMatcher):
     The following snippet detects section titles. It is complete and can be run _as is_.
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.normalizer")
-    nlp.add_pipe("eds.sections")
+    nlp.add_pipe(eds.normalizer())
+    nlp.add_pipe(eds.sections())
 
     text = """
     CRU du 10/09/2021
@@ -112,7 +112,7 @@ class SectionsMatcher(GenericMatcher):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: str = "eds.sections",
+        name: str = "sections",
         *,
         sections: Dict[str, List[str]] = patterns.sections,
         add_patterns: bool = True,
diff --git a/edsnlp/pipes/misc/tables/tables.py b/edsnlp/pipes/misc/tables/tables.py
index 7cda178eb..5ed05a048 100644
--- a/edsnlp/pipes/misc/tables/tables.py
+++ b/edsnlp/pipes/misc/tables/tables.py
@@ -17,11 +17,11 @@ class TablesMatcher(GenericMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.normalizer")
-    nlp.add_pipe("eds.tables")
+    nlp.add_pipe(eds.normalizer())
+    nlp.add_pipe(eds.tables())
 
     text = """
     SERVICE
@@ -117,7 +117,7 @@ class TablesMatcher(GenericMatcher):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: str = "eds.tables",
+        name: str = "tables",
         *,
         tables_pattern: Optional[Dict[str, str]] = None,
         sep_pattern: Optional[str] = None,
diff --git a/edsnlp/pipes/ner/adicap/adicap.py b/edsnlp/pipes/ner/adicap/adicap.py
index 7a85664fb..747737d62 100644
--- a/edsnlp/pipes/ner/adicap/adicap.py
+++ b/edsnlp/pipes/ner/adicap/adicap.py
@@ -51,11 +51,11 @@ class AdicapMatcher(ContextualMatcher):
     Examples
     --------
     ```{ .python .no-check }
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
-    nlp.add_pipe("eds.adicap")
+    nlp.add_pipe(eds.sentences())
+    nlp.add_pipe(eds.adicap())
 
     text = \"\"\"
     COMPTE RENDU D’EXAMEN
@@ -138,7 +138,7 @@ class AdicapMatcher(ContextualMatcher):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol],
-        name: str = "eds.adicap",
+        name: str = "adicap",
         *,
         pattern: Union[List[str], str] = patterns.base_code,
         prefix: Union[List[str], str] = patterns.adicap_prefix,
diff --git a/edsnlp/pipes/ner/behaviors/alcohol/alcohol.py b/edsnlp/pipes/ner/behaviors/alcohol/alcohol.py
index 951cbfc57..7f6f6423f 100644
--- a/edsnlp/pipes/ner/behaviors/alcohol/alcohol.py
+++ b/edsnlp/pipes/ner/behaviors/alcohol/alcohol.py
@@ -33,13 +33,12 @@ class AlcoholMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -84,8 +83,8 @@ class AlcoholMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.alcohol",
+        nlp: Optional[PipelineProtocol],
+        name: str = "alcohol",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label="alcohol",
diff --git a/edsnlp/pipes/ner/behaviors/tobacco/tobacco.py b/edsnlp/pipes/ner/behaviors/tobacco/tobacco.py
index f259d000b..e404af02f 100644
--- a/edsnlp/pipes/ner/behaviors/tobacco/tobacco.py
+++ b/edsnlp/pipes/ner/behaviors/tobacco/tobacco.py
@@ -37,13 +37,12 @@ class TobaccoMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -59,7 +58,7 @@ class TobaccoMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.tobacco")
+    nlp.add_pipe(eds.tobacco())
     ```
 
     Below are a few examples:
@@ -89,8 +88,8 @@ class TobaccoMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.tobacco",
+        nlp: Optional[PipelineProtocol],
+        name: str = "tobacco",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "tobacco",
diff --git a/edsnlp/pipes/ner/cim10/factory.py b/edsnlp/pipes/ner/cim10/factory.py
index 7ba67d4c8..b61967306 100644
--- a/edsnlp/pipes/ner/cim10/factory.py
+++ b/edsnlp/pipes/ner/cim10/factory.py
@@ -25,7 +25,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: str = "eds.cim10",
+    name: str = "cim10",
     *,
     attr: str = "NORM",
     ignore_excluded: bool = False,
@@ -48,10 +48,10 @@ def create_component(
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.cim10", config=dict(term_matcher="simstring"))
+    nlp.add_pipe(eds.cim10(term_matcher="simstring"))
 
     text = "Le patient est suivi pour fièvres typhoïde et paratyphoïde."
 
diff --git a/edsnlp/pipes/ner/covid/factory.py b/edsnlp/pipes/ner/covid/factory.py
index 8a9d6248b..9cd40a629 100644
--- a/edsnlp/pipes/ner/covid/factory.py
+++ b/edsnlp/pipes/ner/covid/factory.py
@@ -22,7 +22,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: str = "eds.covid",
+    name: str = "covid",
     *,
     attr: Union[str, Dict[str, str]] = "LOWER",
     ignore_excluded: bool = False,
@@ -37,10 +37,10 @@ def create_component(
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.covid")
+    nlp.add_pipe(eds.covid())
 
     text = "Le patient est admis pour une infection au coronavirus."
 
diff --git a/edsnlp/pipes/ner/disorders/aids/aids.py b/edsnlp/pipes/ner/disorders/aids/aids.py
index e45ecf7f8..01a3bfa32 100644
--- a/edsnlp/pipes/ner/disorders/aids/aids.py
+++ b/edsnlp/pipes/ner/disorders/aids/aids.py
@@ -47,13 +47,12 @@ class AIDSMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -99,7 +98,7 @@ class AIDSMatcher(DisorderMatcher):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol],
-        name: str = "eds.aids",
+        name: str = "aids",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "aids",
diff --git a/edsnlp/pipes/ner/disorders/cerebrovascular_accident/cerebrovascular_accident.py b/edsnlp/pipes/ner/disorders/cerebrovascular_accident/cerebrovascular_accident.py
index 12c4a9831..d57bb6abc 100644
--- a/edsnlp/pipes/ner/disorders/cerebrovascular_accident/cerebrovascular_accident.py
+++ b/edsnlp/pipes/ner/disorders/cerebrovascular_accident/cerebrovascular_accident.py
@@ -34,13 +34,12 @@ class CerebrovascularAccidentMatcher(DisorderMatcher):
     Usage
     -----
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -56,7 +55,7 @@ class CerebrovascularAccidentMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.cerebrovascular_accident")
+    nlp.add_pipe(eds.cerebrovascular_accident())
     ```
 
     Below are a few examples:
@@ -86,7 +85,7 @@ class CerebrovascularAccidentMatcher(DisorderMatcher):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol],
-        name: str = "eds.cerebrovascular_accident",
+        name: str = "cerebrovascular_accident",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "cerebrovascular_accident",
diff --git a/edsnlp/pipes/ner/disorders/ckd/ckd.py b/edsnlp/pipes/ner/disorders/ckd/ckd.py
index 59b85ae62..090637a65 100644
--- a/edsnlp/pipes/ner/disorders/ckd/ckd.py
+++ b/edsnlp/pipes/ner/disorders/ckd/ckd.py
@@ -47,13 +47,12 @@ class CKDMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -69,7 +68,7 @@ class CKDMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.CKD")
+    nlp.add_pipe(eds.ckd())
     ```
 
     Below are a few examples:
@@ -99,7 +98,7 @@ class CKDMatcher(DisorderMatcher):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol],
-        name: str = "eds.ckd",
+        name: str = "ckd",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "ckd",
diff --git a/edsnlp/pipes/ner/disorders/congestive_heart_failure/congestive_heart_failure.py b/edsnlp/pipes/ner/disorders/congestive_heart_failure/congestive_heart_failure.py
index bb7b5a455..98002ef16 100644
--- a/edsnlp/pipes/ner/disorders/congestive_heart_failure/congestive_heart_failure.py
+++ b/edsnlp/pipes/ner/disorders/congestive_heart_failure/congestive_heart_failure.py
@@ -28,13 +28,12 @@ class CongestiveHeartFailureMatcher(DisorderMatcher):
     Usage
     -----
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -50,7 +49,7 @@ class CongestiveHeartFailureMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.congestive_heart_failure")
+    nlp.add_pipe(eds.congestive_heart_failure())
     ```
 
     Below are a few examples:
@@ -79,8 +78,8 @@ class CongestiveHeartFailureMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.congestive_heart_failure",
+        nlp: Optional[PipelineProtocol],
+        name: str = "congestive_heart_failure",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "congestive_heart_failure",
diff --git a/edsnlp/pipes/ner/disorders/connective_tissue_disease/connective_tissue_disease.py b/edsnlp/pipes/ner/disorders/connective_tissue_disease/connective_tissue_disease.py
index 676e5ea98..54bbc37ea 100644
--- a/edsnlp/pipes/ner/disorders/connective_tissue_disease/connective_tissue_disease.py
+++ b/edsnlp/pipes/ner/disorders/connective_tissue_disease/connective_tissue_disease.py
@@ -31,13 +31,12 @@ class ConnectiveTissueDiseaseMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -53,7 +52,7 @@ class ConnectiveTissueDiseaseMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.connective_tissue_disease")
+    nlp.add_pipe(eds.connective_tissue_disease())
     ```
 
     Below are a few examples:
@@ -82,8 +81,8 @@ class ConnectiveTissueDiseaseMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.connective_tissue_disease",
+        nlp: Optional[PipelineProtocol],
+        name: str = "connective_tissue_disease",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "connective_tissue_disease",
diff --git a/edsnlp/pipes/ner/disorders/copd/copd.py b/edsnlp/pipes/ner/disorders/copd/copd.py
index 8247869b2..294b9660e 100644
--- a/edsnlp/pipes/ner/disorders/copd/copd.py
+++ b/edsnlp/pipes/ner/disorders/copd/copd.py
@@ -34,13 +34,12 @@ class COPDMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -56,7 +55,7 @@ class COPDMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.copd")
+    nlp.add_pipe(eds.copd())
     ```
 
     Below are a few examples:
@@ -85,8 +84,8 @@ class COPDMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.copd",
+        nlp: Optional[PipelineProtocol],
+        name: str = "copd",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "copd",
diff --git a/edsnlp/pipes/ner/disorders/dementia/dementia.py b/edsnlp/pipes/ner/disorders/dementia/dementia.py
index ba5ffc9c4..87b715807 100644
--- a/edsnlp/pipes/ner/disorders/dementia/dementia.py
+++ b/edsnlp/pipes/ner/disorders/dementia/dementia.py
@@ -29,13 +29,12 @@ class DementiaMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -51,7 +50,7 @@ class DementiaMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.dementia")
+    nlp.add_pipe(eds.dementia())
     ```
 
     Below are a few examples:
@@ -80,8 +79,8 @@ class DementiaMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.dementia",
+        nlp: Optional[PipelineProtocol],
+        name: str = "dementia",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "dementia",
diff --git a/edsnlp/pipes/ner/disorders/diabetes/diabetes.py b/edsnlp/pipes/ner/disorders/diabetes/diabetes.py
index 2cf1b6312..eee6fedee 100644
--- a/edsnlp/pipes/ner/disorders/diabetes/diabetes.py
+++ b/edsnlp/pipes/ner/disorders/diabetes/diabetes.py
@@ -42,13 +42,12 @@ class DiabetesMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -64,7 +63,7 @@ class DiabetesMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.diabetes")
+    nlp.add_pipe(eds.diabetes())
     ```
 
     Below are a few examples:
@@ -93,8 +92,8 @@ class DiabetesMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.diabetes",
+        nlp: Optional[PipelineProtocol],
+        name: str = "diabetes",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "diabetes",
diff --git a/edsnlp/pipes/ner/disorders/hemiplegia/hemiplegia.py b/edsnlp/pipes/ner/disorders/hemiplegia/hemiplegia.py
index e4d00d22b..0a06079c9 100644
--- a/edsnlp/pipes/ner/disorders/hemiplegia/hemiplegia.py
+++ b/edsnlp/pipes/ner/disorders/hemiplegia/hemiplegia.py
@@ -28,13 +28,12 @@ class HemiplegiaMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -50,7 +49,7 @@ class HemiplegiaMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.hemiplegia")
+    nlp.add_pipe(eds.hemiplegia())
     ```
 
     Below are a few examples:
@@ -79,8 +78,8 @@ class HemiplegiaMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.hemiplegia",
+        nlp: Optional[PipelineProtocol],
+        name: str = "hemiplegia",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "hemiplegia",
diff --git a/edsnlp/pipes/ner/disorders/leukemia/leukemia.py b/edsnlp/pipes/ner/disorders/leukemia/leukemia.py
index 75b54d435..32ab1cdbb 100644
--- a/edsnlp/pipes/ner/disorders/leukemia/leukemia.py
+++ b/edsnlp/pipes/ner/disorders/leukemia/leukemia.py
@@ -28,13 +28,12 @@ class LeukemiaMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -50,7 +49,7 @@ class LeukemiaMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.leukemia")
+    nlp.add_pipe(eds.leukemia())
     ```
 
     Below are a few examples:
@@ -79,8 +78,8 @@ class LeukemiaMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.leukemia",
+        nlp: Optional[PipelineProtocol],
+        name: str = "leukemia",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "leukemia",
diff --git a/edsnlp/pipes/ner/disorders/liver_disease/liver_disease.py b/edsnlp/pipes/ner/disorders/liver_disease/liver_disease.py
index 011add2f8..8b208bed2 100644
--- a/edsnlp/pipes/ner/disorders/liver_disease/liver_disease.py
+++ b/edsnlp/pipes/ner/disorders/liver_disease/liver_disease.py
@@ -32,13 +32,12 @@ class LiverDiseaseMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -54,7 +53,7 @@ class LiverDiseaseMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.liver_disease")
+    nlp.add_pipe(eds.liver_disease())
     ```
 
     Below are a few examples:
@@ -83,8 +82,8 @@ class LiverDiseaseMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.liver_disease",
+        nlp: Optional[PipelineProtocol],
+        name: str = "liver_disease",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "liver_disease",
diff --git a/edsnlp/pipes/ner/disorders/lymphoma/lymphoma.py b/edsnlp/pipes/ner/disorders/lymphoma/lymphoma.py
index e450ad861..bbde56327 100644
--- a/edsnlp/pipes/ner/disorders/lymphoma/lymphoma.py
+++ b/edsnlp/pipes/ner/disorders/lymphoma/lymphoma.py
@@ -32,13 +32,12 @@ class LymphomaMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -54,7 +53,7 @@ class LymphomaMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.lymphoma")
+    nlp.add_pipe(eds.lymphoma())
     ```
 
     Below are a few examples:
@@ -83,8 +82,8 @@ class LymphomaMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.lymphoma",
+        nlp: Optional[PipelineProtocol],
+        name: str = "lymphoma",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "lymphoma",
diff --git a/edsnlp/pipes/ner/disorders/myocardial_infarction/myocardial_infarction.py b/edsnlp/pipes/ner/disorders/myocardial_infarction/myocardial_infarction.py
index ee10483ce..0f8d167fd 100644
--- a/edsnlp/pipes/ner/disorders/myocardial_infarction/myocardial_infarction.py
+++ b/edsnlp/pipes/ner/disorders/myocardial_infarction/myocardial_infarction.py
@@ -36,13 +36,12 @@ class MyocardialInfarctionMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -58,7 +57,7 @@ class MyocardialInfarctionMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.myocardial_infarction")
+    nlp.add_pipe(eds.myocardial_infarction())
     ```
 
     Below are a few examples:
@@ -87,8 +86,8 @@ class MyocardialInfarctionMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.myocardial_infarction",
+        nlp: Optional[PipelineProtocol],
+        name: str = "myocardial_infarction",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "myocardial_infarction",
diff --git a/edsnlp/pipes/ner/disorders/peptic_ulcer_disease/peptic_ulcer_disease.py b/edsnlp/pipes/ner/disorders/peptic_ulcer_disease/peptic_ulcer_disease.py
index cad307f27..5342bcfca 100644
--- a/edsnlp/pipes/ner/disorders/peptic_ulcer_disease/peptic_ulcer_disease.py
+++ b/edsnlp/pipes/ner/disorders/peptic_ulcer_disease/peptic_ulcer_disease.py
@@ -32,13 +32,12 @@ class PepticUlcerDiseaseMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -54,7 +53,7 @@ class PepticUlcerDiseaseMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.peptic_ulcer_disease")
+    nlp.add_pipe(eds.peptic_ulcer_disease())
     ```
 
     Below are a few examples:
@@ -83,8 +82,8 @@ class PepticUlcerDiseaseMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.peptic_ulcer_disease",
+        nlp: Optional[PipelineProtocol],
+        name: str = "peptic_ulcer_disease",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "peptic_ulcer_disease",
diff --git a/edsnlp/pipes/ner/disorders/peripheral_vascular_disease/peripheral_vascular_disease.py b/edsnlp/pipes/ner/disorders/peripheral_vascular_disease/peripheral_vascular_disease.py
index 02d11e4b7..5258d18d9 100644
--- a/edsnlp/pipes/ner/disorders/peripheral_vascular_disease/peripheral_vascular_disease.py
+++ b/edsnlp/pipes/ner/disorders/peripheral_vascular_disease/peripheral_vascular_disease.py
@@ -31,13 +31,12 @@ class PeripheralVascularDiseaseMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -53,7 +52,7 @@ class PeripheralVascularDiseaseMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.peripheral_vascular_disease")
+    nlp.add_pipe(eds.peripheral_vascular_disease())
     ```
 
     Below are a few examples:
@@ -83,8 +82,8 @@ class PeripheralVascularDiseaseMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.peripheral_vascular_disease",
+        nlp: Optional[PipelineProtocol],
+        name: str = "peripheral_vascular_disease",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         label: str = "peripheral_vascular_disease",
diff --git a/edsnlp/pipes/ner/disorders/solid_tumor/solid_tumor.py b/edsnlp/pipes/ner/disorders/solid_tumor/solid_tumor.py
index ec95cc092..74b12169c 100644
--- a/edsnlp/pipes/ner/disorders/solid_tumor/solid_tumor.py
+++ b/edsnlp/pipes/ner/disorders/solid_tumor/solid_tumor.py
@@ -36,13 +36,12 @@ class SolidTumorMatcher(DisorderMatcher):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     nlp.add_pipe(
-        "eds.normalizer",
-        config=dict(
+        eds.normalizer(
             accents=True,
             lowercase=True,
             quotes=True,
@@ -58,7 +57,7 @@ class SolidTumorMatcher(DisorderMatcher):
             ),
         ),
     )
-    nlp.add_pipe(f"eds.solid_tumor")
+    nlp.add_pipe(eds.solid_tumor())
     ```
 
     Below are a few examples:
@@ -89,8 +88,8 @@ class SolidTumorMatcher(DisorderMatcher):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol] = None,
-        name: str = "eds.solid_tumor",
+        nlp: Optional[PipelineProtocol],
+        name: str = "solid_tumor",
         *,
         patterns: Union[Dict[str, Any], List[Dict[str, Any]]] = default_patterns,
         use_tnm: bool = False,
diff --git a/edsnlp/pipes/ner/drugs/factory.py b/edsnlp/pipes/ner/drugs/factory.py
index 3109d1d6a..f346a16b1 100644
--- a/edsnlp/pipes/ner/drugs/factory.py
+++ b/edsnlp/pipes/ner/drugs/factory.py
@@ -25,7 +25,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: str = "eds.drugs",
+    name: str = "drugs",
     *,
     attr: str = "NORM",
     ignore_excluded: bool = False,
@@ -47,11 +47,11 @@ def create_component(
     (ATC code: A10B).
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.normalizer")
-    nlp.add_pipe("eds.drugs", config=dict(term_matcher="exact"))
+    nlp.add_pipe(eds.normalizer())
+    nlp.add_pipe(eds.drugs(term_matcher="exact"))
 
     text = "Traitement habituel: Kardégic, cardensiel (bisoprolol), glucophage, lasilix"
 
diff --git a/edsnlp/pipes/ner/scores/charlson/factory.py b/edsnlp/pipes/ner/scores/charlson/factory.py
index 76d3d5bb8..0faa1b373 100644
--- a/edsnlp/pipes/ner/scores/charlson/factory.py
+++ b/edsnlp/pipes/ner/scores/charlson/factory.py
@@ -30,7 +30,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: Optional[str] = None,
+    name: Optional[str] = "charlson",
     *,
     regex: List[str] = patterns.regex,
     value_extract: str = patterns.value_extract,
@@ -52,12 +52,12 @@ def create_component(
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
-    nlp.add_pipe("eds.normalizer")
-    nlp.add_pipe("eds.charlson")
+    nlp.add_pipe(eds.sentences())
+    nlp.add_pipe(eds.normalizer())
+    nlp.add_pipe(eds.charlson())
 
     text = """
     Charlson à l'admission: 7.
diff --git a/edsnlp/pipes/ner/scores/elston_ellis/factory.py b/edsnlp/pipes/ner/scores/elston_ellis/factory.py
index 1490775ce..2c0e44367 100644
--- a/edsnlp/pipes/ner/scores/elston_ellis/factory.py
+++ b/edsnlp/pipes/ner/scores/elston_ellis/factory.py
@@ -31,7 +31,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: Optional[str] = None,
+    name: Optional[str] = "elston_ellis",
     *,
     regex: List[str] = regex,
     value_extract: str = value_extract,
@@ -52,10 +52,10 @@ def create_component(
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.elston_ellis")
+    nlp.add_pipe(eds.elston_ellis())
     ```
 
     Parameters
diff --git a/edsnlp/pipes/ner/scores/emergency/ccmu/factory.py b/edsnlp/pipes/ner/scores/emergency/ccmu/factory.py
index f4e6e3967..b66a3d0f8 100644
--- a/edsnlp/pipes/ner/scores/emergency/ccmu/factory.py
+++ b/edsnlp/pipes/ner/scores/emergency/ccmu/factory.py
@@ -30,7 +30,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: Optional[str] = None,
+    name: Optional[str] = "emergency_ccmu",
     *,
     regex: List[str] = patterns.regex,
     value_extract: str = patterns.value_extract,
@@ -53,10 +53,10 @@ def create_component(
     --------
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.emergency_ccmu")
+    nlp.add_pipe(eds.emergency_ccmu())
     ```
 
     Parameters
diff --git a/edsnlp/pipes/ner/scores/emergency/gemsa/factory.py b/edsnlp/pipes/ner/scores/emergency/gemsa/factory.py
index 9c936f249..3b65712dd 100644
--- a/edsnlp/pipes/ner/scores/emergency/gemsa/factory.py
+++ b/edsnlp/pipes/ner/scores/emergency/gemsa/factory.py
@@ -30,7 +30,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: Optional[str] = None,
+    name: Optional[str] = "emergency_gemsa",
     *,
     regex: List[str] = patterns.regex,
     value_extract: str = patterns.value_extract,
@@ -53,10 +53,10 @@ def create_component(
     --------
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.emergency_gemsa")
+    nlp.add_pipe(eds.emergency_gemsa())
     ```
 
     Parameters
diff --git a/edsnlp/pipes/ner/scores/emergency/priority/factory.py b/edsnlp/pipes/ner/scores/emergency/priority/factory.py
index 15d468251..e5598b35e 100644
--- a/edsnlp/pipes/ner/scores/emergency/priority/factory.py
+++ b/edsnlp/pipes/ner/scores/emergency/priority/factory.py
@@ -31,7 +31,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: Optional[str] = None,
+    name: Optional[str] = "emergency_priority",
     *,
     regex: List[str] = regex,
     value_extract: str = value_extract,
@@ -53,10 +53,10 @@ def create_component(
     --------
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.emergency_priority")
+    nlp.add_pipe(eds.emergency_priority())
     ```
 
     Parameters
diff --git a/edsnlp/pipes/ner/scores/sofa/factory.py b/edsnlp/pipes/ner/scores/sofa/factory.py
index ef6c6b916..0e8db8f04 100644
--- a/edsnlp/pipes/ner/scores/sofa/factory.py
+++ b/edsnlp/pipes/ner/scores/sofa/factory.py
@@ -31,7 +31,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: Optional[str] = None,
+    name: Optional[str] = "sofa",
     *,
     regex: List[str] = regex,
     value_extract: List[Dict[str, str]] = value_extract,
@@ -57,12 +57,12 @@ def create_component(
     --------
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
-    nlp.add_pipe("eds.normalizer")
-    nlp.add_pipe("eds.sofa")
+    nlp.add_pipe(eds.sentences())
+    nlp.add_pipe(eds.normalizer())
+    nlp.add_pipe(eds.sofa())
 
     text = """
     SOFA (à 24H) : 12.
diff --git a/edsnlp/pipes/ner/tnm/tnm.py b/edsnlp/pipes/ner/tnm/tnm.py
index 40fc8ebf6..fdcdc2f91 100644
--- a/edsnlp/pipes/ner/tnm/tnm.py
+++ b/edsnlp/pipes/ner/tnm/tnm.py
@@ -21,11 +21,11 @@ class TNMMatcher(BaseNERComponent):
     Examples
     --------
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
-    nlp.add_pipe("eds.tnm")
+    nlp.add_pipe(eds.sentences())
+    nlp.add_pipe(eds.tnm())
 
     text = "TNM: pTx N1 M1"
 
@@ -71,7 +71,7 @@ class TNMMatcher(BaseNERComponent):
     def __init__(
         self,
         nlp: Optional[PipelineProtocol],
-        name: str = "eds.tnm",
+        name: str = "tnm",
         *,
         pattern: Optional[Union[List[str], str]] = tnm_pattern,
         attr: str = "TEXT",
diff --git a/edsnlp/pipes/ner/umls/factory.py b/edsnlp/pipes/ner/umls/factory.py
index 5341ebdd9..fbf96d165 100644
--- a/edsnlp/pipes/ner/umls/factory.py
+++ b/edsnlp/pipes/ner/umls/factory.py
@@ -26,7 +26,7 @@
 )
 def create_component(
     nlp: PipelineProtocol,
-    name: str = "eds.umls",
+    name: str = "umls",
     *,
     attr: Union[str, Dict[str, str]] = "NORM",
     ignore_excluded: bool = False,
@@ -58,10 +58,10 @@ def create_component(
     3. Set `UMLS_API_KEY` locally: `export UMLS_API_KEY=your_api_key`
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.umls")
+    nlp.add_pipe(eds.umls())
 
     text = "Grosse toux: le malade a été mordu par des Amphibiens " "sous le genou"
 
@@ -83,13 +83,13 @@ def create_component(
     argument:
 
     ```python
-    import spacy
+    import edsnlp, edsnlp.pipes as eds
 
     # Enable the French and English languages, through the French MeSH and LOINC
     pattern_config = dict(languages=["FRE", "ENG"], sources=["MSHFRE", "LNC"])
 
-    nlp = spacy.blank("eds")
-    nlp.add_pipe("eds.umls", config=dict(pattern_config=pattern_config))
+    nlp = edsnlp.blank("eds")
+    nlp.add_pipe(eds.umls(pattern_config=pattern_config))
     ```
 
     See more options of languages and sources
diff --git a/edsnlp/pipes/qualifiers/family/family.py b/edsnlp/pipes/qualifiers/family/family.py
index 206c28c56..f65b71b15 100644
--- a/edsnlp/pipes/qualifiers/family/family.py
+++ b/edsnlp/pipes/qualifiers/family/family.py
@@ -34,16 +34,15 @@ class FamilyContextQualifier(RuleBasedQualifier):
     of the extracted entities. It is complete, and can be run _as is_.
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     # Dummy matcher
     nlp.add_pipe(
-        "eds.matcher",
-        config=dict(terms=dict(douleur="douleur", osteoporose="ostéoporose")),
+        eds.matcher(terms=dict(douleur="douleur", osteoporose="ostéoporose")),
     )
-    nlp.add_pipe("eds.family")
+    nlp.add_pipe(eds.family())
 
     text = (
         "Le patient est admis le 23 août 2021 pour une douleur au bras. "
@@ -111,7 +110,7 @@ class FamilyContextQualifier(RuleBasedQualifier):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = "eds.family",
+        name: Optional[str] = "family",
         *,
         attr: str = "NORM",
         family: Optional[List[str]] = None,
diff --git a/edsnlp/pipes/qualifiers/history/history.py b/edsnlp/pipes/qualifiers/history/history.py
index c06c0dd15..8d66f48d1 100644
--- a/edsnlp/pipes/qualifiers/history/history.py
+++ b/edsnlp/pipes/qualifiers/history/history.py
@@ -68,20 +68,16 @@ class HistoryQualifier(RuleBasedQualifier):
     entities are history or not. It is complete and can be run _as is_.
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
-    nlp.add_pipe("eds.normalizer")
-    nlp.add_pipe("eds.sections")
-    nlp.add_pipe("eds.dates")
+    nlp.add_pipe(eds.sentences())
+    nlp.add_pipe(eds.normalizer())
+    nlp.add_pipe(eds.sections())
+    nlp.add_pipe(eds.dates())
+    nlp.add_pipe(eds.matcher(terms=dict(douleur="douleur", malaise="malaises")))
     nlp.add_pipe(
-        "eds.matcher",
-        config=dict(terms=dict(douleur="douleur", malaise="malaises")),
-    )
-    nlp.add_pipe(
-        "eds.history",
-        config=dict(
+        eds.history(
             use_sections=True,
             use_dates=True,
         ),
@@ -177,7 +173,7 @@ class HistoryQualifier(RuleBasedQualifier):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = "eds.history",
+        name: Optional[str] = "history",
         *,
         history: Optional[List[str]] = None,
         termination: Optional[List[str]] = None,
diff --git a/edsnlp/pipes/qualifiers/hypothesis/hypothesis.py b/edsnlp/pipes/qualifiers/hypothesis/hypothesis.py
index bf4f9f9a6..514122d59 100644
--- a/edsnlp/pipes/qualifiers/hypothesis/hypothesis.py
+++ b/edsnlp/pipes/qualifiers/hypothesis/hypothesis.py
@@ -42,16 +42,13 @@ class HypothesisQualifier(RuleBasedQualifier):
     entities are part of a speculation. It is complete and can be run _as is_.
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     # Dummy matcher
-    nlp.add_pipe(
-        "eds.matcher",
-        config=dict(terms=dict(douleur="douleur", fracture="fracture")),
-    )
-    nlp.add_pipe("eds.hypothesis")
+    nlp.add_pipe(eds.matcher(terms=dict(douleur="douleur", fracture="fracture")))
+    nlp.add_pipe(eds.hypothesis())
 
     text = (
         "Le patient est admis le 23 août 2021 pour une douleur au bras. "
@@ -148,7 +145,7 @@ class HypothesisQualifier(RuleBasedQualifier):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = "eds.hypothesis",
+        name: Optional[str] = "hypothesis",
         *,
         pseudo: Optional[List[str]] = None,
         preceding: Optional[List[str]] = None,
diff --git a/edsnlp/pipes/qualifiers/negation/negation.py b/edsnlp/pipes/qualifiers/negation/negation.py
index 99cead3cc..4f13437ec 100644
--- a/edsnlp/pipes/qualifiers/negation/negation.py
+++ b/edsnlp/pipes/qualifiers/negation/negation.py
@@ -45,16 +45,13 @@ class NegationQualifier(RuleBasedQualifier):
     extracted entities. It is complete and can be run _as is_.
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     # Dummy matcher
-    nlp.add_pipe(
-        "eds.matcher",
-        config=dict(terms=dict(patient="patient", fracture="fracture")),
-    )
-    nlp.add_pipe("eds.negation")
+    nlp.add_pipe(eds.matcher(terms=dict(patient="patient", fracture="fracture")))
+    nlp.add_pipe(eds.negation())
 
     text = (
         "Le patient est admis le 23 août 2021 pour une douleur au bras. "
@@ -150,7 +147,7 @@ class NegationQualifier(RuleBasedQualifier):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = "eds.negation",
+        name: Optional[str] = "negation",
         *,
         pseudo: Optional[List[str]] = None,
         preceding: Optional[List[str]] = None,
diff --git a/edsnlp/pipes/qualifiers/reported_speech/reported_speech.py b/edsnlp/pipes/qualifiers/reported_speech/reported_speech.py
index d1745a044..6f216c47c 100644
--- a/edsnlp/pipes/qualifiers/reported_speech/reported_speech.py
+++ b/edsnlp/pipes/qualifiers/reported_speech/reported_speech.py
@@ -34,16 +34,13 @@ class ReportedSpeechQualifier(RuleBasedQualifier):
     entities are part of a reported speech. It is complete and can be run _as is_.
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
-    nlp.add_pipe("eds.sentences")
+    nlp.add_pipe(eds.sentences())
     # Dummy matcher
-    nlp.add_pipe(
-        "eds.matcher",
-        config=dict(terms=dict(patient="patient", alcool="alcoolisé")),
-    )
-    nlp.add_pipe("eds.reported_speech")
+    nlp.add_pipe(eds.matcher(terms=dict(patient="patient", alcool="alcoolisé")))
+    nlp.add_pipe(eds.reported_speech())
 
     text = (
         "Le patient est admis aux urgences ce soir pour une douleur au bras. "
@@ -114,7 +111,7 @@ class ReportedSpeechQualifier(RuleBasedQualifier):
     def __init__(
         self,
         nlp: PipelineProtocol,
-        name: Optional[str] = "eds.reported_speech",
+        name: Optional[str] = "reported_speech",
         *,
         pseudo: Optional[List[str]] = None,
         preceding: Optional[List[str]] = None,
diff --git a/edsnlp/pipes/trainable/embeddings/span_pooler/span_pooler.py b/edsnlp/pipes/trainable/embeddings/span_pooler/span_pooler.py
index 09379e5b0..184f8c200 100644
--- a/edsnlp/pipes/trainable/embeddings/span_pooler/span_pooler.py
+++ b/edsnlp/pipes/trainable/embeddings/span_pooler/span_pooler.py
@@ -11,7 +11,7 @@
 from spacy.tokens import Doc, Span
 from typing_extensions import Literal, TypedDict
 
-from edsnlp.core import PipelineProtocol
+from edsnlp.core.pipeline import Pipeline
 from edsnlp.core.torch_component import BatchInput, BatchOutput
 from edsnlp.pipes.base import BaseComponent
 from edsnlp.pipes.trainable.embeddings.typing import (
@@ -65,8 +65,8 @@ class SpanPooler(SpanEmbeddingComponent, BaseComponent):
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol],
-        name: str,
+        nlp: Optional[Pipeline] = None,
+        name: str = "span_pooler",
         *,
         embedding: WordEmbeddingComponent,
         span_getter: SpanGetterArg,
diff --git a/edsnlp/pipes/trainable/embeddings/text_cnn/text_cnn.py b/edsnlp/pipes/trainable/embeddings/text_cnn/text_cnn.py
index 476d61b05..f2baaba7d 100644
--- a/edsnlp/pipes/trainable/embeddings/text_cnn/text_cnn.py
+++ b/edsnlp/pipes/trainable/embeddings/text_cnn/text_cnn.py
@@ -3,6 +3,7 @@
 import torch
 from typing_extensions import Literal, TypedDict
 
+from edsnlp.core.pipeline import Pipeline
 from edsnlp.core.torch_component import BatchInput
 from edsnlp.pipes.trainable.embeddings.typing import (
     WordEmbeddingBatchOutput,
@@ -50,8 +51,9 @@ class TextCnnEncoder(WordEmbeddingComponent):
 
     def __init__(
         self,
-        nlp,
-        name: str,
+        nlp: Optional[Pipeline] = None,
+        name: str = "text_cnn",
+        *,
         embedding: WordEmbeddingComponent,
         output_size: Optional[int] = None,
         out_channels: Optional[int] = None,
diff --git a/edsnlp/pipes/trainable/embeddings/transformer/transformer.py b/edsnlp/pipes/trainable/embeddings/transformer/transformer.py
index 6ce386f00..87523ba95 100644
--- a/edsnlp/pipes/trainable/embeddings/transformer/transformer.py
+++ b/edsnlp/pipes/trainable/embeddings/transformer/transformer.py
@@ -74,13 +74,11 @@ class Transformer(WordEmbeddingComponent[TransformerBatchInput]):
     Here is an example of how to define a pipeline with a Transformer component:
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
     nlp.add_pipe(
-        "eds.transformer",
-        name="transformer",
-        config=dict(
+        eds.transformer(
             model="prajjwal1/bert-tiny",
             window=128,
             stride=96,
@@ -120,7 +118,7 @@ class Transformer(WordEmbeddingComponent[TransformerBatchInput]):
     def __init__(
         self,
         nlp: Optional[Pipeline] = None,
-        name: str = "eds.transformer",
+        name: str = "transformer",
         *,
         model: Union[str, Path],
         window: int = 128,
@@ -351,7 +349,7 @@ def forward(self, batch):
                 free_mem = total_mem - allocated_mem
 
                 if self._mem_per_unit is not None:
-                    max_tokens = int(free_mem // self._mem_per_unit)
+                    max_tokens = max(1, int(free_mem // self._mem_per_unit))
             else:
                 max_tokens = (
                     self.max_tokens_per_device
@@ -393,7 +391,7 @@ def forward(self, batch):
                         f"Out of memory: tried to fit {max_windows} "
                         f"in {free_mem / (1024 ** 3)} (try n°{trial_idx}/2)"
                     )
-                    torch.cuda.empty_cache(device)
+                    torch.cuda.empty_cache()
                     self._mem_per_unit = (free_mem / max_windows) * 1.5
                     trial_idx += 1
                     continue
diff --git a/edsnlp/pipes/trainable/layers/metric.py b/edsnlp/pipes/trainable/layers/metric.py
new file mode 100644
index 000000000..d4b24850d
--- /dev/null
+++ b/edsnlp/pipes/trainable/layers/metric.py
@@ -0,0 +1,102 @@
+from typing import Optional
+
+import torch
+import torch.nn.functional as F
+from typing_extensions import Literal
+
+
+class Metric(torch.nn.Module):
+    """
+    Metric layer, used for computing similarities between two sets of vectors. A typical
+    use case is to compute the similarity between a set of query vectors (input
+    embeddings) and a set of concept vectors (output embeddings).
+
+    Parameters
+    ----------
+    in_features : int
+        Size of the input embeddings
+    out_features : int
+        Size of the output embeddings
+    num_groups : int
+        Number of groups for the output embeddings, that can be used to filter out
+        certain concepts that are not relevant for a given query (e.g. do not compare
+        a drug with concepts for diseases)
+    metric : Literal["cosine", "dot"]
+        Whether to compute the cosine similarity between the input and output embeddings
+        or the dot product.
+    rescale: Optional[float]
+        Rescale the output cosine similarities by a constant factor.
+    """
+
+    def __init__(
+        self,
+        in_features: int,
+        out_features: int,
+        num_groups: int = 0,
+        metric: Literal["cosine", "dot"] = "cosine",
+        rescale: Optional[float] = None,
+        bias: bool = True,
+    ):
+        super().__init__()
+        self.in_features = in_features
+        self.out_features = out_features
+        self.weight = torch.nn.Parameter(torch.zeros(out_features, in_features))
+        self.register_buffer(
+            "groups", torch.zeros(num_groups, out_features, dtype=torch.bool)
+        )
+        self.rescale: float = (
+            rescale if rescale is not None else 20.0 if metric == "cosine" else 1.0
+        )
+        self.metric = metric
+        self.register_parameter(
+            "bias",
+            torch.nn.Parameter(torch.tensor(-0.65 if metric == "cosine" else 0.0))
+            if bias
+            else None,
+        )
+        self.reset_parameters()
+
+        self._last_version = None
+        self._normalized_weight = None
+
+    def reset_parameters(self):
+        torch.nn.init.xavier_uniform_(self.weight)
+
+    def normalized_weight(self):
+        if (
+            (self.weight._version, id(self.weight)) == self._last_version
+            and not self.training
+            and self._normalized_weight is not None
+        ):
+            return self._normalized_weight
+        normalized_weight = self.normalize_embedding(self.weight)
+        if not self.training and normalized_weight is not self.weight:
+            self._normalized_weight = normalized_weight
+            self._last_version = (self.weight._version, id(self.weight))
+        return normalized_weight
+
+    def normalize_embedding(self, inputs):
+        if self.metric == "cosine":
+            inputs = F.normalize(inputs, dim=-1)
+        return inputs
+
+    def forward(self, inputs, group_indices=None, **kwargs):
+        x = F.linear(
+            self.normalize_embedding(inputs),
+            self.normalized_weight(),
+        )
+        if self.bias is not None:
+            x += self.bias
+        if self.rescale != 1.0:
+            x *= self.rescale
+        if group_indices is not None and len(self.groups):
+            x = x.masked_fill(~self.groups[group_indices], -10000)
+        return x
+
+    def extra_repr(self):
+        return "in_features={}, out_features={}, rescale={}, groups={}".format(
+            self.in_features,
+            self.out_features,
+            float(self.rescale or 1.0),
+            self.groups.shape[0] if self.groups is not None else None,
+        )
diff --git a/edsnlp/pipes/trainable/ner_crf/ner_crf.py b/edsnlp/pipes/trainable/ner_crf/ner_crf.py
index 3040c3f38..d17a24f24 100644
--- a/edsnlp/pipes/trainable/ner_crf/ner_crf.py
+++ b/edsnlp/pipes/trainable/ner_crf/ner_crf.py
@@ -9,7 +9,7 @@
 from spacy.tokens import Doc, Span
 from typing_extensions import Literal, NotRequired, TypedDict
 
-from edsnlp import Pipeline
+from edsnlp.core.pipeline import Pipeline
 from edsnlp.core.torch_component import TorchComponent
 from edsnlp.pipes.base import BaseNERComponent
 from edsnlp.pipes.trainable.embeddings.typing import (
@@ -95,31 +95,26 @@ class TrainableNerCrf(TorchComponent[NERBatchOutput, NERBatchInput], BaseNERComp
     ```{ .python }
     from pathlib import Path
 
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
     nlp.add_pipe(
-        "eds.transformer",
-        name="transformer",
-        config=dict(
-            model="prajjwal1/bert-tiny",
-            window=128,
-            stride=96,
-        ),
-    )
-    nlp.add_pipe(
-        "eds.ner_crf",
-        name="ner",
-        config=dict(
-            embedding=nlp.get_pipe("transformer"),
+        eds.ner_crf(
+            embedding=eds.transformer(
+                model="prajjwal1/bert-tiny",
+                window=128,
+                stride=96,
+            ),
             mode="joint",
             target_span_getter=["ents", "ner-preds"],
             window=10,
-        )
+        ),
+        name="ner"
     )
     ```
 
-    To train the model, refer to the [Training](/tutorials/training.md) tutorial.
+    To train the model, refer to the [Training](/tutorials/make-a-training-script)
+    tutorial.
 
     Parameters
     ----------
@@ -165,7 +160,7 @@ class TrainableNerCrf(TorchComponent[NERBatchOutput, NERBatchInput], BaseNERComp
     def __init__(
         self,
         nlp: Optional[Pipeline] = None,
-        name: Optional[str] = "eds.ner_crf",
+        name: Optional[str] = "ner_crf",
         *,
         embedding: WordEmbeddingComponent,
         target_span_getter: SpanGetterArg = {"ents": True},
diff --git a/edsnlp/pipes/trainable/span_linker/__init__.py b/edsnlp/pipes/trainable/span_linker/__init__.py
new file mode 100644
index 000000000..549d2fc77
--- /dev/null
+++ b/edsnlp/pipes/trainable/span_linker/__init__.py
@@ -0,0 +1,1 @@
+from .factory import create_component
diff --git a/edsnlp/pipes/trainable/span_linker/factory.py b/edsnlp/pipes/trainable/span_linker/factory.py
new file mode 100644
index 000000000..e67afc9b6
--- /dev/null
+++ b/edsnlp/pipes/trainable/span_linker/factory.py
@@ -0,0 +1,14 @@
+from typing import TYPE_CHECKING
+
+from edsnlp import registry
+
+from .span_linker import TrainableSpanLinker
+
+create_component = registry.factory.register(
+    "eds.span_linker",
+    assigns=[],
+    deprecated=[],
+)(TrainableSpanLinker)
+
+if TYPE_CHECKING:
+    create_component = TrainableSpanLinker
diff --git a/edsnlp/pipes/trainable/span_linker/span_linker.py b/edsnlp/pipes/trainable/span_linker/span_linker.py
new file mode 100644
index 000000000..514e43910
--- /dev/null
+++ b/edsnlp/pipes/trainable/span_linker/span_linker.py
@@ -0,0 +1,603 @@
+from __future__ import annotations
+
+import json
+import os
+import warnings
+from collections import defaultdict
+from typing import (
+    Any,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    Sequence,
+    Set,
+    Tuple,
+    Union,
+)
+
+import foldedtensor as ft
+import torch
+import torch.nn.functional as F
+from spacy.tokens import Doc, Span
+from typing_extensions import Literal, NotRequired, TypedDict
+
+import edsnlp.data
+from edsnlp.core import PipelineProtocol
+from edsnlp.core.torch_component import BatchInput, BatchOutput, TorchComponent
+from edsnlp.pipes.base import BaseComponent
+from edsnlp.pipes.trainable.embeddings.typing import (
+    SpanEmbeddingComponent,
+)
+from edsnlp.pipes.trainable.layers.metric import Metric
+from edsnlp.utils.collections import (
+    batch_compress_dict,
+    decompress_dict,
+    ld_to_dl,
+)
+from edsnlp.utils.span_getters import get_spans_with_group
+
+SpanLinkerBatchInput = TypedDict(
+    "SpanLinkerBatchInput",
+    {
+        "embedding": BatchInput,
+        "span_labels": torch.Tensor,
+        "concepts": NotRequired[torch.Tensor],
+    },
+)
+"""
+embeds: torch.FloatTensor
+    Token embeddings to predict the tags from
+mask: torch.BoolTensor
+    Mask of the sequences
+spans: torch.LongTensor
+    2d tensor of n_spans * (doc_idx, ner_label_idx, begin, end)
+targets: NotRequired[List[torch.LongTensor]]
+    list of 2d tensor of n_spans * n_combinations (1 hot)
+"""
+
+
+class TrainableSpanLinker(
+    TorchComponent[BatchOutput, SpanLinkerBatchInput], BaseComponent
+):
+    """
+    The `eds.span_linker` component is a trainable span concept predictor, typically
+    used to match spans in the text with concepts in a knowledge base. This task is
+    known as "Entity Linking", "Named Entity Disambiguation" or "Normalization" (the
+    latter is mostly used in the biomedical machine learning community).
+
+    !!! warning "Entity Linking vs Named Entity Recognition"
+
+        Entity Linking is the task of linking existing entities to their concept in a
+        knowledge base, while Named Entity Recognition is the task of detecting spans in
+        the text that correspond to entities. The `eds.span_linker` component should
+        therefore be used after the Named Entity Recognition step (e.g. using the
+        `eds.ner_crf` component).
+
+    How it works
+    ------------
+
+    To perform this task, this components compare the embedding of a given query span
+    (e.g. "aspirin") with the embeddings in the knowledge base, where
+    each embedding represents a concept (e.g. "B01AC06"), and selects the most similar
+    embedding and returns its concept id. This comparison is done using either:
+
+    - the cosine similarity between the input and output embeddings (recommended)
+    - a simple dot product
+
+    We filter out the concepts that are not relevant for a given query by using groups.
+    For each span to link, we use its label to select a group of concepts to compare
+    with. For example, if the span is labeled as "drug", we only compare it with
+    concepts that are drugs. These concepts groups are inferred from the training data
+    when running the `post_init` method, or can be provided manually using the
+    `pipe.update_concepts(concepts, mapping, [embeddings])` method. If a label is not
+    found in the mapping, the span is compared with all concepts.
+
+    We support comparing entity queries against two kind of references : either the
+    embeddings of the concepts themselves (`reference_mode = "synonym"`), or the
+    embeddings of the synonyms of the concepts (`reference_mode = "class"`).
+
+    ### Synonym span linking
+
+    When performing span linking in `synonym` mode, the span linker embedding matrix
+    contains one embedding vector per concept per synonym, and each embedding maps to
+    the concept of its synonym. This mode is slower and more memory intensive, since you
+    have to store multiple embeddings per concept, but it can yield good results
+    in zero-shot scenarios (see example below).
+
+    <figure markdown>
+      ![Synonym-based Span Linker](/assets/images/synonym_span_linker.png)
+      <figcaption>Synonym-based span linking</figcaption>
+    </figure>
+
+    ### Class span linking
+
+    In `class` mode, the span linker embedding matrix contains one embedding vector per
+    concept : imagine a single vector that approximately averages all the synonyms
+    of a concept (e.g. B01AC06 = average of "aspirin", "acetyl-salicylic acid", etc.).
+    This mode is faster and more memory efficient, but usually requires that the
+    embeddings are pre-trained.
+
+    <figure markdown>
+      ![Class-based Span Linking](/assets/images/class_span_linker.png)
+      <figcaption>Class-based span linking</figcaption>
+    </figure>
+
+    Examples
+    --------
+
+    Here is how you can use the `eds.span_linker` component to link spans without
+    training, in `synonym` mode. You will still need to pre-compute the embeddings of
+    the target synonyms.
+
+
+    First, initialize the component:
+
+    ```{ .python .no-check }
+    import pandas as pd
+    import edsnlp, edsnlp.pipes as eds
+
+    nlp = edsnlp.blank("eds")
+    nlp.add_pipe(
+        eds.span_linker(
+            rescale=20.0,
+            threshold=0.8,
+            hidden_size=128,
+            metric="cosine",
+            reference_mode="synonym",
+            probability_mode="sigmoid",
+            embedding=eds.span_pooler(
+                span_getter=["ents"],
+                embedding=eds.transformer(
+                    span_getter=["ents"],
+                    model="prajjwal1/bert-tiny",
+                    window=128,
+                    stride=96,
+                ),
+            ),
+        ),
+        name="linker",
+    )
+    ```
+
+    We will assume you have a list of synonyms with their concept and label with the
+    columns:
+
+    - `STR`: synonym text
+    - `CUI`: concept id
+    - `GRP`: label.
+
+    All we need to do is to initialize the component with the synonyms and that's it !
+    Since we have set `init_weights` to True, and we are in `synonym` mode, the
+    embeddings of the synonyms will be stored in the component and used to compute the
+    similarity scores
+
+    ```{ .python .no-check }
+    synonyms_df = pd.read_csv("synonyms.csv")
+
+    def make_doc(row):
+        doc = nlp.make_doc(row["STR"])
+        span = doc[:]
+        span.label_ = row["GRP"]
+        doc.ents = [span]
+        span._.cui = row["CUI"]
+        return doc
+
+    nlp.post_init(
+        edsnlp.data.from_pandas(
+            synonyms_df,
+            converter=make_doc,
+        )
+    )
+    ```
+
+    Now, you can now use it in a text:
+    ```{ .python .no-check }
+    doc = nlp.make_doc("Aspirin is a drug")
+    span = doc[0:1]  # "Aspirin"
+    span.label_ = "Drug"
+    doc.ents = [span]
+
+    doc = nlp(doc)
+    print(doc.ents[0]._.cui)  # "B01AC06"
+    ```
+
+    To use the `eds.span_linker` component in `class` mode, we refer to the following
+    repository: [deep_multilingual_normalization](
+    https://github.com/percevalw/deep_multilingual_normalization) based on the work
+    of [@wajsburt2021medical].
+
+    Parameters
+    ----------
+    nlp: PipelineProtocol
+        Spacy vocabulary
+    name: str
+        Name of the component
+    embedding : SpanEmbeddingComponent
+        The word embedding component
+    metric : Literal["cosine", "dot"] = "cosine"
+        Whether to compute the cosine similarity between the input and output embeddings
+        or the dot product.
+    rescale : float
+        Rescale the output cosine similarities by a constant factor.
+    threshold : float
+        Threshold probability to consider a concept as valid
+    attribute : str
+        The attribute to store the concept id
+    reference_mode : Literal["class", "synonym"]
+        Whether to compare the embeddings with the concepts embeddings (one per concept)
+        or the synonyms embeddings (one per concept per synonym). See above for more
+        details.
+    probability_mode : Literal["softmax", "sigmoid"]
+        Whether to compute the probabilities using a softmax or a sigmoid function.
+        This will also determine the loss function to use, either cross-entropy or
+        binary cross-entropy.
+
+        !!! warning "Subsetting the concepts"
+
+            The probabilities returned in `softmax` mode depend on the number of
+            concepts (as an extreme cas, if you have only one concept, its softmax
+            probability will always be 1). This is why we recommend using the `sigmoid`
+            mode in which the probabilities are computed independently for each concept.
+    init_weights : bool
+        Whether to initialize the weights of the component with the embeddings of
+        the entities of the docs provided to the `post_init` method.
+        How this is done depends on the `reference_mode` parameter:
+
+        - `class`: the embeddings are averaged
+        - `synonym`: the embeddings are stored as is
+
+        By default, this is set to `True` if `reference_mode` is `synonym`, and
+        `False` otherwise.
+    hidden_size : Optional[int]
+        The size of the hidden layer. If None, no projection is done and the output
+        of the span pooler is used directly.
+
+    Authors and citation
+    --------------------
+    The `eds.span_linker` component was developed by AP-HP's Data Science team.
+
+    The deep learning class-based architecture was adapted from [@wajsburt2021medical].
+    """
+
+    def __init__(
+        self,
+        nlp: Optional[PipelineProtocol] = None,
+        name: str = "span_linker",
+        *,
+        embedding: SpanEmbeddingComponent,
+        hidden_size: Optional[int],
+        metric: Literal["cosine", "dot"] = "cosine",
+        rescale: Union[float, Literal["auto"]] = 20,
+        threshold: float = 0.5,
+        attribute: str = "cui",
+        reference_mode: Literal["class", "synonym"] = "class",
+        probability_mode: Literal["softmax", "sigmoid"] = "sigmoid",
+        init_weights: bool = True,
+    ):
+        self.attribute = attribute
+
+        super().__init__(nlp, name)
+
+        self.embedding = embedding
+        self.concepts: List = []
+        self.qualifiers = ["_." + self.attribute]
+        self.concepts_to_idx: Dict[str, int] = {}
+        self.span_labels_to_idx: Dict[str, int] = {}
+        self.threshold = threshold
+        self.reference_mode = reference_mode
+        self.probability_mode = probability_mode
+        self.init_weights = (
+            (reference_mode == "synonym") if init_weights is None else init_weights
+        )
+        projected_size = (
+            hidden_size if hidden_size is not None else self.embedding.output_size
+        )
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore", UserWarning)
+            self.classifier = Metric(
+                in_features=projected_size,
+                out_features=0,
+                num_groups=0,
+                rescale=rescale,
+                metric=metric,
+            )
+        self.projector = (
+            torch.nn.Linear(self.embedding.output_size, hidden_size)
+            if hidden_size is not None
+            else torch.nn.Identity()
+        )
+
+    @property
+    def span_getter(self):
+        return self.embedding.span_getter
+
+    def to_disk(self, path, *, exclude=set()):
+        repr_id = object.__repr__(self)
+        if repr_id in exclude:
+            return
+        # This will receive the directory path + /my_component
+        # We save the bindings as a pickle file since values can be arbitrary objects
+        os.makedirs(path, exist_ok=True)
+        data_path = path / "vocab.json"
+        with open(data_path, "w") as file:
+            json.dump(
+                {
+                    "concepts": self.concepts,
+                    "span_labels": list(self.span_labels_to_idx.keys()),
+                },
+                file,
+            )
+        return super().to_disk(path, exclude=exclude)
+
+    def from_disk(self, path, exclude=tuple()):
+        repr_id = object.__repr__(self)
+        if repr_id in exclude:
+            return
+        # This will receive the directory path + /my_component
+        data_path = path / "vocab.json"
+        with open(data_path, "r") as f:
+            data = json.load(f)
+        self.span_labels_to_idx = {lab: i for i, lab in enumerate(data["span_labels"])}
+        self.classifier.groups.data = torch.zeros(
+            len(self.span_labels_to_idx),
+            len(data["concepts"]),
+            dtype=torch.bool,
+        )
+        self.update_concepts(concepts=data["concepts"])
+        self.set_extensions()
+        super().from_disk(path, exclude=exclude)
+
+    def set_extensions(self):
+        if not Span.has_extension(self.attribute):
+            Span.set_extension(self.attribute, default=None)
+        if not Span.has_extension("prob"):
+            # Builtin mutable defaults will be copied the first time they're accessed
+            Span.set_extension("prob", default={})
+        super().set_extensions()
+
+    def update_concepts(
+        self,
+        *,
+        concepts: Optional[Sequence[str]] = None,
+        mapping: Optional[Dict[str, Iterable[str]]] = None,
+        embeds: Optional[torch.Tensor] = None,
+    ):
+        old_concepts_to_idx = self.concepts_to_idx
+        device = self.device
+        if mapping is not None:
+            mapping = {k: set(v) for k, v in mapping.items()}
+            concepts = (
+                sorted(set(cui for cuis in mapping.values() for cui in cuis))
+                if concepts is None
+                else concepts
+            )
+
+        assert concepts is not None, "You must provide either `concepts` or `mapping`"
+
+        self.concepts_to_idx = {
+            cui: idx for idx, cui in enumerate(sorted(set(concepts)))
+        }
+
+        new_lin = torch.nn.Linear(
+            self.classifier.in_features,
+            len(concepts)
+            if self.reference_mode == "synonym"
+            else len(self.concepts_to_idx),
+        ).to(device)
+        if embeds is not None:
+            if self.reference_mode == "synonym":
+                embeds = embeds.to(new_lin.weight)
+                new_lin.weight.data = embeds
+            else:
+                old_weight_norm = (
+                    self.classifier.weight.norm(dim=-1).mean().item()
+                    if self.classifier.weight.shape[0]
+                    else new_lin.weight.norm(dim=-1).mean().item()
+                )
+                cui_indices = torch.as_tensor(
+                    [self.concepts_to_idx[c] for c in concepts],
+                    dtype=torch.long,
+                ).to(device)
+                new_lin.weight.data.zero_().index_add_(
+                    0, cui_indices, embeds.to(new_lin.weight)
+                )
+                del embeds
+                new_lin.weight.data = (
+                    F.normalize(
+                        new_lin.weight.data
+                        / cui_indices.bincount(minlength=len(self.concepts_to_idx))
+                        .to(device)
+                        .unsqueeze(-1)
+                    )
+                    * old_weight_norm
+                )
+                concepts = list(self.concepts_to_idx)
+        else:
+            common = sorted(set(self.concepts_to_idx) & set(old_concepts_to_idx))
+            old_index = [old_concepts_to_idx[label] for label in common]
+            new_index = [self.concepts_to_idx[label] for label in common]
+            new_lin.weight.data[new_index] = self.classifier.weight.data[old_index]
+
+        self.concepts = concepts
+        self.classifier.weight.data = new_lin.weight.data
+        self.classifier.out_features = self.classifier.weight.shape[0]
+
+        if mapping is not None:
+            mapping = {k: set(v) for k, v in mapping.items()}
+            self.classifier.groups.data = torch.tensor(
+                [[True] * len(concepts)]
+                + [
+                    [concept in label_concepts for concept in self.concepts]
+                    for span_label, label_concepts in mapping.items()
+                ],
+                device=device,
+            )
+            span_labels = [None] + list(mapping.keys())
+            span_labels_to_idx = {label: idx for idx, label in enumerate(span_labels)}
+            self.span_labels_to_idx = span_labels_to_idx
+
+    def compute_embeddings(
+        self, docs: Iterable[Doc]
+    ) -> Tuple[torch.Tensor, List[str], List[str]]:
+        embedding = self.embedding
+
+        def prepare_batch(docs, device):
+            batch = [embedding.preprocess(doc) for doc in docs]
+            spans = [span for prep in batch for span in prep["$spans"]]
+            batch = decompress_dict(list(batch_compress_dict(batch)))
+            inputs = embedding.collate(batch)
+            inputs = embedding.batch_to_device(inputs, device=device)
+            batch_concepts = [s._.get(self.attribute) for s in spans]
+            batch_labels = [s.label_ for s in spans]
+            return {
+                "inputs": inputs,
+                "concepts": batch_concepts,
+                "labels": batch_labels,
+            }
+
+        def run_forward(batch):
+            res = embedding.module_forward(batch["inputs"])
+            res = F.normalize(self.projector(res["embeddings"])).cpu()
+            return {
+                "embeds": res,
+                "concepts": batch["concepts"],
+                "labels": batch["labels"],
+            }
+
+        # Loop over the data to compute the embeddings and collect the concepts
+        results = ld_to_dl(
+            edsnlp.data.from_iterable(docs).map_gpu(
+                forward=run_forward,
+                prepare_batch=prepare_batch,
+            )
+        )
+        all_embeds = torch.cat(results["embeds"], dim=0).to(self.device)
+        all_concepts = [c for batch in results["concepts"] for c in batch]
+        all_labels = [c for batch in results["labels"] for c in batch]
+        if not len(all_concepts):
+            warnings.warn("Did not find any concept when scanning the gold data.")
+
+        return all_embeds, all_concepts, all_labels
+
+    def post_init(self, gold_data: Iterable[Doc], exclude: Set[str]):
+        super().post_init(gold_data, exclude=exclude)
+
+        embeds, concepts, labels = self.compute_embeddings(gold_data)
+        labels_to_cuis = defaultdict(set)
+        for label, cui in zip(labels, concepts):
+            if cui is not None:
+                labels_to_cuis[label].add(cui)
+
+        self.update_concepts(
+            concepts=concepts,
+            mapping=labels_to_cuis,
+            embeds=embeds,
+        )
+
+    def preprocess(self, doc: Doc) -> Dict[str, Any]:
+        embedding = self.embedding.preprocess(doc)
+
+        # "$" prefix means this field won't be accessible from the outside.
+        spans = embedding["$spans"]
+        return {
+            "embedding": embedding,
+            "span_labels": [
+                self.span_labels_to_idx.get(span.label_, 0) for span in spans
+            ],
+        }
+
+    def preprocess_supervised(self, doc: Doc) -> Dict[str, Any]:
+        preps = self.preprocess(doc)
+        spans = preps["embedding"]["$spans"]
+        return {
+            **preps,
+            "concepts": [self.concepts_to_idx.get(span._.cui, -100) for span in spans],
+        }
+
+    def collate(self, batch: Dict[str, Sequence[Any]]) -> SpanLinkerBatchInput:
+        span_labels = ft.as_folded_tensor(
+            batch["span_labels"],
+            dtype=torch.long,
+            full_names=("sample", "span"),
+            data_dims=("span",),
+        ).as_tensor()
+        collated: SpanLinkerBatchInput = {
+            "span_labels": span_labels,
+            "embedding": self.embedding.collate(batch["embedding"]),
+        }
+        if "concepts" in batch:
+            collated["concepts"] = ft.as_folded_tensor(
+                batch["concepts"],
+                dtype=torch.long,
+                full_names=("sample", "span"),
+                data_dims=("span",),
+            ).as_tensor()
+        return collated
+
+    def forward(self, inputs: SpanLinkerBatchInput) -> BatchOutput:
+        embedding = self.embedding.module_forward(inputs["embedding"])
+        span_embeds = self.projector(embedding["embeddings"])
+
+        span_labels = inputs["span_labels"]
+        targets = inputs.get("concepts")
+
+        scores = self.classifier(
+            inputs=span_embeds,
+            group_indices=span_labels,
+        )
+        concepts = loss = top_probs = None
+        if targets is not None:
+            #  TRAINING
+            num_classes = self.classifier.weight.shape[0]
+            loss = (
+                F.binary_cross_entropy_with_logits(
+                    scores,
+                    F.one_hot(targets, num_classes=num_classes).float(),
+                    reduction="sum",
+                )
+                / num_classes
+                if self.probability_mode == "sigmoid"
+                else F.cross_entropy(scores, targets, reduction="sum")
+            )
+        else:
+            # PREDICTION
+            probs = (
+                torch.sigmoid(scores)
+                if self.probability_mode == "sigmoid"
+                else torch.softmax(scores, -1)
+            )
+            top_probs, concepts = probs.max(-1)
+
+        return {
+            "loss": loss,
+            "concepts": concepts,
+            "probs": top_probs,
+        }
+
+    def postprocess(self, docs: Sequence[Doc], res: BatchOutput) -> Sequence[Doc]:
+        spans = [
+            (s, group)
+            for doc in docs
+            for s, group in get_spans_with_group(doc, self.span_getter)
+        ]
+        ents = {doc: {} for doc in docs}
+        for (span, group), concept_idx, prob in zip(
+            spans,
+            res["concepts"].tolist(),
+            res["probs"].tolist(),
+        ):
+            span._.set(
+                self.attribute,
+                self.concepts[concept_idx] if prob >= self.threshold else None,
+            )
+            span._.prob[self.attribute] = {self.concepts[concept_idx]: prob}
+            if group == "ents":
+                ents[span.doc][span.start] = span
+        for doc, doc_ents in ents.items():
+            if doc_ents:
+                doc.ents = {
+                    **{span.start: span for span in doc.ents},
+                    **doc_ents,
+                }.values()
+        return docs
diff --git a/edsnlp/pipes/trainable/span_qualifier/span_qualifier.py b/edsnlp/pipes/trainable/span_qualifier/span_qualifier.py
index a684138a1..79db1a606 100644
--- a/edsnlp/pipes/trainable/span_qualifier/span_qualifier.py
+++ b/edsnlp/pipes/trainable/span_qualifier/span_qualifier.py
@@ -96,31 +96,28 @@ class TrainableSpanQualifier(
     To create a span qualifier component, you can use the following code:
 
     ```python
-    import edsnlp
+    import edsnlp, edsnlp.pipes as eds
 
     nlp = edsnlp.blank("eds")
     nlp.add_pipe(
-        "eds.span_qualifier",
-        name="qualifier",
-        config={
-            "embedding": {
-                # To embed the spans, we will use a span pooler
-                "@factory": "eds.span_pooler",
-                "pooling_mode": "mean",  # mean pooling
-                "span_getter": ["ents", "sc"],
-                "embedding": {
-                    # that will use a transformer to embed the doc words
-                    "@factory": "eds.transformer",
-                    "model": "prajjwal1/bert-tiny",
-                    "window": 128,
-                    "stride": 96,
-                },
-            },
+        eds.span_qualifier(
+            # To embed the spans, we will use a span pooler
+            embedding=eds.span_pooler(
+                pooling_mode="mean",  # mean pooling
+                span_getter=["ents", "sc"],
+                # that will use a transformer to embed the doc words
+                embedding=eds.transformer(
+                    model="prajjwal1/bert-tiny",
+                    window=128,
+                    stride=96,
+                ),
+            ),
             # For every span embedded by the span pooler
             # (doc.ents and doc.spans["sc"]), we will predict both
             # span._.negation and span._.event_type
-            "qualifiers": ["_.negation", "_.event_type"],
-        },
+            qualifiers=["_.negation", "_.event_type"],
+        ),
+        name="qualifier",
     )
     ```
 
@@ -165,8 +162,8 @@ class TrainableSpanQualifier(
 
     def __init__(
         self,
-        nlp: Optional[PipelineProtocol],
-        name: str = "eds.span_qualifier",
+        nlp: Optional[PipelineProtocol] = None,
+        name: str = "span_qualifier",
         *,
         embedding: SpanEmbeddingComponent,
         qualifiers: QualifiersArg,
diff --git a/edsnlp/processing/multiprocessing.py b/edsnlp/processing/multiprocessing.py
index 35f62bfac..bb6caf44e 100644
--- a/edsnlp/processing/multiprocessing.py
+++ b/edsnlp/processing/multiprocessing.py
@@ -29,14 +29,9 @@
 
 from edsnlp.core.lazy_collection import LazyCollection
 from edsnlp.data.converters import set_current_tokenizer
+from edsnlp.utils.batching import batchify_fns, batchify_with_counts
 from edsnlp.utils.collections import batchify, flatten
 
-batch_size_fns = {
-    "words": lambda batch: sum(len(doc) for doc in batch),
-    "padded_words": lambda batch: max(len(doc) for doc in batch) * len(batch),
-    "docs": len,
-}
-
 doc_size_fns = {
     "words": len,
 }
@@ -163,6 +158,12 @@ def revert():
     else lambda *args, **kwargs: None
 )
 
+if os.environ.get("TORCH_SHARING_STRATEGY"):
+    try:
+        torch.multiprocessing.set_sharing_strategy(os.environ["TORCH_SHARING_STRATEGY"])
+    except NameError:
+        pass
+
 try:
     import torch
 
@@ -352,10 +353,9 @@ def split_task_into_new_batches(task):
 
                 batches = [
                     batch
-                    for batch in batchify(
+                    for batch in batchify_fns[lc.batch_by](
                         docs,
                         batch_size=lc.batch_size,
-                        formula=batch_size_fns[lc.batch_by],
                     )
                 ]
 
@@ -446,13 +446,11 @@ def read_tasks():
             preprocess_pipes = []
             num_cpu = self.exchanger.num_cpu_workers
             split_into_batches_after = lc.split_into_batches_after
-            if (
-                split_into_batches_after is None
-                or lc.batch_by != "docs"
-                or lc.sort_chunks
+            if split_into_batches_after is None and (
+                lc.batch_by != "docs" or lc.sort_chunks
             ):
                 split_into_batches_after = next(
-                    (p[0] for p in lc.pipeline if p[0] is not None), None
+                    (s[0] for s in lc.pipeline if s[0] == "_ensure_doc"), None
                 )
             is_before_split = split_into_batches_after is not None
 
@@ -481,28 +479,32 @@ def read_tasks():
 
             for stage, (gpu_idx, batch_id, result) in read_tasks():
                 docs, task_id = active_batches.pop(batch_id)
+                count = len(docs)
                 for name, pipe, *rest in lc.pipeline:
                     if hasattr(pipe, "enable_cache"):
                         pipe.enable_cache(batch_id)
                 if stage > 0:
                     gpu_pipe = stages[stage - 1]["gpu_component"]
-                    docs = gpu_pipe.postprocess(docs, result)  # type: ignore
+                    docs = (
+                        gpu_pipe.postprocess(docs, result)
+                        if getattr(gpu_pipe, "postprocess", None) is not None
+                        else result
+                    )
 
                 docs = apply_basic_pipes(docs, stages[stage]["cpu_components"])
 
                 gpu_pipe: "TorchComponent" = stages[stage]["gpu_component"]
                 if gpu_pipe is not None:
-                    preprocessed = gpu_pipe.make_batch(docs)  # type: ignore
                     active_batches[batch_id] = (docs, task_id)
                     if gpu_idx is None:
                         gpu_idx = batch_id % len(self.exchanger.gpu_worker_devices)
-                    collated = gpu_pipe.collate(preprocessed)
-                    collated = gpu_pipe.batch_to_device(
-                        collated,
-                        device=self.exchanger.gpu_worker_devices[gpu_idx],
-                    )
+                    device = self.exchanger.gpu_worker_devices[gpu_idx]
                     self.exchanger.put_gpu(
-                        item=(self.cpu_idx, batch_id, collated),
+                        item=(
+                            self.cpu_idx,
+                            batch_id,
+                            gpu_pipe.prepare_batch(docs, device=device),
+                        ),
                         idx=gpu_idx,
                         stage=stage,
                     )
@@ -513,7 +515,7 @@ def read_tasks():
                     results, count = (
                         lc.writer.write_worker(docs)
                         if lc.writer is not None
-                        else (docs, len(docs))
+                        else (docs, count)
                     )
                     self.exchanger.put_results(
                         (
@@ -585,7 +587,7 @@ def run(self):
             # Inform the main process that we are ready
             self.exchanger.put_results((None, 0, None, None))
 
-            with torch.no_grad():
+            with torch.no_grad():  # , torch.cuda.amp.autocast():
                 while True:
                     stage, task = self.exchanger.get_gpu_task(self.gpu_idx)
                     if task is None:
@@ -686,11 +688,6 @@ def execute_multiprocessing_backend(
         may not be the same as the order of the input tasks.
 
     """
-    try:
-        TorchComponent = sys.modules["edsnlp.core.torch_component"].TorchComponent
-    except (KeyError, AttributeError):  # pragma: no cover
-        TorchComponent = None
-
     steps = lc.pipeline
     num_cpu_workers = lc.num_cpu_workers
     num_gpu_workers = lc.num_gpu_workers
@@ -698,11 +695,11 @@ def execute_multiprocessing_backend(
     process_start_method = lc.process_start_method
 
     # Infer which pipes should be accelerated on GPU
-    gpu_steps_candidates = (
-        [name for name, component, *_ in steps if isinstance(component, TorchComponent)]
-        if TorchComponent is not None
-        else []
-    )
+    gpu_steps_candidates = [
+        name
+        for name, pipe, *_ in steps
+        if hasattr(pipe, "module_forward") and hasattr(pipe, "prepare_batch")
+    ]
     gpu_pipe_names = (
         gpu_steps_candidates if lc.gpu_pipe_names is None else lc.gpu_pipe_names
     )
@@ -739,6 +736,16 @@ def execute_multiprocessing_backend(
     else:
         num_gpu_workers = 0
 
+    if "torch" in sys.modules:
+        try:
+            import torch.multiprocessing
+
+            os.environ[
+                "TORCH_SHARING_STRATEGY"
+            ] = torch.multiprocessing.get_sharing_strategy()
+        except ImportError:  # pragma: no cover
+            pass
+
     if any(gpu_steps_candidates):
         if process_start_method == "fork":
             warnings.warn(
@@ -799,7 +806,7 @@ def execute_multiprocessing_backend(
         gpu_worker_devices=gpu_worker_devices,
     )
 
-    lc = lc.to("cpu")
+    # lc = lc.to("cpu")
 
     cpu_workers = []
     gpu_workers = []
@@ -893,17 +900,9 @@ def get_and_process_output():
     def process():
         try:
             with bar, lc.eval():
-                for input_task_id, items in enumerate(
-                    batchify(
-                        iterable=inputs_iterator,
-                        batch_size=lc.chunk_size,
-                        drop_last=False,
-                        formula=lambda x: sum(item[1] for item in x),
-                    )
+                for input_task_id, (batch, batch_size) in enumerate(
+                    batchify_with_counts(inputs_iterator, lc.chunk_size)
                 ):
-                    batch = [item[0] for item in items]
-                    batch_size = sum(item[1] for item in items)
-
                     while all(sum(wl.values()) >= max_workload for wl in active_chunks):
                         yield from get_and_process_output()
 
diff --git a/edsnlp/processing/simple.py b/edsnlp/processing/simple.py
index 40624393d..ec4fe0662 100644
--- a/edsnlp/processing/simple.py
+++ b/edsnlp/processing/simple.py
@@ -5,18 +5,12 @@
 from typing import TYPE_CHECKING
 
 from edsnlp.data.converters import set_current_tokenizer
+from edsnlp.utils.batching import batchify_fns
 from edsnlp.utils.collections import batchify, flatten
 
 if TYPE_CHECKING:
     from edsnlp.core.lazy_collection import LazyCollection
 
-
-batch_size_fns = {
-    "words": lambda batch: sum(len(doc) for doc in batch),
-    "padded_words": lambda batch: max(len(doc) for doc in batch) * len(batch),
-    "docs": len,
-}
-
 doc_size_fns = {
     "words": len,
 }
@@ -48,11 +42,11 @@ def execute_simple_backend(
     show_progress = lc.show_progress
 
     split_into_batches_after = lc.split_into_batches_after
-    if split_into_batches_after is None or lc.batch_by != "docs" or lc.sort_chunks:
+    if split_into_batches_after is None and (lc.batch_by != "docs" or lc.sort_chunks):
         split_into_batches_after = next(
-            (p[0] for p in lc.pipeline if p[0] is not None), None
+            (s[0] for s in lc.pipeline if s[0] == "_ensure_doc"), None
         )
-    names = [step[0] for step in lc.pipeline] + [None]
+    names = [None] + [step[0] for step in lc.pipeline]
     chunk_components = lc.pipeline[: names.index(split_into_batches_after)]
     batch_components = lc.pipeline[names.index(split_into_batches_after) :]
 
@@ -77,16 +71,8 @@ def process():
                 if lc.sort_chunks:
                     docs.sort(key=doc_size_fns.get(lc.sort_chunks, len))
 
-                batches = [
-                    batch
-                    for batch in batchify(
-                        docs,
-                        batch_size=lc.batch_size,
-                        formula=batch_size_fns.get(lc.batch_by, len),
-                    )
-                ]
-
-                for batch in batches:
+                for batch in batchify_fns[lc.batch_by](docs, lc.batch_size):
+                    count = len(batch)
                     with no_grad(), lc.cache():
                         batch = apply_basic_pipes(batch, batch_components)
 
@@ -97,7 +83,7 @@ def process():
                         yield result
                     else:
                         if show_progress:
-                            bar.update(len(batch))
+                            bar.update(count)
                         yield batch
             if writer is not None:
                 result, count = writer.finalize()
diff --git a/edsnlp/scorers/__init__.py b/edsnlp/scorers/__init__.py
index 08a375441..7956da3c9 100644
--- a/edsnlp/scorers/__init__.py
+++ b/edsnlp/scorers/__init__.py
@@ -1,25 +1,42 @@
-from typing import Any, Callable, Dict, Iterable, Union
+from typing import Any, Callable, Dict, Iterable, Union, Sequence
 
 from spacy.tokens import Doc
 from spacy.training import Example
 
+import numpy as np
+
 Scorer = Union[
     Callable[[Iterable[Doc], Iterable[Doc]], Dict[str, Dict[str, Any]]],
     Callable[[Iterable[Example]], Dict[str, Dict[str, Any]]],
 ]
 
 
-def prf(pred, gold):
+def average_precision(pred: Dict[Any, float], gold: Iterable[Any]):
+    # Average precision computation (pred is {prediction -> probability})
+    pred = sorted(pred, key=lambda k: pred[k], reverse=True)
+    correct = [p in gold for p in pred]
+    cum_correct = np.cumsum(correct)
+    num_gold = len(gold)
+    precisions = cum_correct / np.arange(1, len(correct) + 1)
+    recalls = cum_correct / num_gold if num_gold > 0 else np.zeros(len(correct))
+    ap = 0.0
+    for i in range(1, len(precisions)):
+        if recalls[i] > recalls[i - 1]:
+            ap += (recalls[i] - recalls[i - 1]) * precisions[i]
+    return ap
+
+
+def prf(pred: Sequence, gold: Sequence):
     tp = len(set(pred) & set(gold))
-    np = len(pred)
-    ng = len(gold)
+    num_pred = len(pred)
+    num_gold = len(gold)
     return {
-        "f": 2 * tp / max(1, np + ng),
-        "p": 1 if tp == np else (tp / np),
-        "r": 1 if tp == ng else (tp / ng),
+        "f": 2 * tp / max(1, num_pred + num_gold),
+        "p": 1 if tp == num_pred else (tp / num_pred),
+        "r": 1 if tp == num_gold else (tp / num_gold),
         "tp": tp,
-        "support": ng,  # num gold
-        "positives": np,  # num predicted
+        "support": num_gold,  # num gold
+        "positives": num_pred,  # num predicted
     }
 
 
diff --git a/edsnlp/scorers/span_classification.py b/edsnlp/scorers/span_classification.py
index ea9b8bf2e..78bb1fb86 100644
--- a/edsnlp/scorers/span_classification.py
+++ b/edsnlp/scorers/span_classification.py
@@ -4,7 +4,7 @@
 from spacy.training import Example
 
 from edsnlp import registry
-from edsnlp.scorers import make_examples, prf
+from edsnlp.scorers import average_precision, make_examples, prf
 from edsnlp.utils.bindings import BINDING_GETTERS, Qualifiers, QualifiersArg
 from edsnlp.utils.span_getters import SpanGetterArg, get_spans
 
@@ -45,10 +45,11 @@ def span_classification_scorer(
     -------
     Dict[str, float]
     """
-    labels = defaultdict(lambda: (set(), set()))
-    labels["micro"] = (set(), set())
+    labels = defaultdict(lambda: (set(), set(), dict()))
+    labels["micro"] = (set(), set(), dict())
     total_pred_count = 0
     total_gold_count = 0
+
     if not include_falsy:
         default_values_ = defaultdict(lambda: False)
         default_values_.update(default_values)
@@ -65,6 +66,14 @@ def span_classification_scorer(
                     qualifier if qualifier.startswith("_.") else f"_.{qualifier}"
                 )
                 value = BINDING_GETTERS[getter_key](span)
+                top_val, top_p = max(
+                    getattr(span._, "prob", {}).get(qualifier, {}).items(),
+                    key=lambda x: x[1],
+                    default=(value, 1.0),
+                )
+                if (top_val or include_falsy) and default_values[qualifier] != top_val:
+                    labels[qualifier][2][(eg_idx, span_idx, qualifier, top_val)] = top_p
+                    labels[micro_key][2][(eg_idx, span_idx, qualifier, top_val)] = top_p
                 if (value or include_falsy) and default_values[qualifier] != value:
                     labels[micro_key][0].add((eg_idx, span_idx, qualifier, value))
                     labels[qualifier][0].add((eg_idx, span_idx, qualifier, value))
@@ -91,7 +100,13 @@ def span_classification_scorer(
             "another NER pipe in your model."
         )
 
-    return {name: prf(pred, gold) for name, (pred, gold) in labels.items()}
+    return {
+        name: {
+            **prf(pred, gold),
+            "ap": average_precision(pred_with_prob, gold),
+        }
+        for name, (pred, gold, pred_with_prob) in labels.items()
+    }
 
 
 @registry.scorers.register("eds.span_classification_scorer")
diff --git a/edsnlp/utils/batching.py b/edsnlp/utils/batching.py
new file mode 100644
index 000000000..30138d6e4
--- /dev/null
+++ b/edsnlp/utils/batching.py
@@ -0,0 +1,102 @@
+from functools import partial
+from typing import Any, Iterable, List, TypeVar
+
+from edsnlp.utils.collections import batchify
+from edsnlp.utils.span_getters import get_spans
+
+T = TypeVar("T")
+
+
+def batchify_by_words(
+    iterable: Iterable[T],
+    batch_size: int,
+    drop_last: bool = False,
+) -> Iterable[List[T]]:
+    """
+    Yields batch that contain at most `batch_size` words.
+    If an item contains more than `batch_size` words, it will be yielded as a single
+    batch.
+
+    Parameters
+    ----------
+    iterable
+    batch_size
+    drop_last
+    """
+    batch = []
+    total = 0
+    for item in iterable:
+        count = len(item)
+        if total + count > batch_size and total > 0:
+            yield batch
+            batch = []
+            total = 0
+        batch.append(item)
+        total += count
+    if len(batch) > 0 and not drop_last:
+        yield batch
+
+
+def batchify_by_padded_words(
+    iterable: Iterable[T],
+    batch_size: int,
+    drop_last: bool = False,
+) -> Iterable[List[T]]:
+    batch = []
+    max_words = 0
+    for item in iterable:
+        count = len(item)
+        if (1 + len(batch)) * max(max_words, count) > batch_size:
+            yield batch
+            batch = []
+            max_words = 0
+        batch.append(item)
+        max_words = max(max_words, count)
+    if len(batch) > 0 and not drop_last:
+        yield batch
+
+
+def batchify_by_spans(
+    iterable: Iterable[T],
+    batch_size: int,
+    span_getter: Any = {"ents": True},
+    drop_last: bool = False,
+) -> Iterable[List[T]]:
+    batch = []
+    total = 0
+    for item in iterable:
+        count = len(list(get_spans(item, span_getter)))
+        if total + count > batch_size:
+            yield batch
+            batch = []
+            total = 0
+        batch.append(item)
+        total += count
+    if len(batch) > 0 and not drop_last:
+        yield batch
+
+
+def batchify_with_counts(
+    iterable,
+    batch_size,
+):
+    total = 0
+    batch = []
+    for item, count in iterable:
+        if total + count > batch_size:
+            yield batch, total
+            batch = []
+            total = 0
+        batch.append(item)
+        total += count
+    if len(batch) > 0:
+        yield batch, total
+
+
+batchify_fns = {
+    "words": batchify_by_words,
+    "padded_words": batchify_by_padded_words,
+    "spans": batchify_by_spans,
+    "docs": batchify,
+    "ents": partial(batchify_by_spans, span_getter={"ents": True}),
+}
diff --git a/edsnlp/utils/collections.py b/edsnlp/utils/collections.py
index 7e4704063..6544f3bf0 100644
--- a/edsnlp/utils/collections.py
+++ b/edsnlp/utils/collections.py
@@ -3,7 +3,6 @@
 from collections import defaultdict
 from typing import (
     Any,
-    Callable,
     Dict,
     Iterable,
     Iterator,
@@ -161,7 +160,6 @@ def batchify(
     iterable: Iterable[T],
     batch_size: int,
     drop_last: bool = False,
-    formula: Callable = len,
 ) -> Iterable[List[T]]:
     """
     Yields batch that contain at most `batch_size` elements.
@@ -172,11 +170,11 @@ def batchify(
     ----------
     iterable
     batch_size
+    drop_last
     """
     batch = []
     for item in iterable:
-        next_size = formula(batch + [item])
-        if next_size > batch_size and len(batch) > 0:
+        if len(batch) >= batch_size:
             yield batch
             batch = []
         batch.append(item)
diff --git a/edsnlp/utils/package.py b/edsnlp/utils/package.py
index aa14d8328..4a1831f79 100644
--- a/edsnlp/utils/package.py
+++ b/edsnlp/utils/package.py
@@ -388,7 +388,7 @@ def make_src_dir(self):
                     "remove it from the pyproject.toml metadata."
                 )
             os.makedirs(new_file_path.parent, exist_ok=True)
-            logger.info(f"COPY {file_path}" f"TO {new_file_path}")
+            logger.info(f"COPY {file_path}" f" TO {new_file_path}")
             shutil.copy(file_path, new_file_path)
 
         self.update_pyproject()
diff --git a/mkdocs.yml b/mkdocs.yml
index b0ffddce8..5878bfad8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -13,6 +13,7 @@ repo_name: aphp/edsnlp
 theme:
   language: en
   name: material
+  custom_dir: docs/assets/overrides
   palette:
     - scheme: default
       toggle:
@@ -34,6 +35,7 @@ theme:
     - navigation.footer
     - content.code.annotate
     - content.code.copy
+    - announce.dismiss
 
 nav:
   - index.md
diff --git a/pyproject.toml b/pyproject.toml
index 45f6c0e5d..ca2a78c58 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -222,12 +222,13 @@ where = ["."]
 # edsnlp will look both in the above dict and in the one below.
 [project.entry-points."edsnlp_factories"]
 # Trainable
-"eds.transformer"                 = "edsnlp.pipes.trainable.embeddings.transformer.factory:create_component"
-"eds.text_cnn"                    = "edsnlp.pipes.trainable.embeddings.text_cnn.factory:create_component"
-"eds.span_pooler"                 = "edsnlp.pipes.trainable.embeddings.span_pooler.factory:create_component"
-"eds.ner_crf"                     = "edsnlp.pipes.trainable.ner_crf.factory:create_component"
-"eds.nested_ner"                  = "edsnlp.pipes.trainable.ner_crf.factory:create_component"
-"eds.span_qualifier"              = "edsnlp.pipes.trainable.span_qualifier.factory:create_component"
+"eds.transformer"        = "edsnlp.pipes.trainable.embeddings.transformer.factory:create_component"
+"eds.text_cnn"           = "edsnlp.pipes.trainable.embeddings.text_cnn.factory:create_component"
+"eds.span_pooler"        = "edsnlp.pipes.trainable.embeddings.span_pooler.factory:create_component"
+"eds.ner_crf"            = "edsnlp.pipes.trainable.ner_crf.factory:create_component"
+"eds.nested_ner"         = "edsnlp.pipes.trainable.ner_crf.factory:create_component"
+"eds.span_qualifier"      = "edsnlp.pipes.trainable.span_qualifier.factory:create_component"
+"eds.span_linker"        = "edsnlp.pipes.trainable.span_linker.factory:create_component"
 
 [project.entry-points."spacy_scorers"]
 "eds.ner_exact_scorer"           = "edsnlp.scorers.ner:create_ner_exact_scorer"
@@ -323,17 +324,18 @@ ban-relative-imports = "parents"
 
 [tool.ruff.isort]
 known-first-party = ["edsnlp"]
+known-third-party = ["build"]
 order-by-type = true
 
 [tool.interrogate]
 ignore-init-method = true
 ignore-init-module = true
 ignore-magic = false
-ignore-semiprivate = false
+ignore-semiprivate = true
 ignore-private = false
 ignore-property-decorators = false
 ignore-module = true
-ignore-nested-functions = false
+ignore-nested-functions = true
 ignore-nested-classes = true
 ignore-setters = false
 fail-under = 40
@@ -341,6 +343,7 @@ exclude = ["setup.py", "docs", "build", "tests"]
 verbose = 0
 quiet = false
 whitelist-regex = []
+ignore-regex = ['__(?!init).*__']
 color = true
 omit-covered-files = false
 # generate-badge = "."
@@ -360,6 +363,7 @@ exclude_lines = [
     "raise .*Exception",
     "warn\\(",
     "if __name__ == .__main__.:",
+    "if repr_id in exclude:",
     "if TYPE_CHECKING:",
     "class .*\\bProtocol\\):",
     "@(abc\\.)?abstractmethod",


Test patch:
diff --git a/tests/data/test_lazy_collection.py b/tests/data/test_lazy_collection.py
index 16879243d..82a2e4dda 100644
--- a/tests/data/test_lazy_collection.py
+++ b/tests/data/test_lazy_collection.py
@@ -1,6 +1,8 @@
 import pytest
+import torch
 
 import edsnlp
+from edsnlp.utils.collections import ld_to_dl
 
 
 def test_map_batches():
@@ -25,3 +27,69 @@ def test_flat_iterable(num_cpu_workers):
     lazy = lazy.map(lambda x: [x] * x)
     res = list(lazy)
     assert sorted(res) == [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
+
+
+@pytest.mark.parametrize("num_gpu_workers", [0, 1])
+def test_map_gpu(num_gpu_workers):
+    def prepare_batch(batch, device):
+        return {"tensor": torch.tensor(batch).to(device)}
+
+    def forward(batch):
+        return {"outputs": batch["tensor"] * 2}
+
+    items = [1, 2, 3, 4, 5]
+    lazy = edsnlp.data.from_iterable(items)
+    lazy = lazy.map(lambda x: x + 1)
+    lazy = lazy.map_gpu(prepare_batch, forward)
+    lazy = lazy.set_processing(
+        num_gpu_workers=num_gpu_workers,
+        gpu_worker_devices=["cpu"] * num_gpu_workers,
+        sort_chunks=False,
+        batch_size=2,
+    )
+
+    res = ld_to_dl(lazy)
+    res = torch.cat(res["outputs"])
+    assert torch.all(res == torch.tensor([4, 6, 8, 10, 12]))
+
+
+@pytest.mark.parametrize("num_cpu_workers", [1, 2])
+@pytest.mark.parametrize(
+    "batch_by,expected",
+    [
+        ("words", [3, 1, 3, 1, 3, 1]),
+        ("padded_words", [2, 1, 1, 2, 1, 1, 2, 1, 1]),
+        ("docs", [10, 2]),
+        ("ents", [3, 2, 3, 3, 1]),
+    ],
+)
+def test_map_with_batching(num_cpu_workers, batch_by, expected):
+    nlp = edsnlp.blank("eds")
+    nlp.add_pipe(
+        "eds.matcher",
+        config={
+            "terms": {
+                "foo": ["This", "is", "a", "sentence", ".", "Short", "snippet", "too"],
+            }
+        },
+        name="matcher",
+    )
+    samples = [
+        "This is a sentence.",
+        "Short snippet",
+        "Short snippet too",
+        "This is a very very long sentence that will make more than 10 words",
+    ] * 3
+    lazy = edsnlp.data.from_iterable(samples)
+    lazy = lazy.map_pipeline(nlp)
+    lazy = lazy.map_batches(len)
+    lazy = lazy.to("cpu")
+    lazy = lazy.set_processing(
+        num_cpu_workers=num_cpu_workers,
+        batch_size=10,
+        batch_by=batch_by,
+        chunk_size=1000,
+        split_into_batches_after="matcher",
+        show_progress=True,
+    )
+    assert list(lazy) == expected
diff --git a/tests/pipelines/ner/test_umls.py b/tests/pipelines/ner/test_umls.py
index e4ace7e0e..a41962ef3 100644
--- a/tests/pipelines/ner/test_umls.py
+++ b/tests/pipelines/ner/test_umls.py
@@ -51,8 +51,6 @@ def test_add_pipe(blank_nlp: PipelineProtocol):
 
     blank_nlp.add_pipe("eds.umls", config=dict(pattern_config=pattern_config))
 
-    assert "eds.umls" in blank_nlp.pipe_names
-
     for text, entities in map(parse_example, examples):
         doc = blank_nlp(text)
         assert len(doc.ents) == len(entities)
diff --git a/tests/pipelines/test_pipelines.py b/tests/pipelines/test_pipelines.py
index f37604f63..fa09cc8e6 100644
--- a/tests/pipelines/test_pipelines.py
+++ b/tests/pipelines/test_pipelines.py
@@ -9,4 +9,8 @@ def test_pipelines(doc):
 
 
 def test_import_all():
-    import edsnlp.pipelines.factories  # noqa: F401
+    import edsnlp.pipes
+
+    for name in dir(edsnlp.pipes):
+        if not name.startswith("_"):
+            getattr(edsnlp.pipes, name)
diff --git a/tests/pipelines/trainable/test_ner.py b/tests/pipelines/trainable/test_ner.py
index db8de279c..60db8bf3c 100644
--- a/tests/pipelines/trainable/test_ner.py
+++ b/tests/pipelines/trainable/test_ner.py
@@ -61,8 +61,7 @@ def test_ner(ner_mode, window):
 
     ner = nlp.get_pipe("ner")
     ner.update_labels(["PERSON", "GIFT"])
-    batch = ner.make_batch([doc], supervision=True)
-    batch = ner.collate(batch)
+    batch = ner.prepare_batch([doc], supervision=True)
     batch = ner.module_forward(batch)
 
     list(ner.pipe([doc]))
diff --git a/tests/pipelines/trainable/test_span_linker.py b/tests/pipelines/trainable/test_span_linker.py
new file mode 100644
index 000000000..e949d222f
--- /dev/null
+++ b/tests/pipelines/trainable/test_span_linker.py
@@ -0,0 +1,110 @@
+import pytest
+import spacy
+from pytest import fixture
+from spacy.tokens import Span
+from typing_extensions import Literal
+
+import edsnlp
+import edsnlp.pipes as eds
+
+if not Span.has_extension("cui"):
+    Span.set_extension("cui", default=None)
+
+
+@fixture
+def gold():
+    blank_nlp = edsnlp.blank("eds")
+    doc1 = blank_nlp.make_doc("Prise de folfox puis doliprane par le patient.")
+
+    doc1.ents = [
+        Span(doc1, 0, 1, "event"),
+        Span(doc1, 2, 3, "drug"),
+        Span(doc1, 4, 5, "drug"),
+        Span(doc1, 7, 8, "livb"),
+    ]
+    doc1.ents[0]._.cui = None
+    doc1.ents[1]._.cui = "CONCEPT1"
+    doc1.ents[2]._.cui = "CONCEPT2"
+    doc1.ents[3]._.cui = "CONCEPT3"
+
+    return [doc1]
+
+
+@pytest.mark.parametrize(
+    "metric,probability_mode,reference_mode",
+    [
+        ("cosine", "softmax", "class"),
+        ("cosine", "sigmoid", "class"),
+        ("dot", "sigmoid", "synonym"),
+    ],
+)
+def test_span_linker(
+    metric: Literal["cosine", "dot"],
+    probability_mode: Literal["softmax", "sigmoid"],
+    reference_mode: Literal["class", "synonym"],
+    gold,
+    tmp_path,
+):
+    nlp = edsnlp.blank("eds")
+    nlp.add_pipe(
+        eds.span_linker(
+            rescale=20.0,
+            threshold=0.8,
+            hidden_size=128,
+            metric=metric,
+            reference_mode=reference_mode,
+            probability_mode=probability_mode,
+            embedding=eds.span_pooler(
+                span_getter=["ents"],
+                embedding=eds.transformer(
+                    span_getter=["ents"],
+                    model="prajjwal1/bert-tiny",
+                    window=128,
+                    stride=96,
+                ),
+            ),
+        ),
+        name="linker",
+    )
+
+    def convert(entry):
+        doc = nlp.make_doc(entry["STR"].lower()[:100])
+        span = spacy.tokens.Span(
+            doc,
+            0,
+            len(doc),
+            label=entry["GRP"],
+        )
+        span._.cui = entry["CUI"]
+        doc.ents = [span]
+        return doc
+
+    linker: eds.span_linker = nlp.get_pipe("linker")
+
+    synonyms = edsnlp.data.from_iterable(
+        [
+            {"STR": "folfox", "CUI": "CONCEPT1", "GRP": "drug"},
+            {"STR": "doliprane", "CUI": "CONCEPT2", "GRP": "drug"},
+            {"STR": "doliprone", "CUI": "CONCEPT2", "GRP": "drug"},
+            {"STR": "patiente", "CUI": "CONCEPT3", "GRP": "livb"},
+            {"STR": "docteur", "CUI": "CONCEPT4", "GRP": "livb"},
+        ],
+        converter=convert,
+    )
+    nlp.post_init(synonyms)
+    pred = nlp.pipe([doc.copy() for doc in gold])
+
+    pred_cuis = [span._.cui for doc in pred for span in doc.ents]
+    gold_cuis = [span._.cui for doc in pred for span in doc.ents]
+
+    assert pred_cuis == gold_cuis
+
+    if probability_mode == "softmax":
+        batch = linker.prepare_batch([doc.copy() for doc in gold], supervision=True)
+        results = linker.forward(batch)
+        assert results["loss"] > 0
+
+        nlp.to_disk(tmp_path / "model")
+        nlp = edsnlp.load(tmp_path / "model")
+
+    assert "TrainableSpanLinker" in str(linker)
diff --git a/tests/pipelines/trainable/test_transformer.py b/tests/pipelines/trainable/test_transformer.py
index 687982277..98a33efb7 100644
--- a/tests/pipelines/trainable/test_transformer.py
+++ b/tests/pipelines/trainable/test_transformer.py
@@ -2,8 +2,8 @@
 from spacy.tokens import Span
 
 import edsnlp
-from edsnlp.pipelines.trainable.embeddings.transformer.transformer import Transformer
-from edsnlp.pipelines.trainable.span_qualifier.span_qualifier import (
+from edsnlp.pipes.trainable.embeddings.transformer.transformer import Transformer
+from edsnlp.pipes.trainable.span_qualifier.span_qualifier import (
     TrainableSpanQualifier,
 )
 
@@ -62,8 +62,7 @@ def test_span_getter(gold):
     trf: Transformer = nlp.get_pipe("transformer")
     qlf: TrainableSpanQualifier = nlp.get_pipe("qualifier")
     qlf.post_init(gold, set())
-    prep = qlf.make_batch([doc.copy() for doc in gold], supervision=True)
-    batch = qlf.collate(prep)
+    batch = qlf.prepare_batch([doc.copy() for doc in gold], supervision=True)
     input_ids = batch["embedding"]["embedding"]["input_ids"]
     mask = batch["embedding"]["embedding"]["input_mask"]
     tok = trf.tokenizer
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index fdb9cb8d1..68dad1285 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -8,8 +8,10 @@
 from spacy.tokens import Doc
 
 import edsnlp
+import edsnlp.pipes as eds
 from edsnlp import Pipeline, registry
-from edsnlp.pipes.factories import normalizer, sentences
+from edsnlp.core.registries import CurriedFactory
+from edsnlp.pipes.base import BaseComponent
 
 
 class CustomClass:
@@ -35,19 +37,19 @@ def test_add_pipe_factory():
 
 def test_add_pipe_component():
     model = edsnlp.blank("eds")
-    model.add_pipe(normalizer(nlp=model), name="normalizer")
+    model.add_pipe(eds.normalizer(nlp=model), name="normalizer")
     assert "normalizer" in model.pipe_names
     assert model.has_pipe("normalizer")
     assert model.pipes.normalizer is model.get_pipe("normalizer")
 
-    model.add_pipe(sentences(nlp=model), name="sentences")
+    model.add_pipe(eds.sentences(nlp=model), name="sentences")
     assert "sentences" in model.pipe_names
     assert model.has_pipe("sentences")
     assert model.pipes.sentences is model.get_pipe("sentences")
 
     with pytest.raises(ValueError):
         model.add_pipe(
-            sentences(nlp=model, name="sentences"),
+            eds.sentences(nlp=model, name="sentences"),
             config={"punct_chars": ".?!"},
         )
 
@@ -127,8 +129,8 @@ def test_disk_serialization(tmp_path, ml_nlp):
 [components.ner]
 @factory = "eds.ner_crf"
 embedding = ${components.transformer}
-mode = "independent"
 target_span_getter = ["ents", "ner-preds"]
+mode = "independent"
 labels = ["PERSON", "GIFT"]
 infer_span_setter = false
 window = 40
@@ -199,7 +201,7 @@ def test_select_pipes(frozen_ml_nlp: Pipeline):
 def test_different_names():
     nlp = edsnlp.blank("eds")
 
-    extractor = sentences(nlp=nlp, name="custom_name")
+    extractor = eds.sentences(nlp=nlp, name="custom_name")
 
     with pytest.raises(ValueError) as exc_info:
         nlp.add_pipe(extractor, name="sentences")
@@ -276,9 +278,9 @@ def test_rule_based_pipeline():
     nlp.add_pipe("eds.normalizer")
     nlp.add_pipe("eds.covid")
 
-    assert nlp.pipe_names == ["eds.normalizer", "eds.covid"]
-    assert nlp.get_pipe("eds.normalizer") == nlp.pipeline[0][1]
-    assert nlp.has_pipe("eds.covid")
+    assert nlp.pipe_names == ["normalizer", "covid"]
+    assert nlp.get_pipe("normalizer") == nlp.pipeline[0][1]
+    assert nlp.has_pipe("covid")
 
     with pytest.raises(ValueError) as exc_info:
         nlp.get_pipe("unknown")
@@ -292,7 +294,7 @@ def test_rule_based_pipeline():
     assert len(doc.ents) == 1
     assert new_doc is doc
 
-    assert nlp.get_pipe_meta("eds.covid").assigns == ["doc.ents", "doc.spans"]
+    assert nlp.get_pipe_meta("covid").assigns == ["doc.ents", "doc.spans"]
 
 
 def test_torch_save(ml_nlp):
@@ -316,3 +318,41 @@ def test_missing_factory(nlp):
         nlp.add_pipe("__test_missing_pipe__")
 
     assert "__test_missing_pipe__" in str(exc_info.value)
+
+
+@edsnlp.registry.factory("custom-curry-test")
+class CustomComponent(BaseComponent):
+    def __init__(self, nlp, name):
+        self.nlp = nlp
+
+    def __call__(self, doc):
+        return doc
+
+
+def test_curried_nlp_pipe():
+    nlp = edsnlp.blank("eds")
+    nlp.add_pipe(eds.sentences(name="my-sentences"))
+    nlp.add_pipe(eds.normalizer())
+    nlp.add_pipe(eds.sections(), name="sections")
+    pipe = CustomComponent()
+
+    err = (
+        f"This component ({pipe}) has not been instantiated yet, likely because it was"
+        " missing an `nlp` pipeline argument. You should either:\n"
+        "- add it to a pipeline: `pipe = nlp.add_pipe(pipe)`\n"
+        "- or fill its `nlp` argument: `pipe = factory(nlp=nlp, ...)`"
+    )
+    assert isinstance(pipe, CurriedFactory)
+    with pytest.raises(TypeError) as exc_info:
+        pipe("Demo texte")
+    assert str(exc_info.value) == err
+
+    with pytest.raises(TypeError) as exc_info:
+        pipe.forward("Demo texte")
+    assert str(exc_info.value) == err
+
+    nlp.add_pipe(pipe, name="custom")
+
+    assert nlp.pipes.custom.nlp is nlp
+
+    assert nlp.pipe_names == ["my-sentences", "normalizer", "sections", "custom"]