From c4a1361187c9781c74b42ff5c2dcf1349d709d32 Mon Sep 17 00:00:00 2001 From: Jeremy Rapin Date: Fri, 7 Aug 2026 22:15:10 +0200 Subject: [PATCH] [Prototype, WIP] Fit step --- CHANGELOG.md | 3 + exca/cachedict/handlers.py | 5 +- exca/cachedict/test_dumpcontext.py | 11 ++ exca/steps/backends.py | 12 ++- exca/steps/patterns.py | 165 +++++++++++++++++++++++++++++ exca/steps/test_fit.py | 161 ++++++++++++++++++++++++++++ exca/steps/test_patterns.py | 103 +++++++++++++++++- pyproject.toml | 3 +- 8 files changed, 458 insertions(+), 5 deletions(-) create mode 100644 exca/steps/test_fit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index de0202d4..d980e880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- `steps`: `Fit` primitive — fit one artifact over a cohort of items, then transform each item with it (N->1->N). The cohort's fingerprint enters the step uid, so the artifact and downstream caches are scoped to the fit. +- `cachedict`: `Auto` stores tuples as lists, so a cached tuple loads (and deletes) its content whether or not it went through json first. + ## 0.5.29 - 26-07-28 - `ConfDict`: added `ConfDict` operations `ConfDict.ops.DELETE`, `ConfDict.ops.BEFORE`, and `ConfDict.ops.AFTER`; `ConfDict.ops.REPLACE` replaces `ConfDict.OVERRIDE`. [#310] diff --git a/exca/cachedict/handlers.py b/exca/cachedict/handlers.py index 4020972b..b84a8b0b 100644 --- a/exca/cachedict/handlers.py +++ b/exca/cachedict/handlers.py @@ -384,8 +384,9 @@ def _dump_value(cls, ctx: DumpContext, val: tp.Any, key: str) -> tp.Any: ) return {k: cls._dump_value(ctx, v, f"{key}[{k}]") for k, v in val.items()} if isinstance(val, (list, tuple)): - items = [cls._dump_value(ctx, v, f"{key}[{i}]") for i, v in enumerate(val)] - return tuple(items) if isinstance(val, tuple) else items + # as a list: json has no tuple, so a tuple would only stay one until the + # first json round-trip, and `_load_value`/`_delete_value` would skip it + return [cls._dump_value(ctx, v, f"{key}[{i}]") for i, v in enumerate(val)] handler = DumpContext._find_handler(type(val)) if handler is not None or hasattr(val, "__dump_info__"): ctx.key = key diff --git a/exca/cachedict/test_dumpcontext.py b/exca/cachedict/test_dumpcontext.py index ad9642a9..3a10922b 100644 --- a/exca/cachedict/test_dumpcontext.py +++ b/exca/cachedict/test_dumpcontext.py @@ -6,6 +6,7 @@ """Tests for DumpContext, @DumpContext.register, and handler classes.""" +import json import os import typing as tp from pathlib import Path @@ -300,6 +301,16 @@ def tracking_dump( np.testing.assert_array_almost_equal(loaded["weights"], [[1.0, 2.0], [3.0, 4.0]]) +def test_auto_tuple(tmp_path: Path) -> None: + ctx = DumpContext(tmp_path, key="test") + with ctx: + info = ctx.dump((np.array([1.0, 2.0]), 3), cache_type="Auto") + for content in [info["content"], json.loads(json.dumps(info["content"]))]: + loaded = ctx.load({**info, "content": content}) # as dumped, then as json has it + np.testing.assert_array_almost_equal(loaded[0], [1.0, 2.0]) + assert loaded[1] == 3, "a tuple loads as a list either way" + + class _Opaque: """Module-level class so it's picklable (local classes are not).""" diff --git a/exca/steps/backends.py b/exca/steps/backends.py index a308be6c..560fe9c2 100644 --- a/exca/steps/backends.py +++ b/exca/steps/backends.py @@ -14,6 +14,7 @@ import collections import contextlib +import contextvars import dataclasses import datetime import logging @@ -41,6 +42,12 @@ logger = logging.getLogger(__name__) +# True while a backend computes a batch, which may be a shard of the caller's items +# (-> a cross-item computation cannot trust it, see patterns.Fit) +_computing: contextvars.ContextVar[bool] = contextvars.ContextVar( + "computing", default=False +) + CacheStatus = tp.Literal["success", "error", None] LookupStatus = tp.Literal["success", "error", "running", None] @@ -335,9 +342,10 @@ def run_and_cache(self) -> None: folder = self.cache_dict.folder if folder is not None: folder.mkdir(parents=True, exist_ok=True) - result_items = self.step._run_items(self.items) + token = _computing.set(True) written_uids: list[str] = [] try: + result_items = self.step._run_items(self.items) with self.cache_dict.write(): for i, result in enumerate(result_items): uid = self.items.uids[i] @@ -366,6 +374,8 @@ def run_and_cache(self) -> None: for uid in inflight: reg.record(uid, e, tb) raise + finally: + _computing.reset(token) def _multi_run_and_cache(batches: list[ComputeBatch]) -> None: diff --git a/exca/steps/patterns.py b/exca/steps/patterns.py index 800ee539..67d511b3 100644 --- a/exca/steps/patterns.py +++ b/exca/steps/patterns.py @@ -7,8 +7,12 @@ from __future__ import annotations import dataclasses +import hashlib import typing as tp +import pydantic + +import exca from exca import confdict from . import backends, identity, items, utils @@ -242,3 +246,164 @@ def _run_items(self, batch: items.StepItems) -> items.StepItems: upstream=output_upstream, mode=batch._mode, ) + + +def _cohort_uid(uids: tp.Iterable[str]) -> str: + """Order-independent fingerprint of a set of item uids, as ``,``.""" + unique = sorted(set(uids)) + digest = hashlib.sha256() + for uid in unique: + digest.update(uid.encode("utf8")) + digest.update(b"\0") + return f"{digest.hexdigest()[:8]},{len(unique)}" + + +class Fit(Step): + """Fit one artifact over a cohort of items, then transform each item (N->1->N). + + .. warning:: Experimental — API may change. + + To implement a Fit, override: + + - :meth:`_fit` (required): the artifact, from the cohort's values. + - :meth:`_run` (required): one item's output, reading :attr:`fitted`. + + The cohort is the batch of the first dispatch, and its fingerprint enters this + step's uid -- so the artifact and every downstream cache are scoped to it, and + two cohorts never share an entry. Later batches reuse the artifact and may hold + items outside the cohort. + + The fit runs where the step is dispatched from -- driver-side, ahead of a + backend splitting the batch -- and is cached under ``infra``, so workers read + it back instead of refitting. The upstream is read twice (once for the fit, + once per item), so give an expensive upstream its own ``infra``. + + An *enclosing* backend is the one hazard: it may hand this step a shard of the + items rather than the cohort, so dispatching an unfitted ``Fit`` from inside one + raises. + + ``CACHE_TYPE`` sets the format of the per-item outputs as for any step, and + ``ARTIFACT_CACHE_TYPE`` that of the artifact -- by default the handlers for + arrays, tensors &co (including nested), and pickle for the rest. Prefer + returning the former, e.g. a state dict over a model. + + Parameters + ---------- + allow_fit + Whether this step may fit. ``False`` raises instead, so a batch that is not + the intended cohort cannot become one (e.g. in an evaluation run). + """ + + ARTIFACT_CACHE_TYPE: tp.ClassVar[str | None] = "AutoPickle" + + allow_fit: bool = True + + _cohort: str = pydantic.PrivateAttr("") + _fitted: tp.Any = pydantic.PrivateAttr(None) + + @classmethod + def _exclude_from_cls_uid(cls) -> list[str]: + return super()._exclude_from_cls_uid() + ["allow_fit"] # a permission + + def _fit(self, values: tp.Iterable[tp.Any]) -> tp.Any: + """The artifact for the cohort, from the values this step receives. + + *values* streams the cohort, and can be iterated more than once (e.g. one + pass per epoch) -- at the cost of re-reading the upstream each time. + """ + raise NotImplementedError + + @property + def cohort(self) -> str: + """Fingerprint of the fitted cohort, empty until this step is fitted.""" + return self._cohort + + @property + def fitted(self) -> tp.Any: + """The artifact :meth:`_fit` produced, for :meth:`_run` to transform with.""" + if not self._cohort: + raise RuntimeError( + f"{type(self).__name__} is not fitted: dispatch it on its cohort first" + ) + return self._fitted + + @classmethod + def check_fitted(cls, obj: tp.Any) -> None: + """Raise if *obj* holds an unfitted step of this class at any depth. + + For guarding a hand-off to code that must not fit, e.g. a dataloader or an + evaluation run. + """ + found = exca.utils.find_models(obj, cls) + unfitted = [name or "." for name, step in found.items() if not step.cohort] + if unfitted: + raise RuntimeError( + f"unfitted {cls.__name__} at {', '.join(unfitted)}: dispatch it on " + "its cohort before handing it over" + ) + + def _exca_uid_dict_override(self) -> dict[str, tp.Any] | None: + if not self._cohort: + return super()._exca_uid_dict_override() + exporter = exca.utils.ConfigExporter( + uid=True, exclude_defaults=True, ignore_first_override=True + ) + dump = exporter.apply(self) + dump["cohort"] = self._cohort + return dump + + def _dispatch(self, batch: items.StepItems) -> items.StepItems: + # before super(): `_exca_uid_dict_override` needs the cohort ahead of + # `_make_paths`, and a backend would otherwise fit each split of `batch` + if not self._cohort: + cohort = _cohort_uid(batch.uids) + upstream = tuple(batch._upstream) + owner = self.model_copy(update={"infra": None}) # copy: keeps private state + owner._cohort = "" # -> all cohorts of this Fit share one artifact folder + artifact = _Artifact(owner=owner, infra=self.infra) + reason = "" + if backends._computing.get(): + reason = ( + "is dispatched under an enclosing backend, which may shard the " + "items (each shard would then fit its own artifact) -- fit it " + "beforehand, or move that backend onto this step" + ) + elif not self.allow_fit: + reason = "has allow_fit=False -- fit it where fitting is allowed" + if reason and not artifact.lookup(_upstream=upstream, _uid=cohort).cached(): + raise RuntimeError( + f"{type(self).__name__} {reason} (nothing fitted for the " + f"{len(set(batch.uids))} items presented, cohort {cohort})" + ) + carrier = items.StepItems( + source={cohort: (batch,)}, + uids=[cohort], + upstream=upstream, + mode=batch._mode, # so a forced upstream refits instead of reusing + ) + # dispatch (not lookup) so the artifact obeys infra's mode and caching + self._fitted = next(iter(artifact._dispatch(carrier))) + self._cohort = cohort + return super()._dispatch(batch) + + +class _Artifact(Step): + """One cohort's artifact for a :class:`Fit`, cached as a single entry. + + ``owner`` holds the fit configuration with its cohort reset, so every cohort of + one ``Fit`` shares a folder and takes one entry in it. The input is the cohort's + carrier, wrapped in a tuple so the framework treats it as a single item. + """ + + owner: Fit + + def _infer_cache_type(self) -> str | None: + return self.owner.ARTIFACT_CACHE_TYPE + + def item_uid(self, value: tuple[items.StepItems]) -> str: + return _cohort_uid(value[0].uids) + + def _run(self, value: tuple[items.StepItems]) -> tp.Any: + # the carrier itself (not an iterator over it): iterating applies its pending + # steps, so the fit sees the values the transform will, and can iterate again + return self.owner._fit(value[0]) diff --git a/exca/steps/test_fit.py b/exca/steps/test_fit.py new file mode 100644 index 00000000..506ddd6b --- /dev/null +++ b/exca/steps/test_fit.py @@ -0,0 +1,161 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""The use cases ``Fit`` exists for, end to end: normalization, PCA, and training +a torch model over a cohort (its mechanics are covered in ``test_patterns.py``).""" + +import typing as tp +from pathlib import Path + +import numpy as np +import pydantic +import torch +from sklearn import decomposition + +from . import base, conftest +from .patterns import Fit + + +class Samples(base.Step): + """A seed becomes a (16, 3) array of samples, scaled per feature.""" + + scale: tuple[float, float, float] = (1.0, 4.0, 16.0) + + def _run(self, seed: int) -> np.ndarray: + rng = np.random.default_rng(seed) + return rng.normal(size=(16, 3)) * np.array(self.scale) + 10.0 + + +class Normalize(Fit): + """Standardizes items with the cohort's per-feature mean and std.""" + + def _fit(self, values: tp.Iterable[np.ndarray]) -> tuple[np.ndarray, np.ndarray]: + count = 0 + # 0-d accumulators: they broadcast with the first item's per-feature sums + total, squares = np.zeros(()), np.zeros(()) + for x in values: + count += len(x) + total = total + x.sum(0) + squares = squares + (x**2).sum(0) + mean = total / count + return mean, np.sqrt(squares / count - mean**2) + + def _run(self, value: np.ndarray) -> np.ndarray: + mean, std = self.fitted + return (value - mean) / std + + +class PCA(Fit): + """Projects items onto the cohort's top components.""" + + n_components: int = 2 + + def _fit(self, values: tp.Iterable[np.ndarray]) -> tuple[np.ndarray, np.ndarray]: + pca = decomposition.IncrementalPCA(n_components=self.n_components) + for x in values: + pca.partial_fit(x) + # the arrays, not the estimator: cached as arrays, and no sklearn version pin + return pca.mean_, pca.components_ + + def _run(self, value: np.ndarray) -> np.ndarray: + mean, components = self.fitted + return (value - mean) @ components.T + + +class Pairs(base.Step): + """A seed becomes one (x, y) training item, with y linear in x.""" + + def _run(self, seed: int) -> tuple[torch.Tensor, torch.Tensor]: + gen = torch.Generator().manual_seed(seed) + x = torch.rand(32, 2, generator=gen) + return x, x @ torch.tensor([[1.0], [-2.0]]) + 0.5 + + +class TrainLinear(Fit, conftest.RecordingStep): + """Trains a torch model over the cohort, then predicts item by item -- + ``.calls`` records each training run.""" + + epochs: int = 40 + seed: int = 0 + + _model: torch.nn.Module | None = pydantic.PrivateAttr(None) + + def _new_model(self) -> torch.nn.Module: + return torch.nn.Linear(2, 1) + + def _fit( + self, values: tp.Iterable[tuple[torch.Tensor, torch.Tensor]] + ) -> dict[str, torch.Tensor]: + self.record() + torch.manual_seed(self.seed) + model = self._new_model() + optimizer = torch.optim.Adam(model.parameters(), lr=0.1) + for _ in range(self.epochs): # values re-streams the cohort once per epoch + for x, y in values: + optimizer.zero_grad() + torch.nn.functional.mse_loss(model(x), y).backward() + optimizer.step() + # the weights, not the module: cached as tensors, and no pickled class + return dict(model.state_dict()) + + def _run(self, value: tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor: + if self._model is None: # rebuilt once per process, not per item + self._model = self._new_model() + self._model.load_state_dict(self.fitted) + self._model.eval() + x, _ = value + with torch.no_grad(): + return self._model(x) + + +def test_normalize_over_cohort(tmp_path: Path) -> None: + infra: tp.Any = {"backend": "Cached", "folder": tmp_path} + cohort, novel = [0, 1, 2, 3], 4 + normalize = Normalize(infra=infra) + chain = base.Chain(steps=[Samples(infra=infra), normalize]) + out = np.concatenate(list(chain.run_many(cohort))) + assert np.allclose(out.mean(0), 0.0, atol=1e-12), "cohort is centered per feature" + assert np.allclose(out.std(0), 1.0, atol=1e-12), "cohort is scaled per feature" + # the novel item is standardized by the cohort's statistics, not by its own + mean, std = normalize.fitted + raw = Samples().run(novel) + assert not np.allclose(raw.mean(0), mean, atol=0.1), "item statistics differ" + assert np.allclose(chain.run(novel), (raw - mean) / std) + + +def test_pca_over_cohort(tmp_path: Path) -> None: + infra: tp.Any = {"backend": "Cached", "folder": tmp_path} + cohort, novel = [0, 1, 2, 3], 4 + pca = PCA(n_components=2, infra=infra) + chain = base.Chain(steps=[Samples(infra=infra), pca]) + out = np.concatenate(list(chain.run_many(cohort))) + assert out.shape == (64, 2), "16 samples per item, projected on 2 components" + variances = out.var(0) + assert variances[0] > variances[1], "components come out ordered by variance" + # the novel item uses the cohort's basis, not its own + mean, components = pca.fitted + assert np.allclose(chain.run(novel), (Samples().run(novel) - mean) @ components.T) + assert not list(tmp_path.rglob("*.pkl")), "arrays cache as arrays, not as a pickle" + + +def test_train_torch_model_over_cohort(tmp_path: Path) -> None: + infra: tp.Any = {"backend": "Cached", "folder": tmp_path} + cohort = [0, 1, 2] + + def run(model: TrainLinear) -> list[torch.Tensor]: + chain = base.Chain(steps=[Pairs(infra=infra), model]) + return list(chain.run_many(cohort)) + + trained = TrainLinear(infra=infra) + for prediction, seed in zip(run(trained), cohort): + target = Pairs().run(seed)[1] + assert torch.allclose(prediction, target, atol=0.05), "trained on the cohort" + restored = TrainLinear(infra=infra) + run(restored) + assert not restored.calls, "a fresh step does not train again" + for name, weights in trained.fitted.items(): + assert torch.equal(weights, restored.fitted[name]), "same weights, from the cache" + assert not list(tmp_path.rglob("*.pkl")), "a state dict caches as tensors, unpickled" diff --git a/exca/steps/test_patterns.py b/exca/steps/test_patterns.py index dba4f034..f0abafe0 100644 --- a/exca/steps/test_patterns.py +++ b/exca/steps/test_patterns.py @@ -11,7 +11,7 @@ import pytest from . import base, conftest, items -from .patterns import Scatter +from .patterns import Fit, Scatter class MakeDict(base.Step): @@ -167,3 +167,104 @@ def test_branch_excludes(tmp_path: Path) -> None: out = list(scat.run_many([{"a": 1.0, "b": 2.0}, {"b": 2.0, "c": 3.0}])) assert out == [{"a": 10.0, "b": 20.0}, {"b": 20.0, "c": 30.0}] assert sorted(shared.calls) == [1.0, 2.0, 3.0], "shared branch b computed once" + + +class Mean(Fit, conftest.RecordingStep): + """Centers each item on the cohort mean -- ``.calls`` records each fit's size.""" + + def _fit(self, values: tp.Iterable[float]) -> float: + cohort = list(values) + self.record(len(cohort)) + return sum(cohort) / len(cohort) + + def _run(self, value: float) -> float: + return value - self.fitted + + +def test_fit_over_cohort(tmp_path: Path) -> None: + infra: tp.Any = {"backend": "Cached", "folder": tmp_path} + cohort = [0.0, 1.0, 2.0, 3.0] + up = conftest.Mult(coeff=10.0, infra=infra) # the fit sees its output, not the input + mean = Mean(infra=infra) + chain = base.Chain(steps=[up, mean]) + assert list(chain.run_many(cohort)) == [-15.0, -5.0, 5.0, 15.0], ( + "mean of [0,10,20,30]" + ) + assert mean.calls == [4], "one fit, over the whole cohort" + assert chain.run(10.0) == 85.0, "item outside the cohort, transformed by the same fit" + assert sorted(up.calls) == [0.0, 1.0, 2.0, 3.0, 10.0], "fit shares upstream's cache" + fresh = Mean(infra=infra) + assert list(base.Chain(steps=[up, fresh]).run_many(cohort))[0] == -15.0 + assert fresh.calls == [], "same cohort -> artifact read back rather than refitted" + + down = conftest.Add(value=0.0, infra=infra) # shared by both cohorts below + + def first_centered(cohort: list[float]) -> float: + chain = base.Chain(steps=[up, Mean(infra=infra), down]) + return list(chain.run_many(cohort))[0] + + assert first_centered(cohort) == -15.0 + assert first_centered(cohort + [10.0]) == -32.0, "mean of [0,10,20,30,100], not -15" + + +@pytest.mark.parametrize("forced", ["up", "fit"]) # the forced step: upstream or the Fit +def test_force_refits(tmp_path: Path, forced: str) -> None: + def fit_sizes(**modes: str) -> list: + def infra(step: str) -> tp.Any: + mode = modes.get(step, "cached") + return {"backend": "Cached", "folder": tmp_path, "mode": mode} + + mean = Mean(infra=infra("fit")) + chain = base.Chain(steps=[conftest.Mult(infra=infra("up")), mean]) + assert list(chain.run_many([0.0, 2.0])) == [-2.0, 2.0], "mean of [0, 4]" + return mean.calls + + assert fit_sizes() == [2] + assert fit_sizes() == [], "artifact reused" + assert fit_sizes(**{forced: "force"}) == [2], "force refits, no stale artifact" + + +@pytest.mark.parametrize("backend", ["ThreadPool", "ProcessPool"]) +def test_pool_backends(tmp_path: Path, backend: str) -> None: + infra: tp.Any = {"backend": backend, "folder": tmp_path, "max_jobs": 4} + cohort, centered = [0.0, 2.0, 4.0, 6.0], [-3.0, -1.0, 1.0, 3.0] + own = Mean(infra=infra) + assert list(own.run_many(cohort)) == centered + assert own.calls == [4], "fitted once in the driver, not per worker split" + # an enclosing backend may hand the step a shard of the items, not the cohort + enclosed = Mean() # no infra: its own folder below stays free of the fit above + sub: tp.Any = {**infra, "folder": tmp_path / "enclosed"} + chain = base.Chain(steps=[enclosed], infra=sub) + with pytest.raises(RuntimeError, match="enclosing backend"): + list(chain.run_many(cohort)) + assert list(enclosed.run_many(cohort)) == centered, "fitted on the driver instead" + assert list(chain.run_many(cohort)) == centered, "the fit travels to the workers" + + +def test_artifact_cache_type(tmp_path: Path) -> None: + class Pickled(Mean): + ARTIFACT_CACHE_TYPE = "Pickle" + + infra: tp.Any = {"backend": "Cached", "folder": tmp_path} + assert list(Mean(infra=infra).run_many([0.0, 2.0])) == [-1.0, 1.0] + assert not list(tmp_path.rglob("*.pkl")), "the default pickles only what it must" + assert list(Pickled(infra=infra).run_many([0.0, 2.0])) == [-1.0, 1.0] + assert len(list(tmp_path.rglob("*.pkl"))) == 1, "ARTIFACT_CACHE_TYPE picked pickle" + + +def test_unfitted_fit_safeguards(tmp_path: Path) -> None: + infra: tp.Any = {"backend": "Cached", "folder": tmp_path} + cohort, centered = [0.0, 2.0], [-1.0, 1.0] + with pytest.raises(RuntimeError, match="is not fitted"): + Mean().fitted + with pytest.raises(RuntimeError, match=r"unfitted Mean at steps\.1"): + Mean.check_fitted(base.Chain(steps=[conftest.Mult(), Mean()])) + with pytest.raises(RuntimeError, match="allow_fit=False"): + Mean(infra=infra, allow_fit=False).run_many(cohort) + + fitted = Mean(infra=infra) + assert list(fitted.run_many(cohort)) == centered + Mean.check_fitted(fitted) + reader = Mean(infra=infra, allow_fit=False) + assert list(reader.run_many(cohort)) == centered, "allow_fit is not in the uid" + assert reader.calls == [], "allow_fit only gates fitting, not reading" diff --git a/pyproject.toml b/pyproject.toml index e3186df1..e72284f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ # optional features "pandas>=2.2.2", "torch>=2.0.1", + "scikit-learn>=1.3.0", "mne>=1.4.0", "pybv>=0.7.6", "nibabel>=5.1.0", @@ -115,7 +116,7 @@ addopts = ["--ignore=build", "--ignore=dist"] show_error_codes = true [[tool.mypy.overrides]] - module = ['pytest', 'setuptools', 'cloudpickle', 'mne', 'mne.*', 'nibabel', 'neuralset', 'pyarrow', 'pybv', 'excatest'] + module = ['pytest', 'setuptools', 'cloudpickle', 'mne', 'mne.*', 'nibabel', 'neuralset', 'pyarrow', 'pybv', 'excatest', 'sklearn', 'sklearn.*'] ignore_missing_imports = true [[tool.mypy.overrides]] # some packages we do not install