From dd4ae821e249bf2b41a414ad6dd240111bc423f3 Mon Sep 17 00:00:00 2001 From: micedre Date: Tue, 25 Nov 2025 06:52:16 +0000 Subject: [PATCH 1/5] docs: Add comprehensive documentation website - Sphinx setup with pydata-sphinx-theme - Complete API reference auto-generated from docstrings - Architecture documentation with diagrams - Getting started guide and tutorials --- .github/workflows/deploy-docs.yml | 70 ++ .gitignore | 5 +- README.md | 12 + docs/Makefile | 19 + docs/make.bat | 35 + docs/source/_static/custom.css | 148 ++++ docs/source/_static/logo-ttc-dark.svg | 16 + docs/source/_static/logo-ttc-light.svg | 16 + docs/source/api/components.rst | 272 +++++++ docs/source/api/configs.rst | 192 +++++ docs/source/api/dataset.rst | 267 +++++++ docs/source/api/index.rst | 63 ++ docs/source/api/model.rst | 199 +++++ docs/source/api/tokenizers.rst | 231 ++++++ docs/source/api/wrapper.rst | 59 ++ .../architecture/diagrams/NN.drawio.png | Bin 0 -> 88539 bytes .../architecture/diagrams/avg_concat.png | Bin 0 -> 80562 bytes .../architecture/diagrams/full_concat.png | Bin 0 -> 103819 bytes docs/source/architecture/index.md | 54 ++ docs/source/architecture/overview.md | 619 ++++++++++++++++ docs/source/conf.py | 137 ++++ docs/source/getting_started/index.md | 41 ++ docs/source/getting_started/installation.md | 155 ++++ docs/source/getting_started/quickstart.md | 289 ++++++++ docs/source/index.md | 202 +++++ docs/source/tutorials/basic_classification.md | 415 +++++++++++ docs/source/tutorials/explainability.md | 525 +++++++++++++ docs/source/tutorials/index.md | 267 +++++++ docs/source/tutorials/mixed_features.md | 450 ++++++++++++ .../tutorials/multiclass_classification.md | 459 ++++++++++++ .../tutorials/multilabel_classification.md | 642 ++++++++++++++++ pyproject.toml | 16 +- uv.lock | 688 ++++++++++++++++-- 33 files changed, 6497 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/deploy-docs.yml create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/_static/custom.css create mode 100644 docs/source/_static/logo-ttc-dark.svg create mode 100644 docs/source/_static/logo-ttc-light.svg create mode 100644 docs/source/api/components.rst create mode 100644 docs/source/api/configs.rst create mode 100644 docs/source/api/dataset.rst create mode 100644 docs/source/api/index.rst create mode 100644 docs/source/api/model.rst create mode 100644 docs/source/api/tokenizers.rst create mode 100644 docs/source/api/wrapper.rst create mode 100644 docs/source/architecture/diagrams/NN.drawio.png create mode 100644 docs/source/architecture/diagrams/avg_concat.png create mode 100644 docs/source/architecture/diagrams/full_concat.png create mode 100644 docs/source/architecture/index.md create mode 100644 docs/source/architecture/overview.md create mode 100644 docs/source/conf.py create mode 100644 docs/source/getting_started/index.md create mode 100644 docs/source/getting_started/installation.md create mode 100644 docs/source/getting_started/quickstart.md create mode 100644 docs/source/index.md create mode 100644 docs/source/tutorials/basic_classification.md create mode 100644 docs/source/tutorials/explainability.md create mode 100644 docs/source/tutorials/index.md create mode 100644 docs/source/tutorials/mixed_features.md create mode 100644 docs/source/tutorials/multiclass_classification.md create mode 100644 docs/source/tutorials/multilabel_classification.md diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..d12cda8 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,70 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + - docs-website + pull_request: + branches: + - main + workflow_dispatch: + +# Sets permissions for GitHub Pages deployment +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: | + uv sync --group docs + + - name: Install Pandoc (required for nbsphinx) + run: | + sudo apt-get update + sudo apt-get install -y pandoc + + - name: Build documentation + run: | + uv run sphinx-build -b html docs/source build/html + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: build/html + + deploy: + needs: build + if: github.event_name == 'push' && ( github.ref == 'refs/heads/main' || github.ref == 'refs/heads/docs-website' ) + runs-on: ubuntu-latest + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 31f2232..5853749 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,8 @@ instance/ # Sphinx documentation docs/_build/ +docs/build/ +docs/source/_autosummary/ # PyBuilder .pybuilder/ @@ -165,8 +167,7 @@ lightning_logs/ # Training data data/training_data.txt -# Docs -docs/ +# Other fastTextAttention.py *.pth diff --git a/README.md b/README.md index 6772d79..804b293 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # torchTextClassifiers +[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://inseeflab.github.io/torchTextClassifiers/) + A unified, extensible framework for text classification with categorical variables built on [PyTorch](https://pytorch.org/) and [PyTorch Lightning](https://lightning.ai/docs/pytorch/stable/). ## 🚀 Features @@ -30,6 +32,16 @@ uv sync pip install -e . ``` +## 📖 Documentation + +Full documentation is available at: **https://inseeflab.github.io/torchTextClassifiers/** + +The documentation includes: +- **Getting Started**: Installation and quick start guide +- **Architecture**: Understanding the 3-layer design +- **Tutorials**: Step-by-step guides for different use cases +- **API Reference**: Complete API documentation + ## 📝 Usage Checkout the [notebook](notebooks/example.ipynb) for a quick start. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..88b4154 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css new file mode 100644 index 0000000..7c43b9e --- /dev/null +++ b/docs/source/_static/custom.css @@ -0,0 +1,148 @@ +/* Custom styling for torchTextClassifiers documentation with pydata-sphinx-theme */ + +/* Improve code block styling */ +div.highlight { + border-radius: 6px; + border: 1px solid var(--pst-color-border); + margin: 1em 0; +} + +div.highlight pre { + padding: 12px; + overflow-x: auto; +} + +/* Better admonition styling */ +.admonition { + border-radius: 6px; + padding: 1rem; + margin: 1rem 0; +} + +.admonition-title { + font-weight: 600; + margin-bottom: 0.5rem; +} + +/* Improve table styling */ +table.docutils { + border-collapse: collapse; + width: 100%; + margin: 1em 0; +} + +table.docutils td, +table.docutils th { + border: 1px solid var(--pst-color-border); + padding: 0.5rem; +} + +table.docutils th { + background-color: var(--pst-color-surface); + font-weight: 600; +} + +/* Navigation improvements */ +.bd-sidebar { + font-size: 0.9rem; +} + +.bd-sidebar .nav-link { + padding: 0.25rem 0.5rem; +} + +/* Logo text styling */ +.navbar-brand { + font-weight: 600; + font-size: 1.25rem; +} + +/* Improve inline code styling */ +code.docutils.literal { + background-color: var(--pst-color-surface); + padding: 0.1em 0.4em; + border-radius: 3px; + font-size: 0.9em; +} + +/* Better spacing for content */ +.bd-content { + padding: 2rem; +} + +/* Improve heading spacing */ +.bd-content h1 { + margin-top: 0; + margin-bottom: 1.5rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--pst-color-border); +} + +.bd-content h2 { + margin-top: 2rem; + margin-bottom: 1rem; +} + +.bd-content h3 { + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +/* Cards and grids (from sphinx-design) */ +.sd-card { + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.3s ease; +} + +.sd-card:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +/* Improve footer spacing */ +footer.bd-footer { + margin-top: 3rem; + padding-top: 2rem; + border-top: 1px solid var(--pst-color-border); +} + +/* Better responsive images */ +img { + max-width: 100%; + height: auto; + border-radius: 4px; +} + +/* Improve API documentation layout */ +dl.py.class, +dl.py.function, +dl.py.method { + margin-bottom: 2rem; +} + +dt.sig { + background-color: var(--pst-color-surface); + padding: 0.5rem 1rem; + border-radius: 4px; + border-left: 3px solid var(--pst-color-primary); +} + +dd { + margin-left: 2rem; + margin-top: 0.5rem; +} + +/* Parameter list styling */ +dl.field-list { + margin-top: 1rem; +} + +dl.field-list dt { + font-weight: 600; + margin-bottom: 0.25rem; +} + +dl.field-list dd { + margin-left: 1.5rem; + margin-bottom: 0.5rem; +} diff --git a/docs/source/_static/logo-ttc-dark.svg b/docs/source/_static/logo-ttc-dark.svg new file mode 100644 index 0000000..90f761f --- /dev/null +++ b/docs/source/_static/logo-ttc-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/docs/source/_static/logo-ttc-light.svg b/docs/source/_static/logo-ttc-light.svg new file mode 100644 index 0000000..5c5f446 --- /dev/null +++ b/docs/source/_static/logo-ttc-light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/docs/source/api/components.rst b/docs/source/api/components.rst new file mode 100644 index 0000000..d42a90f --- /dev/null +++ b/docs/source/api/components.rst @@ -0,0 +1,272 @@ +Model Components +================ + +Modular torch.nn.Module components for building custom architectures. + +.. currentmodule:: torchTextClassifiers.model.components + +Text Embedding +-------------- + +TextEmbedder +~~~~~~~~~~~~ + +Embeds text tokens with optional self-attention. + +.. autoclass:: torchTextClassifiers.model.components.text_embedder.TextEmbedder + :members: + :undoc-members: + :show-inheritance: + +TextEmbedderConfig +~~~~~~~~~~~~~~~~~~ + +Configuration for TextEmbedder. + +.. autoclass:: torchTextClassifiers.model.components.text_embedder.TextEmbedderConfig + :members: + :undoc-members: + :show-inheritance: + +Example: + +.. code-block:: python + + from torchTextClassifiers.model.components import TextEmbedder, TextEmbedderConfig + + # Simple text embedder + config = TextEmbedderConfig( + vocab_size=5000, + embedding_dim=128, + attention_config=None + ) + embedder = TextEmbedder(config) + + # With self-attention + from torchTextClassifiers.model.components import AttentionConfig + + attention_config = AttentionConfig( + n_embd=128, + n_head=4, + n_layer=2, + dropout=0.1 + ) + config = TextEmbedderConfig( + vocab_size=5000, + embedding_dim=128, + attention_config=attention_config + ) + embedder = TextEmbedder(config) + +Categorical Features +-------------------- + +CategoricalVariableNet +~~~~~~~~~~~~~~~~~~~~~~ + +Handles categorical features alongside text. + +.. autoclass:: torchTextClassifiers.model.components.categorical_var_net.CategoricalVariableNet + :members: + :undoc-members: + :show-inheritance: + +CategoricalForwardType +~~~~~~~~~~~~~~~~~~~~~~ + +Enum for categorical feature combination strategies. + +.. autoclass:: torchTextClassifiers.model.components.categorical_var_net.CategoricalForwardType + :members: + :undoc-members: + :show-inheritance: + + .. attribute:: SUM_TO_TEXT + + Sum categorical embeddings, concatenate with text. + + .. attribute:: AVERAGE_AND_CONCAT + + Average categorical embeddings, concatenate with text. + + .. attribute:: CONCATENATE_ALL + + Concatenate all embeddings (text + each categorical). + +Example: + +.. code-block:: python + + from torchTextClassifiers.model.components import ( + CategoricalVariableNet, + CategoricalForwardType + ) + + # 3 categorical variables with different vocab sizes + cat_net = CategoricalVariableNet( + vocabulary_sizes=[10, 5, 20], + embedding_dims=[8, 4, 16], + forward_type=CategoricalForwardType.AVERAGE_AND_CONCAT + ) + + # Forward pass + cat_embeddings = cat_net(categorical_data) + +Classification Head +------------------- + +ClassificationHead +~~~~~~~~~~~~~~~~~~ + +Linear classification layer(s). + +.. autoclass:: torchTextClassifiers.model.components.classification_head.ClassificationHead + :members: + :undoc-members: + :show-inheritance: + +Example: + +.. code-block:: python + + from torchTextClassifiers.model.components import ClassificationHead + + # Simple linear classifier + head = ClassificationHead( + input_dim=128, + num_classes=5 + ) + + # Custom classifier with nested nn.Module + import torch.nn as nn + + custom_head_module = nn.Sequential( + nn.Linear(128, 64), + nn.ReLU(), + nn.Dropout(0.2), + nn.Linear(64, 5) + ) + + head = ClassificationHead(linear=custom_head_module) + +Attention Mechanism +------------------- + +AttentionConfig +~~~~~~~~~~~~~~~ + +Configuration for transformer-style self-attention. + +.. autoclass:: torchTextClassifiers.model.components.attention.AttentionConfig + :members: + :undoc-members: + :show-inheritance: + + .. rubric:: Attributes + + .. attribute:: n_embd + :type: int + + Embedding dimension. + + .. attribute:: n_head + :type: int + + Number of attention heads. + + .. attribute:: n_layer + :type: int + + Number of transformer blocks. + + .. attribute:: dropout + :type: float + + Dropout rate (default: 0.0). + + .. attribute:: bias + :type: bool + + Use bias in linear layers (default: False). + +Block +~~~~~ + +Single transformer block with self-attention + MLP. + +.. autoclass:: torchTextClassifiers.model.components.attention.Block + :members: + :undoc-members: + :show-inheritance: + +SelfAttentionLayer +~~~~~~~~~~~~~~~~~~ + +Multi-head self-attention layer. + +.. autoclass:: torchTextClassifiers.model.components.attention.SelfAttentionLayer + :members: + :undoc-members: + :show-inheritance: + +MLP +~~~ + +Feed-forward network. + +.. autoclass:: torchTextClassifiers.model.components.attention.MLP + :members: + :undoc-members: + :show-inheritance: + +Example: + +.. code-block:: python + + from torchTextClassifiers.model.components import AttentionConfig, Block + + # Configure attention + config = AttentionConfig( + n_embd=128, + n_head=4, + n_layer=3, + dropout=0.1 + ) + + # Create transformer block + block = Block(config) + + # Forward pass (requires rotary embeddings cos, sin) + output = block(embeddings, cos, sin) + +Composing Components +-------------------- + +Components can be composed to create custom architectures: + +.. code-block:: python + + import torch.nn as nn + from torchTextClassifiers.model.components import ( + TextEmbedder, CategoricalVariableNet, ClassificationHead + ) + + class CustomModel(nn.Module): + def __init__(self): + super().__init__() + self.text_embedder = TextEmbedder(text_config) + self.cat_net = CategoricalVariableNet(...) + self.head = ClassificationHead(...) + + def forward(self, input_ids, categorical_data): + text_features = self.text_embedder(input_ids) + cat_features = self.cat_net(categorical_data) + combined = torch.cat([text_features, cat_features], dim=1) + return self.head(combined) + +See Also +-------- + +* :doc:`model` - How components are used in models +* :doc:`../architecture/overview` - Architecture explanation +* :doc:`configs` - ModelConfig for component configuration diff --git a/docs/source/api/configs.rst b/docs/source/api/configs.rst new file mode 100644 index 0000000..0e8b5a4 --- /dev/null +++ b/docs/source/api/configs.rst @@ -0,0 +1,192 @@ +Configuration Classes +===================== + +Configuration dataclasses for model and training setup. + +.. currentmodule:: torchTextClassifiers.torchTextClassifiers + +ModelConfig +----------- + +Configuration for model architecture. + +.. autoclass:: ModelConfig + :members: + :undoc-members: + :show-inheritance: + + .. rubric:: Attributes + + .. attribute:: embedding_dim + :type: int + + Dimension of text embeddings. + + .. attribute:: categorical_vocabulary_sizes + :type: Optional[List[int]] + + Vocabulary sizes for categorical variables (optional). + + .. attribute:: categorical_embedding_dims + :type: Optional[Union[List[int], int]] + + Embedding dimensions for categorical variables (optional). + + .. attribute:: num_classes + :type: Optional[int] + + Number of output classes (optional, inferred from data if not provided). + + .. attribute:: attention_config + :type: Optional[AttentionConfig] + + Configuration for attention mechanism (optional). + +Example +~~~~~~~ + +.. code-block:: python + + from torchTextClassifiers import ModelConfig + from torchTextClassifiers.model.components import AttentionConfig + + # Simple configuration + config = ModelConfig( + embedding_dim=128, + num_classes=3 + ) + + # With categorical features + config = ModelConfig( + embedding_dim=128, + num_classes=5, + categorical_vocabulary_sizes=[10, 20, 5], # 3 categorical variables + categorical_embedding_dims=[8, 16, 4] # Their embedding dimensions + ) + + # With attention + attention_config = AttentionConfig( + n_embd=128, + n_head=4, + n_layer=2, + dropout=0.1 + ) + config = ModelConfig( + embedding_dim=128, + num_classes=2, + attention_config=attention_config + ) + +TrainingConfig +-------------- + +Configuration for training process. + +.. autoclass:: TrainingConfig + :members: + :undoc-members: + :show-inheritance: + + .. rubric:: Attributes + + .. attribute:: num_epochs + :type: int + + Number of training epochs. + + .. attribute:: batch_size + :type: int + + Batch size for training. + + .. attribute:: lr + :type: float + + Learning rate. + + .. attribute:: loss + :type: torch.nn.Module + + Loss function (default: CrossEntropyLoss). + + .. attribute:: optimizer + :type: Type[torch.optim.Optimizer] + + Optimizer class (default: Adam). + + .. attribute:: scheduler + :type: Optional[Type[torch.optim.lr_scheduler._LRScheduler]] + + Learning rate scheduler class (optional). + + .. attribute:: accelerator + :type: str + + Accelerator type: "auto", "cpu", "gpu", or "mps" (default: "auto"). + + .. attribute:: num_workers + :type: int + + Number of data loading workers (default: 12). + + .. attribute:: patience_early_stopping + :type: int + + Early stopping patience in epochs (default: 3). + + .. attribute:: dataloader_params + :type: Optional[dict] + + Additional DataLoader parameters (optional). + + .. attribute:: trainer_params + :type: Optional[dict] + + Additional PyTorch Lightning Trainer parameters (optional). + + .. attribute:: optimizer_params + :type: Optional[dict] + + Additional optimizer parameters (optional). + + .. attribute:: scheduler_params + :type: Optional[dict] + + Additional scheduler parameters (optional). + +Example +~~~~~~~ + +.. code-block:: python + + from torchTextClassifiers import TrainingConfig + import torch.nn as nn + import torch.optim as optim + + # Basic configuration + config = TrainingConfig( + num_epochs=20, + batch_size=32, + lr=1e-3 + ) + + # Advanced configuration + config = TrainingConfig( + num_epochs=50, + batch_size=64, + lr=5e-4, + loss=nn.CrossEntropyLoss(weight=torch.tensor([1.0, 2.0, 1.5])), + optimizer=optim.AdamW, + scheduler=optim.lr_scheduler.CosineAnnealingLR, + accelerator="gpu", + patience_early_stopping=10, + optimizer_params={"weight_decay": 0.01}, + scheduler_params={"T_max": 50} + ) + +See Also +-------- + +* :doc:`wrapper` - Using configurations with the wrapper +* :doc:`components` - AttentionConfig for attention mechanism +* :doc:`model` - How configurations affect the model diff --git a/docs/source/api/dataset.rst b/docs/source/api/dataset.rst new file mode 100644 index 0000000..32fdecc --- /dev/null +++ b/docs/source/api/dataset.rst @@ -0,0 +1,267 @@ +Dataset +======= + +PyTorch Dataset classes for data loading. + +.. currentmodule:: torchTextClassifiers.dataset + +TextClassificationDataset +------------------------- + +PyTorch Dataset for text classification with optional categorical features. + +.. autoclass:: torchTextClassifiers.dataset.dataset.TextClassificationDataset + :members: + :undoc-members: + :show-inheritance: + + **Features:** + + - Support for text data + - Optional categorical variables + - Optional labels (for inference) + - Multilabel support with ragged arrays + - Integration with tokenizers + +Parameters +---------- + +.. class:: TextClassificationDataset(X_text, y, tokenizer, X_categorical=None) + + :param X_text: Text samples (list or array of strings) + :type X_text: Union[List[str], np.ndarray] + + :param y: Labels (optional for inference) + :type y: Optional[Union[List[int], np.ndarray]] + + :param tokenizer: Tokenizer instance + :type tokenizer: BaseTokenizer + + :param X_categorical: Categorical features (optional) + :type X_categorical: Optional[np.ndarray] + +Example Usage +------------- + +Basic Text Dataset +~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from torchTextClassifiers.dataset import TextClassificationDataset + from torchTextClassifiers.tokenizers import WordPieceTokenizer + import numpy as np + + # Prepare data + texts = ["Text sample 1", "Text sample 2", "Text sample 3"] + labels = [0, 1, 0] + + # Create tokenizer + tokenizer = WordPieceTokenizer() + tokenizer.train(texts, vocab_size=1000) + + # Create dataset + dataset = TextClassificationDataset( + X_text=texts, + y=labels, + tokenizer=tokenizer + ) + + # Use with DataLoader + from torch.utils.data import DataLoader + + dataloader = DataLoader(dataset, batch_size=2, shuffle=True) + + for batch in dataloader: + input_ids, labels_batch = batch + # Train model... + +Mixed Features Dataset +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import numpy as np + + # Text data + texts = ["Sample 1", "Sample 2", "Sample 3"] + labels = [0, 1, 2] + + # Categorical data (3 samples, 2 categorical variables) + categorical = np.array([ + [5, 2], # Sample 1: cat1=5, cat2=2 + [3, 1], # Sample 2: cat1=3, cat2=1 + [7, 0], # Sample 3: cat1=7, cat2=0 + ]) + + # Create dataset + dataset = TextClassificationDataset( + X_text=texts, + y=labels, + tokenizer=tokenizer, + X_categorical=categorical + ) + + # Batch returns: (input_ids, categorical_features, labels) + for batch in dataloader: + input_ids, cat_features, labels_batch = batch + # Train model with mixed features... + +Inference Dataset +~~~~~~~~~~~~~~~~~ + +For inference without labels: + +.. code-block:: python + + # Create dataset without labels + inference_dataset = TextClassificationDataset( + X_text=test_texts, + y=None, # No labels for inference + tokenizer=tokenizer + ) + + # Batch returns only features (no labels) + for batch in dataloader: + input_ids = batch + # Make predictions... + +Multilabel Dataset +~~~~~~~~~~~~~~~~~~ + +For multilabel classification: + +.. code-block:: python + + # Multilabel targets (ragged arrays supported) + texts = ["Sample 1", "Sample 2", "Sample 3"] + labels = [ + [0, 1], # Sample 1 has labels 0 and 1 + [2], # Sample 2 has only label 2 + [0, 1, 2], # Sample 3 has all three labels + ] + + # Create dataset + dataset = TextClassificationDataset( + X_text=texts, + y=labels, + tokenizer=tokenizer + ) + + # Dataset handles ragged label arrays automatically + +DataLoader Integration +---------------------- + +The dataset integrates seamlessly with PyTorch DataLoader: + +.. code-block:: python + + from torch.utils.data import DataLoader + + # Create dataset + dataset = TextClassificationDataset(X_text, y, tokenizer) + + # Create dataloader + dataloader = DataLoader( + dataset, + batch_size=32, + shuffle=True, + num_workers=4, + pin_memory=True # For GPU training + ) + + # Iterate + for batch_idx, batch in enumerate(dataloader): + # Process batch... + pass + +Batch Format +------------ + +The dataset returns different batch formats depending on configuration: + +**Text only:** + +.. code-block:: python + + input_ids = batch + # Shape: (batch_size, seq_len) + +**Text + labels:** + +.. code-block:: python + + input_ids, labels = batch + # input_ids shape: (batch_size, seq_len) + # labels shape: (batch_size,) + +**Text + categorical + labels:** + +.. code-block:: python + + input_ids, categorical_features, labels = batch + # input_ids shape: (batch_size, seq_len) + # categorical_features shape: (batch_size, num_categorical_vars) + # labels shape: (batch_size,) + +Custom Collation +---------------- + +For advanced use cases, you can provide a custom collate function: + +.. code-block:: python + + def custom_collate_fn(batch): + # Custom batching logic + ... + return custom_batch + + dataloader = DataLoader( + dataset, + batch_size=32, + collate_fn=custom_collate_fn + ) + +Memory Considerations +--------------------- + +For large datasets: + +**1. Use generators:** + +.. code-block:: python + + def text_generator(): + for text in large_text_file: + yield text.strip() + + X_text = list(text_generator()) + +**2. Increase num_workers:** + +.. code-block:: python + + dataloader = DataLoader( + dataset, + batch_size=32, + num_workers=8 # Parallel data loading + ) + +**3. Pin memory for GPU:** + +.. code-block:: python + + dataloader = DataLoader( + dataset, + batch_size=32, + pin_memory=True # Faster GPU transfer + ) + +See Also +-------- + +* :doc:`tokenizers` - Tokenizer options +* :doc:`model` - Using datasets with models +* :doc:`wrapper` - High-level API handling datasets automatically +* :doc:`../tutorials/basic_classification` - Dataset usage examples diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst new file mode 100644 index 0000000..5a7b468 --- /dev/null +++ b/docs/source/api/index.rst @@ -0,0 +1,63 @@ +API Reference +============= + +Complete API documentation for torchTextClassifiers, auto-generated from source code docstrings. + +Overview +-------- + +The API is organized into several modules: + +* :doc:`wrapper` - High-level torchTextClassifiers wrapper class +* :doc:`configs` - Configuration classes (ModelConfig, TrainingConfig) +* :doc:`tokenizers` - Text tokenization (NGram, WordPiece, HuggingFace) +* :doc:`components` - Model components (TextEmbedder, CategoricalVariableNet, etc.) +* :doc:`model` - Core PyTorch models +* :doc:`dataset` - Dataset classes for data loading + +Quick Links +----------- + +Most Used Classes +~~~~~~~~~~~~~~~~~ + +* :class:`torchTextClassifiers.torchTextClassifiers.torchTextClassifiers` - Main wrapper class +* :class:`torchTextClassifiers.torchTextClassifiers.ModelConfig` - Model configuration +* :class:`torchTextClassifiers.torchTextClassifiers.TrainingConfig` - Training configuration +* :class:`torchTextClassifiers.tokenizers.WordPieceTokenizer` - WordPiece tokenizer +* :class:`torchTextClassifiers.tokenizers.NGramTokenizer` - N-gram tokenizer + +Architecture Components +~~~~~~~~~~~~~~~~~~~~~~~ + +* :class:`torchTextClassifiers.model.components.TextEmbedder` - Text embedding layer +* :class:`torchTextClassifiers.model.components.CategoricalVariableNet` - Categorical features +* :class:`torchTextClassifiers.model.components.ClassificationHead` - Classification layer +* :class:`torchTextClassifiers.model.components.Attention.AttentionConfig` - Attention configuration + +Core Models +~~~~~~~~~~~ + +* :class:`torchTextClassifiers.model.model.TextClassificationModel` - Core PyTorch model +* :class:`torchTextClassifiers.model.lightning.TextClassificationModule` - PyTorch Lightning module +* :class:`torchTextClassifiers.dataset.dataset.TextClassificationDataset` - PyTorch Dataset + +Module Documentation +-------------------- + +.. toctree:: + :maxdepth: 2 + + wrapper + configs + tokenizers + components + model + dataset + +Indices +------- + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/api/model.rst b/docs/source/api/model.rst new file mode 100644 index 0000000..e31eac0 --- /dev/null +++ b/docs/source/api/model.rst @@ -0,0 +1,199 @@ +Core Models +=========== + +Core PyTorch and PyTorch Lightning models. + +.. currentmodule:: torchTextClassifiers.model + +PyTorch Model +------------- + +TextClassificationModel +~~~~~~~~~~~~~~~~~~~~~~~ + +Core PyTorch nn.Module combining all components. + +.. autoclass:: torchTextClassifiers.model.model.TextClassificationModel + :members: + :undoc-members: + :show-inheritance: + + **Architecture:** + + The model combines three main components: + + 1. **TextEmbedder**: Converts tokens to embeddings + 2. **CategoricalVariableNet** (optional): Handles categorical features + 3. **ClassificationHead**: Produces class logits + +Example: + +.. code-block:: python + + from torchTextClassifiers.model import TextClassificationModel + from torchTextClassifiers.model.components import ( + TextEmbedder, TextEmbedderConfig, + CategoricalVariableNet, CategoricalForwardType, + ClassificationHead + ) + + # Create components + text_embedder = TextEmbedder(TextEmbedderConfig( + vocab_size=5000, + embedding_dim=128 + )) + + cat_net = CategoricalVariableNet( + vocabulary_sizes=[10, 20], + embedding_dims=[8, 16], + forward_type=CategoricalForwardType.AVERAGE_AND_CONCAT + ) + + classification_head = ClassificationHead( + input_dim=128 + 24, # text_dim + cat_dim + num_classes=5 + ) + + # Combine into model + model = TextClassificationModel( + text_embedder=text_embedder, + categorical_variable_net=cat_net, + classification_head=classification_head + ) + + # Forward pass + logits = model(input_ids, categorical_data) + +PyTorch Lightning Module +------------------------- + +TextClassificationModule +~~~~~~~~~~~~~~~~~~~~~~~~ + +PyTorch Lightning LightningModule for automated training. + +.. autoclass:: torchTextClassifiers.model.lightning.TextClassificationModule + :members: + :undoc-members: + :show-inheritance: + + **Features:** + + - Automated training/validation/test steps + - Metrics tracking (accuracy) + - Optimizer and scheduler management + - Logging integration + - PyTorch Lightning callbacks support + +Example: + +.. code-block:: python + + from torchTextClassifiers.model import ( + TextClassificationModel, + TextClassificationModule + ) + import torch.nn as nn + import torch.optim as optim + from pytorch_lightning import Trainer + + # Create PyTorch model + model = TextClassificationModel(...) + + # Wrap in Lightning module + lightning_module = TextClassificationModule( + model=model, + loss=nn.CrossEntropyLoss(), + optimizer=optim.Adam, + lr=1e-3, + scheduler=optim.lr_scheduler.StepLR, + scheduler_params={"step_size": 10, "gamma": 0.1} + ) + + # Train with Lightning Trainer + trainer = Trainer( + max_epochs=20, + accelerator="auto", + devices=1 + ) + + trainer.fit( + lightning_module, + train_dataloaders=train_dataloader, + val_dataloaders=val_dataloader + ) + + # Test + trainer.test(lightning_module, dataloaders=test_dataloader) + +Training Steps +-------------- + +The TextClassificationModule implements standard training/validation/test steps: + +**Training Step:** + +.. code-block:: python + + def training_step(self, batch, batch_idx): + input_ids, cat_features, labels = batch + logits = self.model(input_ids, cat_features) + loss = self.loss(logits, labels) + acc = self.compute_accuracy(logits, labels) + + self.log("train_loss", loss) + self.log("train_acc", acc) + + return loss + +**Validation Step:** + +.. code-block:: python + + def validation_step(self, batch, batch_idx): + input_ids, cat_features, labels = batch + logits = self.model(input_ids, cat_features) + loss = self.loss(logits, labels) + acc = self.compute_accuracy(logits, labels) + + self.log("val_loss", loss) + self.log("val_acc", acc) + +Custom Training +--------------- + +For custom training loops, use the PyTorch model directly: + +.. code-block:: python + + from torchTextClassifiers.model import TextClassificationModel + import torch.nn as nn + import torch.optim as optim + + model = TextClassificationModel(...) + loss_fn = nn.CrossEntropyLoss() + optimizer = optim.Adam(model.parameters(), lr=1e-3) + + # Custom training loop + for epoch in range(num_epochs): + for batch in dataloader: + input_ids, cat_features, labels = batch + + # Forward pass + logits = model(input_ids, cat_features) + loss = loss_fn(logits, labels) + + # Backward pass + optimizer.zero_grad() + loss.backward() + optimizer.step() + + print(f"Epoch {epoch}, Loss: {loss.item()}") + +See Also +-------- + +* :doc:`components` - Model components +* :doc:`wrapper` - High-level wrapper using these models +* :doc:`dataset` - Data loading for models +* :doc:`configs` - Model and training configuration diff --git a/docs/source/api/tokenizers.rst b/docs/source/api/tokenizers.rst new file mode 100644 index 0000000..a871fec --- /dev/null +++ b/docs/source/api/tokenizers.rst @@ -0,0 +1,231 @@ +Tokenizers +========== + +Text tokenization classes for converting text to numerical tokens. + +.. currentmodule:: torchTextClassifiers.tokenizers + +Base Classes +------------ + +BaseTokenizer +~~~~~~~~~~~~~ + +Abstract base class for all tokenizers. + +.. autoclass:: torchTextClassifiers.tokenizers.base.BaseTokenizer + :members: + :undoc-members: + :show-inheritance: + +TokenizerOutput +~~~~~~~~~~~~~~~ + +Output dataclass from tokenization. + +.. autoclass:: torchTextClassifiers.tokenizers.base.TokenizerOutput + :members: + :undoc-members: + :show-inheritance: + + .. rubric:: Attributes + + .. attribute:: input_ids + :type: torch.Tensor + + Token indices (batch_size, seq_len). + + .. attribute:: attention_mask + :type: torch.Tensor + + Attention mask tensor (batch_size, seq_len). + + .. attribute:: offset_mapping + :type: Optional[List[List[Tuple[int, int]]]] + + Byte offsets for each token (optional, for explainability). + + .. attribute:: word_ids + :type: Optional[List[List[Optional[int]]]] + + Word-level indices for each token (optional). + +Concrete Tokenizers +------------------- + +NGramTokenizer +~~~~~~~~~~~~~~ + +FastText-style character n-gram tokenizer. + +.. autoclass:: torchTextClassifiers.tokenizers.ngram.NGramTokenizer + :members: + :undoc-members: + :show-inheritance: + + **Features:** + + - Character n-gram generation (customizable min/max n) + - Subword caching for performance + - Text cleaning and normalization (FastText style) + - Hash-based tokenization + - Support for special tokens, padding, truncation + +Example: + +.. code-block:: python + + from torchTextClassifiers.tokenizers import NGramTokenizer + + # Create tokenizer + tokenizer = NGramTokenizer( + vocab_size=10000, + min_n=3, # Minimum n-gram size + max_n=6, # Maximum n-gram size + output_dim=128 + ) + + # Train on corpus + tokenizer.train(training_texts) + + # Tokenize + output = tokenizer(["Hello world!", "Text classification"]) + +WordPieceTokenizer +~~~~~~~~~~~~~~~~~~ + +WordPiece subword tokenization. + +.. autoclass:: torchTextClassifiers.tokenizers.WordPiece.WordPieceTokenizer + :members: + :undoc-members: + :show-inheritance: + + **Features:** + + - Subword tokenization strategy + - Vocabulary learning from corpus + - Handles unknown words gracefully + - Efficient encoding/decoding + +Example: + +.. code-block:: python + + from torchTextClassifiers.tokenizers import WordPieceTokenizer + + # Create tokenizer + tokenizer = WordPieceTokenizer( + vocab_size=5000, + output_dim=128 + ) + + # Train on corpus + tokenizer.train(training_texts) + + # Tokenize + output = tokenizer(["Hello world!", "Text classification"]) + +HuggingFaceTokenizer +~~~~~~~~~~~~~~~~~~~~ + +Wrapper for HuggingFace tokenizers. + +.. autoclass:: torchTextClassifiers.tokenizers.base.HuggingFaceTokenizer + :members: + :undoc-members: + :show-inheritance: + + **Features:** + + - Access to HuggingFace pre-trained tokenizers + - Compatible with transformer models + - Support for special tokens + +Example: + +.. code-block:: python + + from torchTextClassifiers.tokenizers import HuggingFaceTokenizer + from transformers import AutoTokenizer + + # Load pre-trained tokenizer + hf_tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased") + + # Wrap in our interface + tokenizer = HuggingFaceTokenizer( + tokenizer=hf_tokenizer, + output_dim=128 + ) + + # Tokenize + output = tokenizer(["Hello world!", "Text classification"]) + +Choosing a Tokenizer +--------------------- + +**NGramTokenizer (FastText-style)** + +Use when: + +* You want character-level features +* Your text has many misspellings or variations +* You need fast training +* You have limited vocabulary + +**WordPieceTokenizer** + +Use when: + +* You want subword-level features +* Your vocabulary is large but manageable +* You need good coverage with reasonable vocab size +* You're doing standard text classification + +**HuggingFaceTokenizer** + +Use when: + +* You want to use pre-trained tokenizers +* You're working with transformer models +* You need specific language support +* You want to fine-tune on top of BERT/RoBERTa/etc. + +Tokenizer Comparison +-------------------- + +.. list-table:: + :widths: 25 25 25 25 + :header-rows: 1 + + * - Feature + - NGramTokenizer + - WordPieceTokenizer + - HuggingFaceTokenizer + * - Granularity + - Character n-grams + - Subwords + - Subwords/Words + * - Training Speed + - Fast + - Medium + - Pre-trained + * - Vocab Size + - Configurable + - Configurable + - Pre-defined + * - OOV Handling + - Excellent (char-level) + - Good (subwords) + - Good (subwords) + * - Memory + - Efficient + - Medium + - Larger + +See Also +-------- + +* :doc:`wrapper` - Using tokenizers with the wrapper +* :doc:`dataset` - How tokenizers are used in datasets +* :doc:`../tutorials/basic_classification` - Tokenizer tutorial diff --git a/docs/source/api/wrapper.rst b/docs/source/api/wrapper.rst new file mode 100644 index 0000000..1290cf7 --- /dev/null +++ b/docs/source/api/wrapper.rst @@ -0,0 +1,59 @@ +torchTextClassifiers Wrapper +============================= + +The main wrapper class for text classification tasks. + +.. currentmodule:: torchTextClassifiers.torchTextClassifiers + +Main Class +---------- + +.. autoclass:: torchTextClassifiers + :members: + :undoc-members: + :show-inheritance: + :inherited-members: + :special-members: __init__ + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~torchTextClassifiers.train + ~torchTextClassifiers.predict + ~torchTextClassifiers.predict_proba + ~torchTextClassifiers.get_explanations + ~torchTextClassifiers.save + ~torchTextClassifiers.load + +Usage Example +------------- + +.. code-block:: python + + from torchTextClassifiers import torchTextClassifiers, ModelConfig, TrainingConfig + from torchTextClassifiers.tokenizers import WordPieceTokenizer + + # Create tokenizer + tokenizer = WordPieceTokenizer() + tokenizer.train(texts, vocab_size=1000) + + # Configure model + model_config = ModelConfig(embedding_dim=64, num_classes=2) + training_config = TrainingConfig(num_epochs=10, batch_size=16, lr=1e-3) + + # Create and train classifier + classifier = torchTextClassifiers(tokenizer=tokenizer, model_config=model_config) + classifier.train(X_text=texts, y=labels, training_config=training_config) + + # Make predictions + predictions = classifier.predict(new_texts) + probabilities = classifier.predict_proba(new_texts) + +See Also +-------- + +* :doc:`configs` - Configuration classes +* :doc:`tokenizers` - Tokenizer options +* :doc:`model` - Underlying PyTorch models diff --git a/docs/source/architecture/diagrams/NN.drawio.png b/docs/source/architecture/diagrams/NN.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..ed86a7d014ee05a2f44c17f0df503c17466f76af GIT binary patch literal 88539 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 literal 0 HcmV?d00001 diff --git a/docs/source/architecture/diagrams/avg_concat.png b/docs/source/architecture/diagrams/avg_concat.png new file mode 100644 index 0000000000000000000000000000000000000000..ecea862cd654037ec53f2dfd1a6af7944a5f5abe GIT binary patch literal 80562 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 z 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

A%qE6j<+$J5Ivs-`}9C@VyDiM93yr=HZ2kFj!C zzhE^HE>njw=qpi$@>~}vvj?m1(1;J*ybr=~_ig0Ba2Asm017J}Y~cN6@;rLbIWT!R zc(r_}J)|gVQ2KDr^TD%KMGZn|>FVWdE?f`?E$w18y=K_0t8zO_Fq$)_zI-9aGr~-_Yor>5p zQ~>V_@#l>97MVT0vN_$RQ>n8qHM%Dcc7D=ZRN_y7h980g4Fp#riTG6d7_@CQ9osi+ zIi-UC%@x65Rx^a?6C4m`+2K4_7m*OC(S*#ET)Usa#+T~qN)7AF$_992dHO(D!#>+& z{EZ{Y$2^7lU+k%ll3wcsGqyXl3$jS6t1vmK$n%2-@1zgNoVevJ+=0E0C@cs^bx@D& z0_j zBe#FDVE(3s2xMmZP9_5A=j8KbqB*P>hETu69J1nhS$KLXp!duK)zmFS48c3SI}T?( z1ptuZk5Y^ckYEySp6QrkB-9W9v&BqX$#o#^RDnb52A4sNQYiO}+&MQ{JntG%o4rNIdF3S`&M6DXnRrm?2`|PJ)C6pFmBAHhL#0B$xrcl)u2jp5}hRrL_hC&1v=xW^gnUJ0IAnCSBh?52%1r zkrCjdMKHmuSF<753_ly7u%*Yjb{VIb(u z0N@XPe7J5rq%rC8RU% z7bV>I@ZDAbMziJB+zv8Ij=lHgVNE-P3HP)kcG9>X3Wb`O=3{Ok*Z@`gT<)q*E2>Fh zgIK9yTch(V+0jUP7iie6#G%!pI%J=<=zI7o@8Lh*0sA!mDNKE+xl zZ9aB!acMeqNhKbPDS_$C*$c3q&w zTCmUEt-NJW)K|hLQOYA~vD^q04V=x2UjS%<%H=!6FYlYfq_c7Qp2JVNQ%bHv8CKzY z?m3D6o{QFS!&#;ZS_aQv@f~+jqST$)iNZY*!=g|%xZe4rhfRC=W5{IRcN?@JMI?}> z;0VrMjL#P!m^Fs;oP{_e#>XC+28fXgN_E5_ws(h}VN3xUt-*cH-0`D+phz1V;i);& zN>xfBswfv0@XNYL)D^PdT5g(SEHkT1dF%!KQz$LTK2LSlJFk1$X6Avh_>SkW!AopE zG#Wu`SPFw#Dkw82U5-Dg1fYCb*Bne4ImPy&hPAm2jH}NAds) z3+~K(QgM}q3+w3sjm9bS{rq1T zck?F-D$suj?_47*tL;7l+prF1rW+7q2B`PAavAH+faze$UAA%Gu%j=kSmc%|jF z5SGyD-5>#(`$6&qm0Cm8gPsEv6!5&fEu{VQF>>)t4}Nn6D5Oz=1+0&9V`0~D1LNW4 z1ELB*erY2GQ&MDX$8)eY)ODY80;0|d|5TC*)C#=2p@VdY0R9Ca^y<>la7D|8d=N7M zm}LqWyV=#BomRAx41;tnp+y&!Z76FX0C?az`rbU%L2b>-S}s+nn;6h(I%)|9VH0f^ z8 z$tA+?_utyxTeva5&^7ZWlsicM*zQxP5xWkmqmq7GFpsuT`T>YdL8@v<1G}LlmkQv& z4hcB2x>AbbL5r+}AUr6pdSpTVAO%9R{C)M%NIA&YGd|Kc7BmFz9pxpir@}_h;y}Yq zL$_^dSFp8erhanXnF_ac{HqA@ZFJ++Gzi?H+wFUDhPogur2*|bZ)4#_wC-2R^Ge$% zhJx_isCnQB7c?#=g#d4cLwIng2gpZ47@r%7lg|UkTp`>+JD-!%maF>pvX{Bc6Zqn^h1j3#?)`_ znoq9>O|%*6=Jy_NZM;14F+(VPQ>0sn!e~zhASE?$g-Bq5Mp_*Eg8xZ+-qU>ob9A_DcB7=_2) zlLu|MM?$V6fOL!go-|w-dl-v9ksTL4`hD4>_xF*pI(HDs7RtlG()`~`64M9n! z;Pyh*iC>}POFkq@tp8pd%L)x8`h(T+V{27Bb+kP%$OIly3aSH~p$Zo3a^kL5o+~`u zgfUOr7cmrs0E^loBCg8N?7O^rso@5xV?r+~T{NI2NGU>cnMwVfSZu8P;{uW4mvcKI zyJJ&L*_6*BjkWCPGlZl&-DS*n41%2*kO1lENf=NcDGuIsO-dSbnE-oyysS5U;3!z9 z<2L>YDGLfO(p`^DyK&+UBzEA7I|oL!muxC0aF-it(^NUd&A3Q-{7*Nf$#} zRuAvdDGyL%R%^VeL1pB#dZ1hBdj_n~+bA0vswIr3NErht&VlL78_*3!*nsvAHL92? zU^QY6G>7duQ;&EY+DDK{eR2^Qc|*IzH?0S%`s2M2mRydhR&fR{P+EYuP=M0=+ZwKe zj}L-(9|z63B!l4f10hrRN8=;!LUM}jkhJN3Ae0Or+brW>l_sKW^A>FK&Co3u9`vXT z+^&OQc~S(AUtWg%Fg!$HapD{dXB^LBLxq9HN@TqZs6kBX+71S&)aWYVW8{1g6P=gHYZZ{c7MK01YAbWj z8ZTFWg6X5bc@AM89*a0t5--=Wx9 zw+H)7f*z^~K91HepF_X5!8J|C*fW=nJrYYFgF%hVxh*)n_1Ahk5V_gHlTt>-_<(ah zj~-5fSiDZie%)N>kZ8$>jSSN2;1gc1!7y@kvn(8>!XtcJPdPP>9%Y)P$n3wvu8(j;+K-Jz;f1X#!+>4d?+q(ZCej zkB#~YA6Z&&@#2f%P5V6=Z0H283wsKPMgQE6*r%2p|{IL2PoucbpWMbR8_AC+7 zMW6*eNQ3|gMGX9VXfd2r2-UhEYlHRlyDlzr!)0jzKuwg>Iw@R|VF+V?UpVGpWO z`l{i$vM)VBeLeuK^IV{sn!7lyz5w;*_%=0g8Odf1yq)lT6;!-;1eJgv9jHQvO6MiKy9=Z^D`czCzF1~0DurFW zWhj^1D2_?l!`#w52``n_YEqXHc_x5nJ3v%(B$CF}l#T!Zx)di@wkgT{mKEidPprz^ ziN=R7wcj&bAABZ1GQG1U_5~ML;w@$bW`I6X1B7>SnFQ89QMIe*)uGiJ*2s?zsfH|K z7KqFR-;JKRpjB-*m!T`7be+wz7an}jrEm(jYZU`i2kYpuiu`q#&X!{RR|% z_0UkVW~i@H*JAvIyjVniUwhxseO~84O!eK8w2<^O+EMRQo^YC6C#;-rbRMm@@TlR` z_Js`*om~4(R(7VcaU|3|d|?H`*GYlMyuIMPoh3s009g~9ru@0dvz59g<5%(fDx=H+ z#OKP;z@B;W?mqNdA!wGe>%-lL&;qc|lw%Ci{fQ$UZ#~4+r79l9sMWUjy_Yl;dIo;b zY2eHUS=tn;TT=V6A?vy#&M9HT)|m#$oH5=X*MP@%fsRBUxDBhucgDl^;UQb)z3K-N zkK98E4|)KJm+yljq?kHcI80T1iYl+eiu2c3lWJGhse(Z~Uz^l9DbXT?VPYZrc@-#k zoj3^XB@$OJ;k|r;ZF(dvZX7P($s+}Ba$+|R%F!S0#~M9J5yhZG*Zv+(M;HyAXNmi4|_>R*uCnAB=6<=_0=5k^7PQ)8b}FGsY3ON zN*SnGz+|+ZD67166zdm0IwiE+u^C@dHu0!;9yju`Db`F^tF3_gj!J5XFQs=`F%Im1 zvk{YPp#u}c^IPWB#tP6sZ2}KBIoYCF6jT^yz7Vr|QI8=3c1Fy>a+d`0mF`pqv{AZO z%%7qd9ynB00{Wq&{Vj?PF$&I9^R=kVm(;jilpVXcUZfd4V=MoyM+S9!I<;BF-J3(G zmxyA}S3!n4eLU#FKI!MDw|d&XI*@;z1H~K zvLpYRao1`$V3m8w{Uty69lrvxxB z3q22BjnNoIkGw(>fc1^HO8MB7E$^Q6Mw(lZZIU6;g6}0vtWb-uZH zLMU=cptVziQS5oKv~(=15~UvWfb=$;eeeK;iz-k-advC68?R2J!7B>HcI=b(da|VE zjx=pFk|m;NyX9`NhLm=(fHwY|8rElBmm>7jOz|OOrKY?>V}wvzUOe7+xW9)Mo3_oq z8_1&*`CXUG^Tr-75$S*W4s?kheMHVYESuT`y`STDdpez;un38yjYdk*V&uJqd-_*@ ze7ZGc_+7pW!`KN&U z?cMP#2bz?;>)^pROZXqkDOUN=WGq(`>9YWaeBBYLH4`@;e3UaY-jkWf&aYHd51jdZ z;C-(cdql;nr9Eq)z{?Lvz8C12V)-RdXEnhm=|Bt-)4#)EU|A()Jypw29X`qy{%2vu~D$>AZ7EvyHq$tTsqN$RMtYqY}GA<=6 zrIfvA5egZ}-rSFu-k;C+`@4_dKlk6a^x=&+~P@UeD)aJ*(5q)H6X4%dytD zODE6jETkO!Hy1*2eROI4Bj4Wd&5SHGqSOoE;w{! z+0H*;jW!ejSYpsH(1?-FRq5%=>ZYn^%~aiAg?hki@M$ia#g&?{K5;{I zSQQkMy&}_W@xvNXX7!`emS5rosb}lTj>gzJ?KX^`!v8uy5F6A1cUn3~qPg>P4}Yup z#5D0(>W%N7lTl(wAl6xK71t+SeLS?I^ZHnN#TsE9a@^jDRg+D`pw zIt{tXaJUxrC5z36KDyluBnxu&HO>|xskVdKt7-HLHD$^GSqv1yM2D+gTH6J*Yo%v+ zyj`LJI1QO^l#8C_sX6i*n^cZmEzu_l#9)(dKCKwmGnthQ_c*~^W&_bT^UPh>l%Y7^ z>Pl7mwt9GC8ldyUg43-g%U0NTuZsA|mX_wGzv0?9Rxa`UT;J}!PuSj6*)7ZBkC^vg zqa2frICq*F7!*()8MW|)=Oe?1FInE%svc!rrq++6{BRtPy3NHD%yQ`P`M2!UqqjJ{ z>0RZKUEt5U>Nv0M#EJDmR+S>~L*t{;y1`$j4or3|gB-zyz~fuT5}q=23wp3eCzDPq zj(KF-p6Bg1LdCC6c+9QzUTC9+ZJgkKRa0UY(oc?H9Ac!$%x1G(;^e>M;zA6fGn~7- zOMc2B-_7>dI6j zGsls-z;keO^o!T-TPHB)=8yk~lPV#=eF+JF^+bDYr`^7#zK)i%!S%f4L@N zx@U>AtLDSlo!cgN#w}W0^Cc35RI}labM7?lFI*0tM_HX+s{{mIuj~s8ZfY(67qUBg zkR^k|Ep~8AyhC+B?TKo~qrOk+EY&~m(|H+5H^e+0f;OJ6`A(>Z_v)Kp@T+QAz5Js# zWx?ZVNvCKWr3)TVZF?!M;eXgLl>ZP>J@EKLiBV;fkaRw->aTM zc*Hlo=51b~sf|7SS5lN(BSLdSz36pHXQ^OGAcT66;X{1YXG=sMpQ5a z9EPr;$Jf9-hJIVzs;*4G??TO_mIAwHw1i3NFr9MxaQuPuGxtX3UbuR>d}`dzDpzx0 z9FDqyPCo@#7a}#?m#^V;hAEz&Q&YBoLCQi^xf4fhiT)Pbkt%5>D9)OaAnwZ;I~^c+ zo(W&eh~Q+LtTPFzeN&q!t*fpVHaBTm+Zq1;1aEhB;K8%I(@t29J0DAWgc_?VGo;HY zjjg-N-Ku-*s<0fVUwaMEzR!&KtpkOa$Rk+?r0Ci1i${n>|R>M z9yl!5QoTkqoc{RmYITL_KOW2hEdDibPa3uZ$+&=$b?BJ5>JhW-(=%V+v(yi?7ffA9 zpfjfeRUa&0yQLdZV~ls*W9-FPc_$`u*7gich8Bu z2#4Y;lGWL|>(ZX-tfdNT?G;Oime&3Ldrs%XTp%gUYU&WSY;un7kznq5n~T_V%G}thw`% zO%;i+PE5_42n0t1&OaDPk2my=rckYZS4&fU_p^vH5@`euaX@?d=46Ly=8Y=ii-+L+ zx9`5zLuz)BRJWKfyQo%ZdTJLP^L%4yDNKNYHLIbMgo!=(FJZ8^uthswb?ew;I`Vw(vCvnwEvlJ+F zj}fXQs%z+gRr^R_LfK~L+AcXQzhHAlY?*=cr>@lXpIL{`YCMnB6^?6DV}Aaz|I_nD z`I3qKX|0oko1JB<@{CtGH16K`?Fe(e+46}<&HX*;vJX{TVwOs(pLptQ@Yt;Ful;$8 zs>M0_HL70%{pM-=*!L~O7F%~#ZFl^Lgw#ajF_NCsT$fsH&$vx}7}tHA`jTXMZvVjX zKrmh-DrVtv>U%^SGzJR%shJU!)Z~q)AE%l-0HqQ#tKO+vcDPDB=$^VQeP#}NT*sou zd*TyOEP4!k^?-0%N_w>T!AzYH*~xc;zzh zTl1W|;tw~`ebM#d%ggzr#E2>-u-*m@vsPNSgZYAW znwQKU>ljAYZlE^le{)4=B&SIAbF+>!Kd$;Jw!{*}RkmtEtz)GX0ICiVTdHdKQxC3f z#ZQ1IPUyxPsu)ld3(HNs=%XVO;T|TJzkddF=aDe=%Z=G5r?0m-MH+ zF7N66@w_gA?fA0!bk~|#siA{!4H(^ZL$zZE8Bf^udh%YJ_psnS3cSI8UotTA(x#O> zxs8Wc>#tw7Jwx(rgtOFs&PI?vUssN_zDcw&2w~e-ssEaNdu5u4%c)qa)+hIVt58GV zT1}hZxYtHddGtqk%Z)6o~oh`WdpJ;Yo9%;FO=qt1h~@=UrI zvdbXnyQbo0fkW~7*>89GyaJ8!hVhegG;Yx+!U3pA?)agl#fq+heMDC9RJdEN=6i=U z7GX|Jx=I016H`(co!HRb_S~bgqco()%8DwG(7zisba-xanvec7-4=a|)VPtH=GDXF ztz&yO-3+hm0qD1KEabFBs)h3;0+pxQ>5D$&HMbySN8j#!|3(oiMu#h&c7M*F&Sv>7 z=1cUrQm=)$AUK-03A&RXdFqVs_sq6=1x8F&o&Rqi99l>2aUi`9|?oe za^Hzy=Qtrou>1RC&R)a@ne>c{`%ic&Pxq&lb9>v-owaQ`NK|v|M;J@2r*WzO(!G?e z&uUlK+-w0N3vsTZa>W8s`g9OfP}%4tND3i*dY>L|csHdyT-50a$#EKXOqbPtU2*=GV^_Eb8itld%ASEN`iL^>nDTK-6Y7Eg}X#c92*p$k)b4 zYxS?@ZEs}Ea)`_8mTplNw?mG@QoRtNn|oeO@_5TKdc%;K{`#`qD{-9#?Klm8B`u@w z@0Qwftt8E~;(gso_Bufx1YC|_OKYkE4_q53!ss9&Oxcxc)KK%7Bt|XuSL{@|Zr8OL zi;~NXY-bRI@u=T&Y^FOSy36O8>|Pe(k$p%K*zWrK+CD}eReODUxS=>2|AZ{}Np5z2+oS=M^I7cJ(X>d~=Je9^FvJWz!E zsHt@LlE*XtE4MpGRg}Izmu$V+tm|{shcNJ=TG%e_eU?( z_~zf(#D+9>y`yoSB9Cufk^B2uHYSrz=bAPCEBV0YcDOUL-`W96%bD#p_CMR<7l>8M z05SeaZ}(kU)ysX2H3yZcm+5!Z_eVF@G}{%4XlB?Mv!4KHX{VQR;kMPcw>8&Bdw)$? z+}GP=Y76{M4^aQ~-U?t^GZzs9(d*s~jae@ofP)N~%*(&tyDvSKfAa6iLs5#yZJBT~q$;8P*ayD1P$2}n(rzSMCXwDB&0=aCd@wnah?G_FGH7Pu9|2|51} zC`Y9eRz}%}SKm6l`nRr-^7gZ(&`~3*mal#_7n6v|%5A63_GAK06*9=l5<&k%yt?-m zDt7GuPvGifLa?Pk4Rp^dkct;HK_%gt_05~ zbV%)mvC;#8A0IA0{r~)@;2AU-xCR|?AlWtX9H{VNMpd%L?aJFm`F#D~#)9&2%Ly=| z#HWR-vgn{H)e^qhB_KtM@BJ%CqX-VS^DhX>(Kk^r2IsF?&7dZrF#g&A*2@Rjd;p!WE=u-djl+MBnSp3 zHnHw3AQzvHrxb_roLOiazAp5D%EiA#ze%X;t_Ts3yHYYCy8X-~Z4jY`HN>w))be4( z3SlXQ$eB1qHV=mnwG%i*4)W3BkVW3{0m~)FkSPPM;x(xT*Y2H#Txj}pPN~%6@33L_D0Fqr^ zkOgTs@hqo8#xpfKo@N8`#lzyW=|2(f2dkXU9cKz+5&IxxK{aW%{jhvqDrB!ZnGKJ5 zZIsGdk_B!BO)lh%l!q0NzkPya)q>bMPjHwQ;Z}Ot3JtZ+Z9IC)oM|2LeL+~%fV#;V z%Q_WAMm^XfOPIi8we+H3aKTZ}BFTQZkA@1!0?GJ4-qrJoLN3C7%U<-D@Uf&8Azr?J z40yy+Z*@S$8xK~KTk`?XRk%Mdz`!xOoqkMy(<7oOFc{aik!^%^o0m@(bkMv47?97@ z75o+D!U^PR(FF4m&*L7}bM4x#K*|bZ-0v_FW@nx8bB#^kGTwIYKt=VR!x5rGBwz)g zvo=ZSQ6)l@>3=w=X19J0v&f>{6BpkUIfA2Y=DaEg`Pn<^#nt;~NfJa@k1U1Po*36i zj)ToNGH;>>=E($uT={wy-lrdr7wIqsK|GgAiU*?t=uDI0r}#>+o}tdNF=yiX(#yJ< zZ18}`8v5J|8pLJ>LT6m}7-faJsY1}iA6K@mI~aNk=rxwOWkg+j`?jL*(^T(Ox~1HK zIM|zj*E)-od4#0h{F?X4UimljMNq~QHN-{cH9aPD>xV4FW?+UUF3l%~F+7>QQ2fT9 zk(H?yrgzqe*Z#snO(pLQ^rh#4V5JbN=7G<+P_7W&JH*=!$K)4i8jTjWYIb~>+w(BI zI@px0u0q2;`i94_zJmHck}DDIs&pj8llc8)v|PU*yEMtXp-v#9>9TUkR85M5;Hx`x zb1#kiFTWc=Qp$}-X#g6x3iB^2S>$5H);VI#k)3Sh!BLz*4~D0l%b)0jY-d;+*saal&SR;Dv~y7H(>Miw>QV&!V9 zvrp_X-@lA95PQQGA%Yx}8=vEiD~prhya2MZoV*C=-v))ZWP@S4y*d>Ul|_P7`qkTG zL`|3=!*Rg{{}afA*C3AHCx4Gj%Xjo+m3pjIyHS zwnf6XSI;&-K0yr>3{u+>KZID#4LqkTOoOh-@2sy>feMk=C+ zO1m`z2bu4Gy*^Zu$EeTi=Y06;&&VTd_YQ)K1M#-KF_R+|TlexbACQVRg$EDIZ)6KD z%0>8U_G^2)uA}!*FE|P{Eo5R@{Ni?j|J!1(xl0s9^KK~J!+?*T`?6}r&}?*Oj3%TR zAYx!0UE9*?zD-0g2ba zVdjv2V3BkbqJt+^8U5_X`jS3gwWxl%a;>G{=jtt=Nj=_ktOjCf_Ct*FB)XjMon7{U z>~ozIx9WVJd@!Lw(o$11SNsZ|Sv5r~$a^fG`cY&8ZgAF$^6>WG-w$Q+(Hcc{!waqN zJ?CmXCJ`2zjLZZCX!Ni(gz3K4V!Q|07!QjeIa^u~1%Ko74-Heg><}VZQb~5mfkG^_ zlXiam2HoO3vpD%;Z!35&;9cG~wYNIdrLi}AY}A0A{e6jV0gNamiCK1Y_LR^00nGcc zninccMs{y`!oq_UWlkc`AX>@~L1>mWVekcu4_5SXn&v=>s2QcqIR!C#FI2UoPgB2G z$?U!G`QaP(jUm{K@Wt!9LoGkvKq1v4v4Y1l@4I|AG1=|!oE-^)-boYn7JfnE) zi6rY%SCE%hc&u1S7(|Orc_IsVb=AP!Q}+qJtUjv`iAA%nwB>TW=;K^Ol}*lbZaeM= z&G!(!*KKj8TmHjT#hcpl>I(kb8DS6UqKwd(8^;+bHIpHS?{ExE5lsRD%ET%x%u*gG zl1-ArE9L1Lj;f~OPb;ncQ{YIK;neK8ZwjM5NmCxjsg}}KZ(Ug_@Amz(WgClyDX9)g z(TrWdmpJgiG|xx*T0ze^{4f>p#3+u@dT6|y*|;Hi{U|8O$=Y2q?<`L;P*4y&w4f6u z#`P&6*Jh9mW`lG6`O3oN&cb4FQjNHSbIJPA55!4G-`5%bS{MZOea-^oY6%~TeDtNW zTB7tma(~^tCnii%pIM9RCBN_e=|87X{KWT!xKpS|UTw!-SiMKYBY)`yLHKrk;Uzmw%X6DQ$wgtTA z5p(F(CXTSI#rn%QJ7H*ye~PCr7iX2!5|-!qEHsKNRcDT)Dt2=6V4+p z(0aI>Rl5H&y9bX2FvhiGEPVEtzd&fyOQNrs9@H$GhGgnQy2>|9LwteAEL8qolT+4-s2hg0 z$=INtZvqy34tpRAWMh<#?`?AXl0HE%?D`Vr`z!nvZxAF%KR2UTk+6aibQJ5Y9%R5T zLul(y(-#MsFOQR{!n=Uuy`~qsyi^jG2f<=r&pG=2-qsz%8nM%;L9Q<^6;O|^$D7dy zmhv^;u+w4-C@W)X1+e9hls#^dJcd2fR#bu(aMm#ji2*@!BTpKzG)SOkgcnyQ#q;*Jy z0>5#a|Cv=X%t?-`&EOIwU1(?f0^U0*y-w{a18PcC(^{n0)fE@7iPwqna)cWaO}IT_ zswWzeZY$G4y7zfBf)9eN%U`Y1*kT?WIIZJsx9Lk;iSWJ9iO9OSj7CWdxklSQosjJv zc2`3Kidr3wc6xQ*&e$0G-@mYXU68vmw4i%*-2cOn_BZZ1{KHjE_M*j55?&X^R3n#% zMI=E^ffMyoK~+$wmf-cvTW&>toZFMDMR|A#hlKP6?}p@p-!@WC)u;0yxK%#)d)a?= z)E!4ih4&2e(iGV34{LbGI*yY72fz?Cs2I+^l0k429{M%)Y=mpbJwc{tW>OfJrm^6Y zamfcU~XL3&IkV}>cU3uJdRQ~F?n*aQz>K^VI;h0VA-uL|f zeU)GP26p{lFZ%y=5B~d~31?;5FkN3%~*k?>~0#=;=g@sM7F``h}FQ;dD z+Q0RO&FU%mZ>Kg`X=ga@ep`Z_(uy!Ss-gQDpo(G3#N421Q(rzzdW-yy?#$_$5B%J= zUnc|U<<;)uhMqJu{MYv-to-+b@ul^CE`Lr#NMx=cwwD{rDQ-_b?fWNU>mB##j$+8d zOrZrxM;Or5Dmld7#gt|CLrD$+*{0s5l!`}!7N*d-1sjp#8hu7Js^AMG4Bkf|__$?2 ztoi(T9~6jsBU;M=kkcRU563Bry+nRZH2*{b%q{_0l7qtJsUGndEmE*lewm)2^)rBv+)W_kX9XI2^0?JeDPh6l`!Fn`@o9=y`Hq zPswo3)bCV0{Y?%t$4>`duUCW7f32|gISBkhiMe+BYab5Iprb5kw9}%>R(b|ZW&xJ+ za^X|fop^}{vp|Zc@d!C0Cs2?KN`FK2ltI~ucrufoAN~EuSPqB4oiDlkGiG&bU za;Lt7OR+*@F%;FHaZ1v-ki$kQu27ZYjdYmu>J43lU6aeC8gpr05bV;hH-2+;ptUPD zR(bnuj%@d#)lJu`G*1O-6CfN%T^Xd87YM_@RQX%(8s*=JW+4eDFavV;I-72cdP+J{ z%2CzUu~ihsb1f}Ta`b6EwX{0@4Xg_(l`KQNz`&@c<~i59a{7-6Zk0v85v)_j(k7)F zNjk3FP_YDw_+cw$T5&9Pm8qa5LGyDT-Lg7=*!U02W*owa!)I-Z&BewcQ|+JHkj|(< zoHXf^=U$ataasbp-&;TRhge2oEKeuJAy!{qA_I9;)} z%mSpWMg5FFpu%8@N3N}wy!niZUsli7+p(q98wT(hv6} z4&=_O=S9Cie%9c%b%;UTRU@o>d?^Z!Pl8lP6hlvv>TkeL&eeLQ)?Y+(y0G)iuA&MbIR~lz4yvH!VfRSo7Nq{S*HLF@5nH4U-PQH8#UK2# zdtN>U{sn7nuNbLi{Y!1Aww8^IY5_z(u?BzDRFuCOKZlAtcp-pFC`gko*3tsjgRHZZmn# zN0)%N(4?llFfT!^237S9ys8nGmr1@xUN0)26ig+UI=waGF~pBWPGL3)A%VKTvxZJs zR77~<*5Ir1b5ibab}|25GENi5sgolf%kh^-Sr$+vrd~d06vj^(6d6dXhA?*m7N*+C-(g0K!YDaX3+u3X;liyU(XOw>^K<5 ziEjS+Phq#?tsd;7uLJt3zW|lTYqa97>7KhdzlwC^9Edj+fCK@;NPB9D8sFh}M(4Gc zM9l^OSa1HYMyeKpR8d<`9QdSLWQS`cR{WM|neX89CdN_DS;A`{wM`h64k4W$;q%hM zT&-(UV|1dF^YBby@dRdpK(#1Kbn0^XWaQzlZ-~R)LJBZT;EwYcrY??KDKl=FAusyf8UzF)F@f zz|ttP71a}SCe4YPlZ2+sUO!YOki|+l{bVO+5KMOoQZ~(BVbJEf_uC*}TgmtO+{{=` zdbIM7J&*C!FD$|O+G19`P9mYT`h}Oy-8JM|_;>+j9TF?os3-?+ldU^+YlHPG?AH}! zkQ_?*<)ak0u46-2-oSx3ek~Bk>j*_6Qbvy{0P@N-Ff*%hE@C@5GF-qz%f1$<8fHCO zsWLyAPT;swzvv)JXqPY-o$D4O~0m4!)`UKmjkl2=ku2Z*C8hbQ=3MV__WX>LU6N zPR#k0WK^V6k|1OKwH^sOgXezC`CnG|I#gS^c*WsgDYs~-6p$uP9mRH6533ZXWkV85 z?^+m3<1h<@;N&HXns~OX^^F_ z`8>9uHh2P}3v06Nz~5ANQa$FxNFV#YKW=N6El{kER0$U#S(wD>1#5x{9EV%qU=apl z4450MaqIgTL!_Hbks9m=@mNLF+l@lin~cjQ+#E?S8r!m&DY)DFrIaM%iC4@QdDg_w zK_RhO4-J7K;k^58AcW$IYy?t^L4NI65)^S`GoWstFt4&>Whi63Q;w1w_3n9s0azM> z`UhrDiyPwSD9WmB>mX4~9mC4mvXZA@O%x67H(~8%@b^03lLRBPy9Hsa2(IswOko1zzU~4N| zXb>|yGe7v+=w@w^ux{E*V(O3v6u>UQa3F<%#;ZAL|$|E56)ZCp$KzmWGsIoCQ9` zk^A#6LZv#^O&`SMuZfnJ`@*gBcIdF?qXEPzhnCOZCCc|Xq&U>(Wb3>*LR+*X;Aa_+ zC4Qiy@TmYtB+Smc7VD^fn2T%ENPM|Keg241yw?)|9KW|=v~NnUC}-$F!?Ilg4C_sQ zzxNAhzNBaK>R@RyvWGY~7;;b>7m}`f&EN0++}lwiQT0PB?*{F~kw8B|PV&8#R!HuOpXqRp8}^6N}~0qpl>8+wF9O#Xu~|`fj(9@Wjf)5G`{4*A(grk!=*Eh0#O(1PZzdm7yS1YInaX&B(Tp+Ok>9bA5bVnd2;%r4C27*2m)8^L$ zR&NEJO8fRU5R|4u`8*UczSh&vypw)T-7q>a34+HYi|L~uj+1fE^P2TvQU1EX2oivu z>pM@tS2$m&lSGGCoi0!$28Ezdasc?&@Z*cG$7zbU^t_;>k_#_k<=4n#w1Zwj3>zDV z5~)013PSjv-`;WTc8ke;4HXP0t;D*9mO*>SDg;)Uo(K@lBRPRY!mAo0Ta$xFd+x60 z;vHQJVoo$FS0rYFu`V)Lt9Hi#*(V5F>ZZ@{ro7>NC`p7l`0%Gcz!D2b241S%*J|T<BlAd*6MyRYQr^LN=C_i|o!{sSB;CX%35Z`3e4)PR9oBw7tzzzLrmhbi ze?^fy&8&od`q-D2P9S6r1P=m5mAM=N1?yCSQTM*Wu~r4)uN)cP;uCup=|VuzqFoKb z;44?~0U|>J3dH}86aC+%MmNKb55ij1^S3YVPC_tUmqZH~7m;Y-%kEx-P zseAh+pUKu(7O$JX<$SiCIsH`n(`rZaD)%HZA&QK?z~c_mta*-fidRI~i@^)TuJyxd zuiJ5`nDC@7V|RLd(07flo|2LoSivz(*cp22lV4ztK|WCDQ zHe$K=<9pf{KUpH988h6%!u~gv4v$hz+^9;JH5NUu|U~@8>H{O2qB?fxB+3NBw-w1@^*DMv4vm zhSKel)dw`6)9N2|lk6)iGVr2@MeZjS>(H@B*n zROkxqu|ev67Y?_^2xBBQ$reDrIV_zXMW-?I{Tk>orJdTA zrp6_*kPV%eg+tqX&(ck9q~5;j#VNw~+*%kp6zro5-Aw<+Vv7I6;MWw5)l4<#+K?DV z&Mc>Jn@6o`@b{yQv^chRYTVx`sT zFEwoU{54xz%0)3Ga6ns-+UlFjRG&k!SV27zLXc2bl9_c7mQFHde5O-D_HdIeg(Ca( z*rELzbII6fw6RW!CzqUV<5u=9L`m>8)%lYw8aw`Ck8BZ@ac&)TH^rxK1+bj!*vGDJyGx16idl31`e;NTylT2Egv1Qxb$C zo_vXW1+YlAGy4U7ABU)>7q8lD#E3Ma((O~2oA4oUIChM+2ta{o=o%>a1hly1C|b<- zOU2JickQaBglQu<@W1}P1uy>f_sRCepOYN z+GI0wKU&UA(ma*~atf~_wH02cAt*^MAt_!Hvz}~r?ZtqLu&h8J%}Eo!7y(0SA^YE7 z8`6P}^!JbNCqZ(QRVI*yjAAdnHg=cTd*h@IchaY;$V5r6CXBG}KQHT9^Q4i?B^y8H z9%TK3US_Kh(x&C!?XX_mz694dZ~g#kKZ15;m?eF5t(L z;H}wBlQ^mWNsrDVq6bt$#vo${>}7ma`Yw_~(7$SncS-r^%-rv(ync2`r3aE0Rs8ky zm!;UTTEZQy;abnYAOuxJ-@{g1=(B`J#yXgUpvFmm7;%@3oxjPaMWYqtQExO47@=*F z*GKX$k%_h6kRYTn=T^bR)EL(LLe(rDSYR@Z1d(jgf~1PLi2GkSME-vmkLg1f9kzfE z<4mSbh3t?Tobf!acu)Ee>pz%W{R+hh5!9fnZ09NCiyhwNf`dcz<*Cgi^V{R}bWu*r zmCQlq)GWsm=t7ZUJzvtJxKu9&c_gkMuYB};GK5VzeVBFr-w)9$#`Qs^H z{h1Eq87K9NfQH(+mpj+7$bJCWr}b(FNux5Tl_#p87JZck-Z{@guD0EF(P<(yi-LdU z!27jKF?c3JRzxuN?P~k|yr-^}Grt^!jT>es2^iX`CBX5-m;&qOaGICxk8?AufJl5bDk4ARI|WqX+jiN zVrp!ad(-gM^>zF2upGhI*-Tx#6UMtu%RacxfGm6q3Uv9-BG|Yt4C*xo1swy6J zkwgLl_rmwUr!eT+Tc6zAro0++l$Huf;mEQz1y|p%gBNGa<2`2G>@z4j#r){@!b8q2 zYJQQ86}R#thI<>TY+m@@NE0pB@I1TDRC&5K?OO1uH*>8Uuev_ZZF#@#O&nMAnAVQ>*#lo7JA2O(<6%o{EU#;jmkac=Z15)43Jxp3HpRd%8Fhr((~k zTNu;NzOt@@^>NO{72^L*r=v`^Cg>2nFTMY;DZmpj*YhMFnziF4TQ**OlFpwm8GZXH zQDvQOA>=fQ0t|C_&a1|G9dk0>j>V7+dlKN-#A!PV6(_Nr&x5$%EW3zaEA##d4BBN& zpH^Tzr=FwNrZy_QUivYq_V@$7Y-f%gmehl85fN%ueRM zggTIC<4Z*~Y9fo!$XVT@y*oZXvB11$Ay`XdSsIAXhuiVTO7>kzZ`xR#FkPu#aBt1w z!*gp{PBLnk@>~B28%~$ zinBlV?&+-0nOmIdadT>ZFStMxbJ9-ZSCp(ES^8lcD}LOswNhhp*R}BU6~ezb8qiqy z$!60AwcSzQRcfA@|D>1q5-bZ4)s&0uFX>j@r;R5Q%ovFs7*+F)Khpdp^@o|r@oxoK z09=;Gf^jD_URJ7cK4jrWfc{FE=xtn=xz#wM7k(mwDn^3tNVf?vJieqtlC{zYk$JlB z3E3|~qBSFj{4$8sD=fi5hDjld33*PWzgYQpOI^Z?_<=FT2C*HZQw%5}>>=g{|i?4Q5*6$Zsif>?y5Cd2?S zWOf8uz-?>JeZl;IN?+{zQLTS3-v#=@Ia59%dLakZ<__C>WEu2B?*TT6`6Nbx{0XNM zbbNj;=N-8|b!0VKR7Ky{x=!Dskyi_M@(-Vw{!0T*75twbHOr@-9{Gu>u$>m={yR>; z7utrI5&P3*!)~g`zC0j)=KcN}|8DOHQN6U=*!oLTtZMd+j#jP!V0>RZEZu;^Y+DGw=OA6SD2JNVb{6fZi5KSSEUoMh%6c(Pb zikH3eJJ@>lW}Ua((|h)rMO0k@-bto zo%uIqs`0Ji89n#t#?nyimIN`Y#{>S|RK0T)zhqD|k3M=Gy?ifZJ&=qL37(J`uFVvs z=-^ashTtB~r^N2K$iy>T_pbVIW_$tBoom_WAWGW-L3&eTP6;KkGb3{P`%UTvWvUv+ zB$AoA%eOFSB6>?a<{vTd=GgUsiY+&dB-y)2=2gI2Z*Wk_V)i-;U-{UT>BX`S`Sm21 zzW5R_KGpTl5b1;P-pnOQ$*fjUy4#cS(l0@f?Xj{uKQxl>=jA_#_mpdFs2r#I(YyE7 z%LXsS+)@*1^PxtNUvu0E;c9gZO?o4n1rjOF)0f?WM@m`nszzo#D8vTHGk)fJ>1OIL z_(_;`d``cKgT5w9(>~TUlKKb?;{ZDoKuZtsB+-{tjY56skD=0lO zb9U_coH}*!9FZDqHjPg1H!(AB$}+E+6}{9__IGgmMt#gs3MwZXjF*?WR`i#9BwmWl z)3n(;;@bCZ=^Hi#}bS~57cS%_Biw!@5Q(Ldgm!*bX`RWuF zI+ov%(~?~~8L22?bAxrAXn58U7pLoYMbZPDte2pEDV>gS+&{$EIr(F`ABu%!4X4sw zt(rycL5s=*bd^2%Rr!Rr_#hQ7K-S*8p)`4UL${Q|-cEm}q0~P}jMGjZN_BAAnTu>` zWw*dwFOqc;XdK4*q(NNT$8{{TMZ`cYzA!x{Lt#B{aa;&DTdm?z>E*`E6r5g@)OiX0R`TXo0x_)AWG3YpVapS%zAJqwI1?sQe0(C~T60-vMrhcFI73=t=TC>6 zj;tjA!RhN^*UQe7PU$*Y|1Rp(G~nYA;V;H3?ET`wovWh_-aU!)yv4hP=G81EPg-iZ zp>ybc^?@y_Mx)S%#?B=kx!8B8c}JkutTv$QdwAn_p}{D$TeLOzTZn{XigBNkO@6ge zb9XVFDYjB0p>FU^2SqZP)ouL*A6I$x>{RYh)0356+(91L~0!^nst-U?l$kM7=>GyF%jG?lao79Vxw6aq|nfjel68DHtMNh z(bv)$3Z=vl(ZhaH=aL`o@1SZOK6CZCD1nyO-0;*z(JhwxSlqx4_=69uv1L}<8MV3= zA7MJ=LQ!R=TI0uM>Hye8<79`0ror|xleoC}>EJNIlK9eI$kvyNOB@YBG&kHdjI0yQ zM=|`cM$^DMCMZM5KfuzQP9i!sOOZH%B-z638Y5pCjGovvcU8>1`3()8~^T60dAm>``n{} zj%PgS0nR-M>~Z2+y0La-BLriT&wievD1qhsAolO03AY+ymbV&+*<(&dUJCN%aP8S zEsSEcIOxM?atIfyNQvJ*jZm1ym zlJqygriGkLHMvYnZQgv0uyRS9xVy`x-Y8wgpZ!Cz@zdCOt?l}v16F(V{A6Zu1cvQ~ zPrfh|J>=H>l9qBFXgky5D1`#qD;9W4E@zW)GlWMq*OKR#_c!{fz8eWigs2A z+Dj)iN4o9#-kmNJ*=v`)wMJNG*c01D7b59j*kpqKB?e8e)-D&KovO-V7`a(|U6 z&7$6eO?Jl#`H3I(mZ>F1;C3GXYLriJG>;Q!mT3Cy62vN(x@8EX>x3kBLy)%tNYDdh z3>UDwjvuTMquk9vsG|~6SPEMnlOr@`?V6JlnM9HAzsqJIX)x8mB~L_eCpPdGpX&G(V2xgDnBn%z+C8KJ1~f!xC>N4GR* zq*m|Jhcd9&e(8YOTpTkpFkz)Pg~#oqy5@ulb?)%mvl=1 zSxe%}ztWyrwL#W&7r@pN=Lu;rq4tQb-!?m>FgFy|Vc`-SoHtPwHY+(h<~k<>)+1N@ zQi)Wj{B{!{C{jX=ovNNp7jE$ny~s#(Og9S^H9RokteO#Toc;Fy_UBA_`%t2m|H!rL z%_H3@`aWfu`e%3EYo9O(SKZ2- zk>v1#!HRWBQx^+nM_=dq?det>>~~j8{W;Mk6(+u|tv6M{QVJ<(D~om+%%@ySdXbGm zQxjr~(1FaBnVm3Oav|qaz{+Tdrcqi|42E$_m;_7$SFz!=p<#Z2%*9Vw7({BM{_`Fa zU-<=>Hy!1}gDqVU)D(w=1#;?e*=Yz#DyY;C>HLEm6#b?H#MZ$x*9Sgc1g)VpDw>38 zG=~6r747z@PCj2SXq++qnm2c!|D3$A#=?@PCe0Qu{n;p$3}(#pTfJQ0uwwwKLwZyI}>OvxJ)vTCvlHtHsKy}X8LTDpuZM$Iyb=h!v8EEUY>QZ zSiS5u)Ze2w45v`MrIb7CYg0$@&tIMT3kkA%>g4)ufA=| zXe~z?8lt~tyC&kVu40XL^rQk+r%=*3$R=M;&Y}XCsXNo}tLoSX%5;m@TLSJe?k_0f zmC$1)O4wwi`yL`Eb`4caWb*}i8IO{Q*hWWGS}!0KO0HH@ISlfn4O_rn2+*H~*`rDm zL)37PNy^gKf9t0~e9Zi{x#J%Lq^)#~$y%g{CY|J=Wl+S^Hi{V?iigKGYEZ1x081hB z=Ogpj(qh3<2pRlWNHpC8YA1wRE}=5V;h9x>U_Z+(R-x2|5*{V25|^9+cN*^DQltPCcWf9oURi9M0PbbhAJ zU(TPT^&;iR8>Ah;kqsb;$lop1rX^>acYM?V3FE!>=O65e{`#Fvl{W4-@f{Ms6iUl+ zNdHjMT^!FJ!tG^iZG>bJ&d%wRQ`TfxQmoZQ3J6F6enll z%syWtRd)|G>2~RFm#gX_1v!s`?w;8BPx&Zu)&M#cv@+~bP3MXFSz))v?NbDq8i^_@ z{>!2gEZtqV*m<}SpCy-6WJfL(j97)<{QGRWtJi`R-=Yz*7ZXbRB`n}iJ3QI`h4*sq;iwTK< zDgdu5M>6c=+{NuA1)1V@OG+g?kSrfO#yg;Cb|(S>h0VO& z=)WIm4Q~FQ&HrB2ah#>%q;^63{K*e}AaaTpMEJG2t_fLHTj)Xci}0v{i|ZW^VFDcWgpHsPDxZq=xU zPmq@aN39WZh)FT;-^aQOhPIhXn)GXlAH{COYk3zd;t+(gf@Mo6+L!2EIos78m6>u9jNU$p_Dzp5AMT@ z;D__kH?PA*X~qL_NG2xb!^hm2 zrreKOxsx^H@7Lz6fQ~$@UC3ZUIWS}Xqf#is%|Tf|EpyJMQvm>I|Hd5|NU(7hf)jlu zB6bh$1Gy9>JG8fD;smB1GZAq_VS{R1&1?CYPLqKlpD3YJ{>`r0n{4zxcT z+CSaDn?2en^z^Jl!yDk@ZR8O{8Z*C-nasqIjh1Y73yVpwvL=fNcy=g~pk)M`+M%tYct^jycjR;P% z_=)RGk3r`xKc4L$ion0f0)F0z%I^WvL`uKqezhz5h_Nr#2Fkv*vnIw67XU08>Dp%_Y>RCdKL+BWVlu<`Ha zUVmf6GNA(XI5*~qTlAuQ-}Z1Hkek9;o|!JCf$V<;N-A>Mt}+X;QS(@+_2Bq#6^bVJ6Mb#%o>A zx5Z~6vPAwO8%^`{JaT~=#J{evyEwQY18tIIJ$=--ST96}!TUe>Q{-O_<@#CzGbz%v zCrGc443BEN7dsQ)cGII~$n@^H+lI9CCnRy@{EPe2g493+t?vLPQ_n}_yoEYLCg|`l z*{s>gs8%x$Y|0=>4fuMguuQ5v{+1`ofCou{_gq$atH#9hToQ}(0blCf6(G6EaK)7Lbp5#HgR)$4E4ntE1@!e(!g^!f5HEpUPzPFf1#^a0Sv1dcO=Akd+P@})aa`C@wQX~xk zvWl_ko{Meys}5Md{^l@Hevthm(4VvKOTZ1Jr#Ya`chyr556m;cnOj&~0!4&8DTxMk z$;N`0KtwA0$ic-y#q4z(U(!o&MjM;;zB>K)uv@P8(w$a=g|5}1-;51B%5Dg&P-t@p zi4chianL2_v|UF`QEdtVG0Bv?0WXE=&GXF#D)PeVqOt?wU#4wK+y(R0i>f&4zV;Jx z6lK=wg(Xk+Utru2Z)pR>C17P*r+xl@rEK~do##YG^z_taRP*;iX*?AyvD`DIvvj_9 zIkoMox#Pd}R+hQSnziqsQ&aY&rH7Z?DFIF?3;_eR!>rcs!_E)Ikce(3s2}hy3p2ya z8cn^AxOn~dYLqoL;Nh)NUM8dvl?z2tr3Ss#tw#je1ePQnwdGQn^QZvzDYRNn(|EID z1s)a;;RSgwE-?ho#0Sl7K9EY4caF$C)+k+g?xGZ)e-~T~!3e_u5NV@1I=>BKw} z+t`n4gvTT}rEpC1BHIRO;haC7yYe@CZB)BaLbp7;7msh1-A;UeAWw;XNr4X!4=K!Z zlLY)3@ml}aKg<3%&Qsen4|8~4yiV}0|G|4&ZV^sqAGX^wqcV)?jPcv`|TbJ4CD^EYNKFg`dt-KpSTr(+%)rTkBjp{Zu) zzCZ64i9tQ)&aWB`U~0)2*LMw9txFB%{|i(Bz>*PO(0Mj#r-jb{)83VTHGOAo>QpvP`zQhY zXg1`%%M>W+Op_Z7;P47mO+?da^N>zRvIq4!QRgUETy<3M-WRnck{!BNOcXY0S{nX! zm8xBN!IC)~UVA-s&o5%dWaVf7V`rPNGS~y~MA2!1=vz0>B|g*)#VD*~c5rZ)30qU5 zY!&WBw7Cp_o>|o8BZtEwPEj5lr~&{^LK1QXlVf?tTDO8&b)jWujON$>K> z7}g?oM$x!W5hV7iz-Udf$JGlKjqW*r@~Hat96)jl86}fOfpniAYcl+z8D4L9crY`x zLlbyZn# z0DayfKtp~k_8-OZSXaUBx>S>93TuR^zF(uJpHOg3(*^6zA0@Z%Om zuswsBEP@Tu2ge@K{ zdA_vt^-hDggP77Q@x<6F&lQi3t|q43?j)j}mPWv+XA%P@0y^2NN1Px>wT!gBXP)xO zX{S9otjUbV5q^j1c@3U z-kt9|-b5XZAi4&VM&~8008caY7qDNzmG|4$EM>4#_&vL?2u%Ol;<(~tJ?_SF;dfXR zfi3;;{R+_^uTQv=3>fqc5d}lxH`ki(3S_g`S6wlNEfY6v)P+cSSOc z`~BlL^$8443=T49O62I>oC$B3(N;X)oV2MIX?cJOmPj3g)#PWzZGlbjM4S|_;sO2< z_z%R{*=RG~cUoMq&58&_xjB6&)&(ib0IFKz8gHx>H$A3PkowAO%Yw$?gfy()VFiPe zt$kVsI}6@#qpW`x{J1GV6n_KVB_T2q5pIj-bK90j--u%y(23;QHe8(uH3JOvbn;if zx{g%}C{V|yfvvd#!ceNEBU4-1!jq^IyK-sH*3jAL`?;IidI=U<$Tu^pT~KKIF}SEa zItg--OqmmPAA-Pcy`8T7$Hg%P-lLQS6Mh`SQ7wIQw|CMR8mk%Vaia`gA;nA`wce2bFhKek-Ku1q6HLzj2DEmN-<9xtYGt5sAc-VYT&)5~Pyp5rK zc6z56)7qvVU8$iBOnc4OucyYDb)y-< zx!z5Y3=1fkhXD=)bL&sZKcRCb4=c9L=k3FqKbyO6hH)H4_`p#K7f*fk0i>q6(Ym|4 zh58rm?*j#;L-_dDK!Nx1JSQ|Za0oEe{io=-_wlr0Y$6%*Mho&u{(7U1AMXEbD)Pal z!&DpLgAlBH;gKWrfA2UqO6}}yf!G|+41V*_Vk#4sNP(|1m4<^(HKu_VmLMDOLAyr< zTF%mkYgapY%+e_PTYXcsDLLUVI6_op;=g;`X1P5&YPg=A*@6nA&w*T{#Dd@~XjhBK zhB4BLb}2HnCs4d1HIt-BIw$dV8G60>Wm8m+Q`eFOG|UJ#ffqRyxy!#V(MAF+)P_Tv zPal&4g{G*NP@z5>0WJUEvtN;z=n1Z40umA(z*X)B}^MT;@_G$iL?wB@-B z4IqfW`0cYZ$Ciko?XajIlCcf)>Ire5HB;2Q0M0tre( zz16*_w=(3ShI!z|g|sP>Lfl;LDkmRvyt0X10#ox5GWJZ$WBSI4C+5dzlo`is##@v_ zsd-p*f$yfbcW*bX&F71ZqX3S~s#D7QBa6Mpul0EIj&E?bRFssqV3>*wZj(R2>%7Va zlVvkt2M?n!Yl*l+n><8JP)Fk+vJ%GZ)^*gY@zf7(ZaRc znyh%v4GE0yfZ!y}!oJCx`^xbB`iuZ`MRSu|00Z6@`grsFPqzFXr~`#&OYP8MjbeeX ztPj3|?b{TU^OJ8cAPm`6jngjVj)HxN<)mESSuQmR)(poM2}e@mKX?nmPQ5p+ZFynm zaks${;H<;6@jx8;yBV^K*aIjS(bK)(>9TLU8&s?$4NB7GWHt}CT5=Ohug%SV+1##{n46U{%)bfka#2Sgm##Nn$}U&KVvG#i z$?7Z9cI3>OW=Y%9gq8Pz6vX0P=et0uT79M48}}d1bR6s<6kY)ImN3)4npk^(+ZKde znws?hu_cFqM?&JyD?nvDATbZsg-yzPp_|p_Um}pO&RKpPqT2~ZyKvK64c^Q`0@pIc zzDWd*G?Y`CB%l1i?(8Sp<)~XmF-ZFg;cy<_cE4_k_E6TKA5GB9piYtW>SLK_Y$jON+t`pI;6_1@F3MG2z5k~3X3hn7oHgn zN%F85|G~1`D?V6*lfW9lX${peE&!oR>IwA?_1}0^I_Un zNK!Armn4Y2F=0HirS!G%B3mVYj2qyqNX;Ui6(sN8%3t5VZ5d$ext8;jEUQh+;IPq> zzCej~JR4qkM%K>LE$o?g0b#0|ZsHSxL~Fawlcu{Gx0s>6sn3JgtU;${6L{{eBl=-QF(OYD zZO9MzJw1?up3^=;_6-zjU%fFr7U z$XVi7B!vI|SjV1Z^m4Yp%N(asi2`}M{z}FzWjc>QJbDKU+={7Rido|ImBW}#beS%= z!`FGWA~j6aJ#nY|yb(1&BEdquA^!Zv8Di$5A3zN3bkQB0-W$I4#pM*OCx4evB5ZJJ zXp3uV_)A8XF_)bpe223KEs}kov!0h=_qh?$@9-Q0@UVY}CAVsShCx61S*nsIVZRuX zHJbyL)Ngu>s#96cSlxzx5QUI@gaQAw=EQ_S_!l42W=VjOhWj;61%b^{eo`4L&?j5vw0C!$k z-$d@hCN_q zXwGg^i?HYYVggn~Udr^B37>QVSe`{gO~1@>ta4LpZ!i2d4qF*zU&48$73B%GD=;@* zBq(J*XO>Ra5^~UeVxH&4mTfVz2E4HOIXXjRyegOz%uW~P4NNHWg9`cX?VHA}j}1ix zI3+6kAMwYpmM8qvxQM?mI6@jHQcxS;G-KM+#(QojuvE(hD z2RwXx2%g%m(+z<3;&q}IQoSD*X_;dDg8%=<{{xRFVD{99QlAa_h zWN-eT?>$PWr*nSiJkQhlKj(E`=iILQy07ayKJ)!~f4-ejS5;iTgnr4qdGnU<+PP!z zym^ac=glMYpW!vD>;->WD)?{?Yxwt4d$gB|2`9jskUaaJbtSTS;hPpo`A z=C<|@te73Fe0;`-4|5&D8JpP|TibKlm^i>C_}ue7nRW;#%czNJ5MixH77lQnRk1m4zY;Z;9@L?+x9g~AfIC!pt07i%lBM6tb zDecr$QD)_ngU?ntOB47%MH8&0E&7R^xt*;wTv6b~@NvPT@$b|yHZ``xO@D;^A+vAq zL8Epuv9rh7+7KqjCkQ`=x1lfWvBp*=gv-2yD+g`u4w={yF2St$STXXfJTh=A`Y%R~ zxFYa}jWLV{zf$V3D4fKf;|>w7?NS$Vv`};1sm#YOyoX2AT2px^|IDY6o@wo(XlH!b zT*dZ~i4{EfkPG1ve8RA3_)#8mC0ya<ts`;8Kz9E1-+oyQyD!S|e|emOwSbA0HjnTgVFeurEfEco zgNmG-#3gewwsIscIbo&j9b8Eg>}-y6Fwr<{j72v%gGhnOnLAip!C$=a(cZz%*3v}I z*2T{3haO@#*PlQa1RoO+1qbp zYlEHw_TP#m5yVS&whqP)1Y^l3gr00_YvVvPExaP|H(?t8U1{NjH{(qoNnH4dvN26u z_|X0%DVQ!V{KRX2BY7d|(hoG(#O9C;GPh{wt&HvMaadSqdk14XhuN2>X|$cKqs<`` zWRicV#U_WovC=Ghf;U?@h+yyh4(_RxDCt;8N_`d(U zvi!6bAbI=aXyZTbzlb5^H~wpe5d2D!gaFTM*T7417t>wCOs)MJgc5TFbw3Ct`2Gnc z@IfEw&0POQ5!@G3`X%t38A=F1$cs06)12C`5=saYM*bt!{U?-|Q#Rlt5|1B;5&{^a zDIvuy|MgJfi}518@z)I{h?)EUawtJUU;Z$bAl>*sA50)!L$Sk;EYdF(OpsuTY0g35 zmw+Wm>DD(p@q<_b^G_@>cN9TzA3qsO2>-IN1X9~S(%gSyiGM$q5aj*SV+oS&`sHH@ zJ`6!N|CeJ4lIQ%xSb}up|9&h%1jxVfU^9S;IW5vOXz5#QiXcUEAl3i(wVWOw5PiUO ztl`f^M4^&Hm?{x1N4P!SxRWfwe?E`FkUZb_Uir%=G9>8eM`HK~A?W}QvHe~8-G0v#`k|mB>$2XC#k?MxZ*znB>fF#oR3uM`Sn(u1U~=B zs{G^J=ECUsNF`mOr{Twla0w8BbCM1F&-*t-jKI6lX>S!0;`#a?Vz5FQ!7uCXFh7w8 z{tZn5hJ?!etxdrW1p!wlOL;dlO*@+%=FU6a9ISS8@)B44ugPu@6@*lB{aS#XyXZQL zF(fD?BP#+WOCkqkMi@OOh3F6KvlwEsH$4%WbKUUw{kZN;NY`gzH-GbbENT8f@(h2V zfPb`4b7VmO!ASHA(m^zp0wm;PnreQDPXFGKDCVCOl%PUC{@*z&X*0dF04W_IImrLM zi=Oq$U)Lx9L_+jWu=q2<;+HV)PZUED5iUMrF_iSyUsn=;jYLt1WNk=j6hG0BPjf-P z2pEDPiq4P10U*O4MkGtDqn(wjter8|652B`vVRaIe8oml3}|YFJG>K~fj(OqA2hL2 z2eL4c_iIO>JM1B8)Gk3#rJ zFb+vs|9U`-|KFty|9_8!3(f!w`1nP*Fnl6k|3kEAq`m&nMB8YrIG;=xOTxu?qq>34r4`5=z`8~K=F2L2!< znFU<@sV!-|GwY@p9${!nz?-sZK?Cnx>dHqd6aJ0%V>X*y_aD1V>OX{*fNvc$KQA%L znzq#>BRpO4NZ0;G8~qbW*_`meKOJR|C(Z$0@qXzEAVHWI49O6Xw*5aJDb9K;hG*7W zf8Ahlt~7Rz-~n_Joe;n(AZPDrZ3LbCJl{qa_%>QXCoKdUaKkSfar|}7w7eLS4I_#- z{~Rq-{G@bwy6yY>1sxhcPbmXqW(NF65Ami!mN)CInWWc;I)e{k3h!HdiwRFDpY5fmaO%R~wJ!U>)x zA-{@J{sfjWXISz7J>t$QKtij&*a?9@A$Jl)l8L~kFK;E*%zvc_LdMd07uHhCMpIcy zTR{G>h=ZLVFX3RE^YyMBEqv*$5jo%_z+2Hoo99q#gWI5%4*;;wL=y zoJQdvoCUOVj$C7=XpnT6wB!HzOoQ)_Uioh)8YIsM2Q(SGf*m5w;V|JGF0f5MLscX^ zh4idh=M#K)_^WzIbYIAw)1+liA5O~3NyhL`b>VZK{xz2^ znNg$s$A!-jiZ6f8;lc&Klk$;v{6Fu)XT0%CJV|=vzwN>QL^n65Xp)+krbYT1CRXSe z-haq6M2G=HDoPN&)?9{&WUHp5ex#g(BqM*l9TNNrO7|b6b$<%#$3rsVU-IgoOy5X5 z^_MKUDh}sj?TE!_YuNEQ<4h0PnQ6NGOd|b{>HlKuXMC8$^piqHqGSlpRI>lP>F0%3 z&#%-?_(Wv5d~XE*wkse^9wYZ(P3C@fHvcQR-JD7}7o;+q7BHjg{|ld0^;hM4oI)fJ zcBZmSkKswie!A}@UHeN6`J9cPyu={>@6a#{v_Im>#B&}~49B+Hx#~h&1+ldj{i#Rn zvcP}$wb2%RwF7Mq5|LSut%8EA0RHrKg4h37J8kFGJt8HCU?t(j@aVbM8@==AfTC{> zT>bkr+Wu(z)52}S+D;RzKZwBoXqW!G71>#RwxoYAVVXwI6%7BSk=fh=M#ONYtvnKN zjX&qu@oziep@#YIC^S;qe@UT1oiQTlpJn(DDl})40 zq_2e0&#~KttcKt%=Y(-EL^k$xC7km{5(F^Am;W1TIZZl9B%SHfLAv(S{`hBxKuVEj z)u@1*^=&C0BSQR}B%S|_#E|6Zh|w)(X4LvOPX)i6hd~@a^eGY~@P8%wo7?aIv5ojY zMqsm!HJRf~nxB#Ox$7b#GawLtq!h^NO;bpcU`|&^l7f&F;!lMm=iG^(D2x27J2B&I zp#Sbp2$PnXs6TV<#1|d=dvC>0Fwg$gt(XzR{CBrPXhwl%uC4gn#V~U!1*uc_n;2#` zIE9q{|C64T@1s}$Wtk!!U`ulEBpI7#X#cW?6@1R(roO;u?ne-Q=<{qbrR?^ac={dRc@zG&qs%YYeRnMAj#xXgV-?RF zuP>ze(QV9>0t=gg|D2|<#h+n)1RDYsozlicMwHfViw^d`smcSpJ$Hrc(XR&NP) z7>}{6zH@_P)iO-!xoG#UEPnHfScksnLWy){k*P;3X=T2@iR!HLWy~+RrM@Ir$X{ z)=u%LHN(SAK~=oP>n{e1b=_Obg|2 zJ2UDl>nu~E@+UfN6C-T0al2hyYE44plQZ9nTBjDSX{%J?wr=Z_vCZu%O8@;NgXy!5 z+c{jz>G-ZG-Ac{vzHE6rJpc0v?hj42Swu2`@wvfc?{;uv^t#mRB@54J8#HP->KH5 z%8xrg%%)&sP&|S!W7owk@yi8YGdH+(B#+Qtkvm0Y?Rr-ggvOyqw!l~JoujTJX$ugJ-V^>_51K>?}-khDhAu!^V&w91LJABmj$1V_l6ck zjt+Ily!9Mv=zon3ihpo?Q46a@Q;Ti>i0Rqhg5FCh%C2k7#S!uPG<@9BB@%B3sIF?)JU+XZw$D7-yE~E5+c>u!gkmQf=Av zJwHqNQX5pb{x-MSqwa#K$#k$EA7gt#T*Fp!y>;)-8*vBSavv?^_BtQ4fxe{4CjEti zNU6WsnXWsJsBmxY9l8+zG4kjUujdcB z?m7JXsVbrTo&blgtd@krHTyWCVB<$3xpP0u>K{5U4>Q_^AuGS6V<-|zQ}XexN8fYY zSZ8Lrl%I(`OKw1mcNy4L5lsVZfI#IN`|>C-E*C=#-P#E+q) zZ5v59%2epZoUAT;Pmb7!jr3QkwIn?L^ej1}j-C6=SbgK9VEJwTcnhiFcgOkbtO?#5MXKEZU&F zJvn`^7gOuGjdGR4JxzV7yngn6oEbYq4c%~(&cR3S&tLRmg5_4HOMY0}$lVlA&Kitt zCFm>HqN{87AC@^A9)En{^4J$kwq^zGyV-Ta%LM!JV^8sh z7`aE3EG#d_+tj=-%RQxGQMiT;k`OHK%I(VTs9dl@{G8cwoBWZFBK`h)SDoLwnq`CD zSKiL)x?BckDc8I7jejg(^iWov#m(j>mdH2OnGKaPix`9Vcu)4lwA|@C$zb#3_x3x+ zk9rGY+}f|Nx0oEQDNvP~>Wb`lVCm|@Bcn{n(Qb{Wl?Fcq;#JUnZ03Buvx60VF?I|BxeBL_M zTjP@NzItZzn4?PfnbU_S=UvyB`G)ldTM8Sm8!_6+~N0reV@wwxU#n~y0u^Q|JW$Sp-!XXuH3y7Q>=*ix>rGT|nFwJ7d(TA9{Y`)s2Y zE?al5C0j!caCz+fCECNIX|@E+#o}FshD(i=7*J_$D-x;#;a4jE`>e|sEz62 zc(K;VgEz*tr*N&>SXYj%VVB^;mj+I?tcz}>s`=gmD`)4GZ%!U!|6D-FOVvD>PRj2c zU&!46x+~6&p3eFQd?@Us>6JcpK}0d)0h)S9v^%%2vaeh!CbTKw=m03Qicq76y}tLv zfW1pyf%nw7a;0$Mj~00V?C&cEF*!eqP51r7Ly?P?tlF!@d5J6c^TYIqOSXS#wrZ-4 zq}j{$+mlTq^8&n!`*x@a|L&VR6=9nn?$qcxH2wx}X->r)8K)Ba#1sFK=A=Rr-9C=% z?qIzVnnU(+ol05CnO+gxC;!^@^Mge#Eq+B#Wg;!tHcYfCj&$>;@rRV$#U}zjU6hZz zZ-TjZOr$JcIXh5FIsra;O~d{Pg`&#L)IyH3VGE8ZhDJZVxSl-S8yP*_fh8S+`D?w4 zDzgHTQ~7P<-UObFan3K|%jyxY6gC?doBX8fG~m8t>0*?Fjp|lhBYk^Y|wHNpA~FMU!owtqSa+!%|cK&_N_clpI$pWrct~mH8oQ1N;fF!IjVU( zV6Mz4mJ;G3nzPSHGwQ!1&CwxQyx#uW?~hImeGZQboUH}Y?hw$`$Hlz7K>Y4An@{Fj zx9!iRrp9rlVZ1mnJaKY!gfI6sA&ZkcM*LMp0vYU+Yu%xXDwV2Yoi7tO)vf196_Zy{ z`(~7BKP86nJ|RnxGgVfT7|;|2$G6Xo>BJmX(_9heb;hj|q)~}QF;E$NjL{j3cy{u$ zt4_DP1~Y7P-KfB;koLxAc;!pocMy;7I>qRun+@*W@>Xoy90`NuJ}_Cw&eP0qWlM~_ zfWiy@CVx!A;#FIMW3_Kc$GZ5`nnrMXj`tL|-CwYx)QIfG`+!S3k4oo}qbPad3Z_UD z^t%b{w?un9V|g58rK#%{U(@caq?A?@>rC%I!R@ipJL>Sm#kd&HL0mQ4C8by$3I+dl z=gqhZ2cK?@xi&G>XvAE1Q{w#xa?11^>2V|EyLl%jy1i|O5|X55Ny$8)hae^=E~*yX zzrLWNXj#SN#8~2yrXrPsdG6HaSf6z*wgnSTHZqO*o>h>zQ(U!v6LUlLsC4(zQgF$* z%4@swjA#UA4V;=;oe!3p+u;eUX#|Q%_>a7Q%w`s2e3GtrnFxcAD5bK*TeEPJko1v{ z5s47C-J^P*BF}(5nG-P22eVLjEz_ESjz9jekY7eRms1o?8D+vm{ya>TRQ=+p=@DGYo3y*hsg% zu+|srG@@=|ndvbf4nj_1_=!5vCQG`B?h4I0y_K2GZ}Qy89w?{uin%_eIGZndmMi6h z$BjACtJo^AQK}kuZHg6`-JY=}UV~JQH(-NQzT;L6J|Dx?r|Duhc#j_uZH+x%>9dF_ zY;>Tb#_#TtcI_pUrux@!esWq%7^%~cmiHX9Uyg*hNS6HS3=v)@>ETibKASwY@O((d z<_F5S9EHN&%T5M6$FKQHDG+&}pJ~2B(pmf*+3o9>t3R%qi=@jG`g+)HmIg3KnijUL#%ES^ICkHc;-8kWYg;JJ6;)FW;wPR$rgO)hm z*h0fxVF$Zaawcl_b7ED{bE=LY10y={Qlo1AqV?xg!Bfd*>sDw`#>uj?zZ*?5FSb}S zpNx|h%w4d~@3ZG`&B06=rO;AnRpHtaB{b0&Go|b~+P@1W5F68AG}az>liW%u_HW?a z>O@9H&b|$xqXUT+D`!~(>5U+?IG!S_iYiqxPL)Vu-sgNJ9Jr~8A?`!kNwJCNj@&$V zcF8?S_PW%|@<*kWVDOyHTEerR!?2W6Rw}Mfv}~dE_`M{(E61V*^Yu0Jnqf&oR*B7@ zPe!|%+E@FIhQMriAeI_ZIx`+7h=Xv$pMQ51Y+Y|(dj75V)P(qU2T1Fbwnn=g%zQ^7 z(eU2>a3M^Jw)?&?whl z88Y(Su*aOniAi(pF&n(*SjFCDJ1#L(!%v%#R6d76MF`oG&mNQn{%$vahh(dzAjSI} zF794QFzio7cR0>g_1!GYl(fDfraU(wen{d4Z|3t6#N>H|2+6yDb#Spv_@PTEWNT-j zD2X$B7wcl`ZQ=0o5aU(R*@7NVL+!gnRtd9C%a;h(O83F$etf=uHbSPp0^ZM@&ctG{ zy&k#HH4WD;kW&UL!xH^QwTE$*P4KA%Ge~hIp3a{gZns#5qAgdLai3}6Y}b}_>L{}& zwSLg~+n202LhS5kvv(IP`W9Gpy;Dh4vzHNvU&hL13bb%L9wDyCg=xBC6lZ(@?TzfPt0M4*K9vO%dbF&hc~%-nBK@s_s!t*O$-M)rn*gulg*G2kDs+o zT{%b2+8>DQdYu?cxgILLN-QMDT9S2eQO z)k7w4v_U>p$HNbj=2@eRb;i5aw{)&$yWe5pb2Z(XW8v-n@gb|Z2`NBfrtX>9zKYyU z5&4JCsPiqi;8&NJ790!T?VM8Ns6m)ocCGlSIpBloE~}I>xoq9nbRl~Gx*+Csp4}9) z3&ffbc7RKNeePAH6C^ho_TAUT=9A?Cblb`4+QK?(Ew<(09pfPH4KD%M=2uB+0+%37 zhjE_R!+v`98XUw&0EH*hKc=TaSP|u}*07km6+%>V+oxjOv&2uj7KHU!#cx4-25z2* z`xT;+uai@*77RbsL*fFUQ06B^z8$atTi9oJrMIrLh2a?&J|Oh;zhM*r;}>YRQel;j z<$<9Y2QQgjlt>E0&pdTwvPdVSHP$U}HJr)F-IO4n+1Fd9G;9CWo$&hb39C6_u^IB3 zJ!Y2d^T|%YaF)ht&MuBi7s42hRh(UpdyX@MF^COC1GHYT52A>-7n}`eM;*UGWK40z zWJ6rL4h!5LAi7TjLR1t*u8Ti5JG^3tb))sww%Tc_Dh9u-&l-9e1oy}KJ=f%(Vpq##aogOSnSzclnIc0wc>|y4I zsM!_C*iU%Ds4Y$!t=&&xfdltq(afOzl@^V!fjKQf(!f~r~ zY$mK(9&2~*Y$Y;*Xl;pXiKIexiUQnE@0gJQ*#T_fudD|D6QZxan0$+BWEj9_aRAa` z;?*QaHV<^xXOiEYg#pDH;H`lZV@|t)49GZznASx|A+lf>n;)xI$dRquqP!ETL^hWJ zxvK=MEp*{>`e0N6lFxYUJe_O_SZ05Rm`{W_QU}hr&-Y4ZKG*npFFejmYPjt&;kEl6 zqL7qJO?hNPy`^e}x40f(d3O8lFod(<=_Ui!I*C#fZE83GWUp(- z-JQ9otw->tr+87+s+D12a9+Mf_#ZL-evw0lM>9W$VJnXmGlm|ofFeO6ps*sHsl`le zAw^NZai~fwmy9rta(oq{<!}djVWeXG%Ai94ch;4A;`O=H5TG!7I$nE0A7*y_|faw%kjw`5j2TkQ4*I=~*VP;oqaU z)0eszDOX61|GolOZ=I=vE%M(5RdTbnyGOiFjU!+Wq|{5_;e~vatyf>E zD~s|PZNr5cIPHS+vCU|aQ2zOYA+4x9eY;?Clz(cxFY$GEPUA#ZaEzjh+vG?~%O*9^ zwrk?@=$^q+H~5w zl+pkwSN4oZ-5rV>pDx)o2K>zTUY5mbV_I-cUuyj>%jg1($BpeOwq`^{@Cfx?@D3jYOCM@8QZe~iF*01*B0 z0OD@`QLT6f05U5b&ny85dLfy7f%eF)Ek>aIuCMl!pQBMOT65*&Sa(5;V4ZnfdZX7^ z^E2@aKD1l>J+HidIud%O1))6uE-er+L}C0$I#V;N+)hSuvp%lUdpd&lfS-Y z^4&>R%7ETL@!lu<6DfCMGmM*Zt3>mcXJ=f{@_*-Et)=r^#)P~k;BXIE=I4#|AnsM% zUOj%};}yD0LPa{h;ZR;?bb7rpfI*0hGwZ_zk<9_~`jg@UcMH8aEawI+i@polyj0(? zNc!%b{pNZ`fxL%l>*M0b#Va<-oxIl5RNR8au4!4b&+0r^UVo)W7iHrL;de)Ky?yT< z$p=InBUR3SY&^xY(=scX=7LRTgOdn#fF-kO2o*j5Hfx}OwFPUa_IRjcqrY|a2u~G_ zqGB8Ucr`Zhkj~Ne;N76vH@ej6$jIw<#J1M3YY2&jnaH^>V+wnRC940j56 z*E2O9Gj;J%Y?$lX2kPWk0^%(g7Iocg3dDgyntW-O-IErm)LK-~ly#mr4ntP`I$^D^ z)Bfm8EcK{saptZe?c0}oI4YWgq$E$Bq)|{v8Ub=1|EUWs3jRBR^?Xx=|HgYLrH z1ss^$;wLvH`A=X!nv!4Y$t|ze-p9FN1NJb?IAY~vL_vdX^-bXQLV*4h;{!L}%do&H zRYBh4P2$l_-5N3656dYfo6MkMYZlFzJy>Via+qI|`Y?&za;bxR`HZxt6H=fk)fH2a__7?akszamUz?M4k>`BcaK ze3{sA4JjS|V~L2ASpLv2{8&`f3y}egEJHb4=cgVm#uc3vw_RSD`F=ok5jQYF_6O*i zwvKXz6iy&&S?Jw>meWOi>Addz$DUBWZR$3fXxFK_1w0uOy#{c|V%+D&OiROZ`8!dV z5wpk*+)i_RaGZab3B6Kn;Cd0h^7sDKnCM;f8{Ik57#vSGJ7C!wOhWWN8kMb0+Zp$k zCPbkDD`;7&p9jP-JL)m;7|kYj*5Bw71PWPr+Z2SiP0t+XC#hsXaih6?#odk4u@dBG zy^1m2jULUg>~ZN8X$Krj^M#UD+|0Y3fpM0o|f` zod7V8&&HGs27 z);9{sEJvanTwlnqjh9;?!7tJCc$I60ssHh5TpJtl62XTrC=WEP-+uevL8k4fJf{z8 zUIj(mp6h+`d7CH^qY#l@?ez8xg@um-aj!Z22F<4;E1j7YO?4*3Q5?P68sT>=x_6nE z09(w_jyMm`s|rw#FhVNAxNm)$kW`0jbU?Em%WHqLqY7bJ;H;GOxH7TQwB=U*oFRn) zwayyll-9nS{q<57&fB2w+KDhm={IRZx(3}7sNASE) zeJnn$+Gy$Tr;(SrSC2JdLi77AXQMga9B;APkdtkoHf5%NtPV`}R zHAT*fs>Jb8l{_J;q8qo^D+z(h$mos$h^h)upz^HlJ?^<`reZY954`hE@ot`FS<5AuOj#7t~clp5VL>0ZhK zn=(t9UCG%Qd{v=7>b*%kq}YNZvzm4gFWT9aO9QX_!TxedBu4?H!iIvIO&zZqemj_e zD;>^q`b{-TTw9J#{@MPGRdpjrv*grjaVFL7xewJ6hQi9x%7roNGoVmxQ zSli?62z|+iqQ0F?kv-kRY1rE2wMQ1tNKIlfWS5r8vW)wynHN`T552Kh)At%_uA;ZR zbzZRc(8bk4+b=hq&ClA_ipyB^$!IGqc9h$`eE?_dMp7tWtppBkRvpD{U#uOd*e(HJ zD1VH<{r=*+ROMF?I$b(m0Ri@{w&ol7N49lt>>f8+bJ#OD%lRenH-1~PdO|<`R;MQS zj6vedjZ4x!sp&D&*tQ#CopeeEffpgknfiET8?Zm0Dm4|Y%qkb}Qx*koca_o9XpI99 zwmdD^lG}V}kqN~}9Fa;%gEcP8OX&p4f)*ORLGr1l*@LGHR*CIAjyNsdp55*!1jALt z%Cter7SHQ$uSzUWjHX>}+~&J@Rj>bxG8=r*?4bSD%W?H#&zGq+cf;rTbm6CxGjL=! z!3yR{iKdb!Gd9>6;q>N6@%a7<-6hT_I$6qbM|$*`PW}b${YMJZA1q|J2AQFVye>Uv zM}(`kIbP~b=tkT3D8NS0xywg^)aT%({;lBvk+3y=MiqKK87pMBs$336q!@=!sl^}T z4;KT0C}uJJl~zR8XL9{;-YT_--tt;=sF5Vl-yc@qi(+IPWT|)rn#Q!ySrnYI;XB|5 zsZERyl%)7r&9UE<9{Om>$~!7hLj#_)1jJ4y=teLyTT zqS4A?85E%3|Az6gf%Gn?u;l<5xdl591b7KxR}$M+xK{@&rOgHxEjFuX(OpU9e*=-| zrJTcH(^cS+hYW!s$}k$w?a78GL!_1RDJACUQrF?w#a6avKrmPDy9CO+C&?BpqEbeB zLeFzRdilPTr*o_aP+{tr`9oYHa0%fFFU3Rd1z1DUI{s$Tzxl8lv{>{*U>grHy@(Wx z!~4f)@YV!GxKEDa3fE$?X5cx5q!|G%*~f0_<3;xow}ap1^E*48^euP&R=j|<3d=ZZ zM$f?b&8vaW9=}8un_)^GMRV*nQ0UqQ>uqCJde{K0fGF1^?u-j6ucvP6-;cb~>r`xC z?>(B+lE5Ho%k3*usxk8RMAUkXd{bY+LK8Ex1^^(P{K_8yqfPSwP~Vl6IPYpgdqAEA zyZUX@wG(xz`;#>o$Qo81_H-yOfAQ^Da;0&{D#D)9VFXSsqq`6S%+K7%`$|9UH$Fj| zAl}zL);Kj8%)=i&F``$G65LVH1aL}t{?gRGV$+l8Vd8Ai=+=h5?hMv!sRh2oIy%0(xmx$Vvsb&Ba8plr8&Mk;s z3hBR*8_<2KYRzIA8m_WCdw2+Qg)9%Oapxmq2tT7NuIYzS``*_i_>}(KL6W8#Dz_gR^&y&a8 zwKDNYp_SU6{RSDxf)IaBrYLGd8YV?%M3GFrsTt$C9|Ub_a^yqG{@=jEgo?#3Qf;iq zxJ8I~r^y^n8*|%mB9(6O0}HMk{S`TC&= z?5&!y&WA0nY$$rwatW@Ug*Xq-7siTLYQYI z#Koaz$BHmgLJ)G4FUK^6-AkpNUub-aF(fE}^O!H-I$RB)7gftO#Cq3EnC5+wQ9B~1 zv4I)t^VHWLb8cjQj3@_qc z=N~=WO}D{t1PEQh$nd7G)L7&579x~>rxn89 z;S^n9Dbryz36MBlEv?oR`WmQMZ&iQB%x`K_RA~cF&^%v+wtUE6Z-LNT7py3x3mUmH zr}eJJ+%4?IfCDMLV)o{Pg%!X5DIJ~?gp7_9wrg^%JBAN~ zdH4b%dk#ES&CockaMlwcyJPW9Rxbi&%*7ws#svuOc7Ah2@h!duYvnDc$RqT{F|e69 zl>9>yy%+TZN#cLXa-Z?;j!_#KYJNL=j0PV981t)D^8-RU3#i!JiVeyzPR9b-iy+|nU0)Fj> zcRcl(Lmo$gR8$sB)EC5~bfgnfTh6WQu?dbRCprdG5mSF zdstn8{0g)LVyQH+AGJAB>cbf8T&UEsQ-S5{2mC9v0|c8_CIw1+%(H?{!LVN8qwVRp z!a^Qjc#!(?fSPrcd$gxo^&?lxV6E7uG`A9c#eq#w#5^*^B6+{>rG{FwJNEQTb}cDwe9Vx;bJL#wj=?Fm>%xQxs9%8o}!`* zn}CeD#(t+Q6`Vd!l(J%icbe?7cs259&Iu0UILsItyVv;^?agG&yr?S5HkF1wS2vu+ zoW4))*#mNmjd!3WGZEHMyj(VuSg`2i{EVQQ=VINtuB;0nTkhOWJtWu^eel-dwMIu$ zdI4h&@1#t`P@n!O|q&M|uG7fbJAAjjzVG(GXys3iS2L%Ze11a9twHWSwTDHwZ?={6v zy}@^Ua;P8%Sic3o1A?JK0ZnbutG7#`Oh&Erw4(0#`?Uo-k^O3~&$I=IIjhpHgfkR{ ze>lm}n<^2v>+p&e8;r!RY1LkkWU}LwRPROTPBFQekB*dkM<0LT^0umlGhobUYQU)N zG)v%!FJcXgRJROs~X+*s`c)N(FTZ zp>Ezp@g~gViuQZ_Hoi%WF*Nihw;@evyP(ANw45?(boksG;4w?Eq<6?YJ5SEeO}&AB z-wwS6OGsP*6k`GoZZMKzL8vt(`Q1mor5jRcN*e2-a>eZfy$;t0H{rUeuwrVEh&@3;(f-B72!` zKpb<8IQ;6vOzL`u1#0$VP9^cKk_=f8#7S0Dl;w6?C-TfZAHJoJF%?ZyCtM+6VrZpc4z@;TAdQ}mLp&kXh$Z-pf*>T;x%wHz4fYJ0&26-*y8wjQ8e~sYq$5* zSbtu(r|s?qQ!U<;Th++UxSux~tE;Jmf`e2;QSsC&kZD`4sm7^^#=dJ8%B*Oji$3+S zhVJKswrJ6m!vX+dJAPCj!zbxJ8LPHgQ`48!NrFqlwZ(_30_ppyWot2pFAxe2e^{#HV6B77OCOEOcXYmZk5CTKx8_EX33^FjO!l(b(1SYjwgJ#^8g}vBc-MeAnJ=FFeH#eXI{rBX!1#-!9|rgwGa=-E_W1i7}|;DIS4& z^*E@O3X%`CNq$X_%j~8SkB6iz{jeyIY*yn(_!+_`M&zZ}ubSTm@oBW&t1?I$%68WV zNe@AVzCnmXX|eoNqh5&9^+i6L$O+virv=%M6CnB4XlV}nEXe*p6pz0KrIhl)-$k9jfYU<^NQ@9mfsqM41u8d#*b!|7=A!t%0S zAM}fFiSd@?UPMz~z7I-H_B`|jcM^wgl@H4^mb4aSk1|-@k?{&N$~`>{{iD$*S8SLJ z=cY^$;K74QM;5PythggiG%l`nO6(>}94qCmf} z!07xYbv^Cgr4S;rQV(%MU9U!5rU|U>t|OeqYpyh-2BfXH9h6y5w?ur1Jw@5 zGF6^;W};~1!W2`BFq9_yCTbLz865q)<*)wy7?T?58lXlP_wMLTX)t+vI3 zMJhe@S*B3YYj<#YG3BhSsGtP(Z_+U#vG!y-`;W-u+q7+RIy1@`4*>GEdn5G_C-kmX zkAEuDog7p$8r^$a$2Y{jlvh#z;2G9RD4#?>dP*q-sB?7(@)|Ewj0&JOhkDSll8~mR zzp*Q623+wur7obw@B2dw##yE6`;aY1t>{!jK>+25&;I~z6wk*Vd%i?%%;g*fPz~)n z#oIK_JD>G!TJg1uU^K+Qgt;itt0fhS{ zKqom{`R6xaS?%tOxE6RYbkr0tc#3sx7%pV&_)u&|)wAhpZ8E;#VCsH<(s3o^afz9J zlLcv_eEPEY{lA2_ zXe4whWrb`p8|-%&(HV&jc0VY-#dfR9rT6%iv{5-*f$5!3Y!w zSsEiE!BHf>Y4Jb9$jiVlTXrvqPD-ln+QnXbaV%W}r5H>4I6{NX9H7y7NV*05>iPtH zvH0Rnu*+$=pE|FcV!9mKf*Lhr1YaLGo@-m2{Wwh)IlgGukKy9KZ|=%0+7X=O9?2cj zk%ux@j;Q)eT7zzRESdz-9#26CQP_O?0^O$2F67P7K^Sb@lUQaLu)_Pc z#vLM)AW~{2xZnE|%<%n|#g8k)dctBQhTg1N4UJv2=rkL8;ll?;pWAv}G&_qgu|V%H z6lqWu+Wj&*qy}G@PgB5F=L};^9g8g7fYIUHmnOsF5ug{7I^Knv+RZK&b5y?Gv<;;r zeTUA6?V=yjVWFO=tH}@X9z+=gcNzmmhh=>LwO~e(Rn^%_OK6e4JPeR4cjuKr8-?0l)?h;WpWe0@v{#d=?Qlytx(f`Nm58$|TlHivfF! zK)|+S*{wz3^WLRC-LkN#H<(wh1R^H^3j6^ds1vt*@Xg^z%k9_EHToT}hrsFS1}jM* zPW9fZKyWzs6^4y1TsfaEL__*GzP}v8c!^VyJyY_^)U^O5RKh6@7pX)(oLg^Nq+I2~ zWd11!V#b!3Le~(0ht}UI>di@A?W`=Dg^mG&a^?A$J-JUd`Dv~)xQVdQSV6M*#jJ_7OJ+!0&h3K5c_C)aFs6DmpAb16JefUusE2= z7~j1j=@=pNIZ2kvVd~4oJBF5Lfc*W0D|uLte>tTbR-}H|5cJ4B9D0IX@})*mHqi-( zkZ5S<0#Msh3C<=eW@IESW`g5XOu$%lD*IbN{X#?U1&rP7nNswP?&)*cT$>PKb2%SM z_lf9q2ik#yy*r_PowlQCJ(R+?+=2)R@z-*e2D4LIA1J!8BK5JHNBcI(w$(~vB7LBa?1)BOQt~%-HvkY*557WY z4E4iFBpLT)FMVYI$1cR1K)sWAPvl!ufm!#S~=L z=!_Q)N@x6APB{4Cn_bXr83%>wt_dSS7(Qn=yKq0&1rO<*fjJxM|$7FD}W&wQb{bLS|7Fs3MsjoHXMoWz$r^a2LmAtvJ>K# z6QxuHN$(1c9Q2NcsMN6u3yrBbV2rFfnh~h`6o{>;0-5)Udg~TKxhrA&9XzEAfG8+C zHb!*iJE5iz!a7JZ$7ev5w$*>_1k{flx(a{=D*y*CZ7x`>YZ`7+iCB{8U>km~@!|1h zxn0DXWTxVJBNH&qm)vzw%6$n{3iY+rNSH*!TH&!zX;r8g0LJ6DuC)o|_+7=nR)E_4 zCbI)H>LV0kXPiR7ZlFZ!dX*V)EU$WvXhP`-BLHflqya=9BI`Q4wDIo1+5K^k7MMQG zEa$sW)73`X8Lu^LyOefEu`gc%^8 ze(77WCWnv#8YQ!)^;HGRU^0Z$BUM2?%PMrKh9KbCg+ILvkU9HpqYw|Fpq{rdaWmj` zuWW`6;Dw-}YbAxIE)a}fjbsT@TUHgt``9n>UgHj^TV6j6@aK2!oxjOEy-l zBBMDPZTF0=4P>A!=(n@(9)e^h)Te1FqyPtVc;~a|V+w%Cm#*lyj@2w6Wppw{6jg&Qke|J(4Jh z8TSE_ywH6H)@Nx0m(qN4ea;HPVGgTP_BD0*u2S6zd#83&E!l%CY9)A>9q442-h7(V zC|D+Yq58BI^+|WKRA|eX-znmb3mb6-_&*+U@2uuZe91aJw@LVk-4HIivr2r_8w@Tmk!Zth!c~L&3-lQ!YoQ<9p<8DNQ9z> z{OGRs2n+hOI@BWavQGw$c@8tbp>QD|MJlpvpJMI)b6nZ2SNl&%jb9l#&L|VRh#UdQ4ge)wbGdDejX~_2nRG~~apmuxf+7gd4<=v}v;GjA@Z*tfkA(bKK z=N}MC>MH)KIj>YfMsm1npo+YN8g)+3*V;?$ZLU~A2A+N=MDmuXoZ|M*N>wIS#0U4B zGN7eCAAsKHw5GOXoNJ&T*-@$S=*@8LhuG9EN`Jv>fPuz$DyS4(7sEH#<45nx;|k;;KY+t>+N=)9or08 zJv#$gJZA1zzNj}o`6jvYsobUh^SQW{v7B;5hy^)Tut4vqy; zMiz_nX3{Z2ebGmj0uC7}xCVJ&BDj#W3OibbSX#0G>3*n+KHZ*GC?NqmL-`mZ135qw zSm{O0#i?*&Mar`3y?BEBHuc!#V-@B@aD-nlie1yu8EB{&g@AZA$vtZd?!3Qv4ov30 zXkPlU)u}sd@Cr>4Xeh6RHY)J+nJ=5oo(bwEA#I@z$od-&0@-I~jM zUM<~BvR->B7-r6FJr>~7=%Oi5=%^hbDxM$3LhvtKs2p3l zd;8sCWh7KYS5V=FXF%!U*P_$;eA1C}sLZ&P)rC`11NaXX{szK6@PQi>S?b+8uirp7?5%9h#0Tphe+rD~(1)(wAP3{0lpbCt_ZS<*{S0zKDv+CEC ziH8F!pgUhoSnC#=*XTgj8fdNZgsz66<(CL^t}}QCVcBjx#0?bf*Ig=mrk!qb0!B=6 zCw_#!%p78_+0$n2l6L=T4dlxhcI&P#!eTM1$23IzL zvZ5Bf08u3^{Ekce5o!&_t-u#U9H-yux_+Oh7y&kv{< z;>#~Sf@^ghtF@|rKeZwHTIxdEHmtz%(=5r+|Wjka?C6u zpqgz<{DBmTz#G&AP}NXF^;L#$;CubFJ^d|>NDVR2wulZ^AhgM#>F?h78GLg*B(z%* zG|~oyL<_d|Yb(D#1vUFYV`g${OD_4OJe}|45-fhbMHCbCtKmE#{SuoRkdbUQ*vl=k zj|h7pPXUp1UVr!0WH;_+a>fnwr2>9LorVK$`tki#Z9yyDsarR~GOmyjTRxxc5^!*| zZ-@yHBzER%)LBNq`m}^FlG*K0>r4L{25q zUx5x~asUL!F>dv#uko?D5x(2UQ!(YTjw&ny$v(rQ7CccUoVHP?`3`V!6@&xQLjZBM zW*Hu=YL)N-N63aE+6kzUsiBUQZmq}RuyX1c{8LTk4DCyxFO1W^I|=H4Ca_;2cgj2!8mK8~FKnDf1Dy%?q`{5W3Xf^JO z`+tnRby$>J+cqvZ>VP#2ASwnRjetm}h>Ua(U4kIpp@Nix3P>p-9g;&ymxKz6L5GBh zqSB#K0wTX_;da0K{l4S)9p8V?e$<({*S*#i=XIXvYCl4u48|F@*8kUL@xWK3W*7n5 zXyz{_h&Yc7>YM=&_tzrlDWK3Yv;nF&1$wA6T;)W`%(ECQ5#*Ji*!}UdzfuVKjOpLW zgQ_PpwsaICyLg|pb=KR zL)r%LR3lWx`;lDcx#~L$*oWZEWEbP8D^K?4z(`MFR3*MQ3jl{3xm3m!IdT#u1Fkul z$~qGzk8n?uq{}))95F@4lBO>|hu6$M6j+yudiG2Ua|hRrz5gv^KFzz+#)wUk56fw2Gx*B0Q-i*jUpBuDrK) zVSFTtg_er4K#ESw24Khw=eswuA> zK}6I!^8E(z@-lB75Myr>ok*JMyl_wjRn|KTYD9z>XF z_i5WB-I8FsbZzB# zq4`c(H)#IAi3dnA+`CDN{TI?E>Ef2vdlcbucs_=?kI4D2{K=HMEJ9wA(Z<7BRhWM+fy6RdK5v(&liT5zo6nX!DH~}B_Jj# zo1)l}{F}XGPrwO0`+AGzLG5812)dp5Z5n#A>!;CQ%O#v$`H??cu zlYbbFtMltE68*ELZEM5moFMa^UFn2-rz~;oAgiH^3nD5qLIhy1@fYr9Szzef<6ipoe~#UE@{RfL>t@4$xd3%eNCYPanLCu%D7fMfyr%vU)0!7_+pp<8t_#Ze zA!+pd4!6umAs$rd@r~b?KiCoAwfEX7pPf$-32(BZ zyyoO|iKy^rHDfN`sf0D-*-mA3_q3M_0;IcmdF-UKk)4D52YGtTKQT~uSHG#>W;skK zGoe@i^I45p_$>ARx6k^=H3{XJxLrIj=MsQnulw`;!tOQu_TV2HG_Yx=LqdCoI{;p#1R|CoZR7~&%ctm1rD?&sLze|xIL6+^N{;zoE(;;P zF8Zg$kj=si*l+Ku7X_2I^A!!lH<(yu#poDtZ1(_QO}#5R_ipW8NEfSA>V+$RUSj{x z&ZF=Ww_T_lBBDsK70^SsSaNp?fK${EdWjp1JN~pV?uM7j+Juzo9m+L^pb4%sjwMIS zzVH8S*&hoOA%ie@T&%m{M>MWQyfu($)z$v54?2x4?4ImA1iuNYLD*PvC8QL=?ccP& zRRH;x{LeK9?6Q}@?^+&a&%OqhG1`NU|%9lhF+{pWy^@A8Gnkpl^y5eb-g#gcNxV2hf*G%0pW3_a2Dp;7y4f%OqG z@~_(^=Noacz5-8(Le&Mw({_WnO9^7A-Qe6&mFZs=%6S7%g-tBH&z1Wh-yc2Z#*XpWV5W=vXw9nFA480oejoa5Qa>)O~bs+ z21HrF`4M&+ixoUu?|FC=VoRk$e}Z`m^-lohJC=ZdjB4hz3Ds%k7(A?;=7|2r3!3fV zvl&T_;F|47S$}3W>Hc4jI1N}eI+na++i^#l0RsqgeJ}V7FM$Vs7TM0-(m>~y?s}@x zL+iXFfP7a7da<+vq9GvTlV*eTxD{8wxZ^>_>)$Jk@;E$vkkUJ#b5UoZ@Fg&D&r1U_0UbrB^7vF(muYpV^6(-huwR7wbON z-NLC`WMA*S8a~vmr?vxgt@7ZGvllQIo2t$&{yOw<>M$1l%@bsS>VfJ~2XNT^WTV1y z>H3GM19o5-BF~dc$Xnrfj4z&bbib_8fiU zp3PsCHa)C~f_AZeMaIJ+v|LvRp@%k4Hq#3`x;G9FjXt|_aC|2j7Q9?AIGMd^;~QgN zHpcW1KXN;Od8X*!A*aXv{0YO3fHTzJe2Veo6=#M2Jjo8s%~Q@W#5O24sV@LS{-^t2 z%SSOZmH9BgZDsl$;qT;kV13PDq}D^H#GEh;Q{)2GWU>BA2SdAeMakN^>WpfK>w#u7 z3n*m;U`xXWRgPhEj62A%vf(>q*k!E0Bqi+n%7dP~M(JiedRX}s>CS`QuR89XLVpb{ zXR9SSfI@-w05-3{lyu_9*@xbTu;1)ccTMh#=Ob^}6rG%?s<3-E1n0mJQgpZv7*q&d zoi|G4w+cs-ah-e}?$sWWHkV<;jT+>|y8={f{a`%H*%uGO(YILK+Ws*@zry(#A8V60 zIPpB)2j9(b0CUqfGJL02KlaZr@%N9{WSIx~ozUeM`|>rjDCHR<1;|q79$ah*5S9Mt zHJCl$4NzMufDWTR#WQjqY0KWhsfvjksawBZLg!_luTxkG{cNk}fZ3g6kN0T>qSq~f zwn68Y`k+&ck!I)z@Jkri-hn^CQIelOCx6GhOPPFoBfvY7#=0+j7=i6@8L$;4hR=NR z0>2D>B@b7M50#`S2@?th+K8biVEV-Ek?s2sPS{1yJ= zpp?GmvADgM8yjwwsiV>BE0d2RD&b}ogTYeEMdio7n_h@jV+J&YYFb_s2|DHH$I#a@ ztL$K;VHB{j`Xr6DNbp}se52eE_Z$65V1RVx$;{_X{-WDw2(5}JIqCRS81We9s3yaX zl#~nE(XkSB)@qjS^Kk0(Z@)Z2u|C)ub!raUma?G=yI}x&?cT(4yU9#G%I>Ti}|d(CyHM!Dm1Ad0@rGbeD(C3 zbHEnU5Mr~tg(AfE!e$qBls=blT5YpD{H`?#efCdQTxp2QhryUfOK2bgdlKiUdKsAsFvCLKrCrxEY=BD=iRRFf6Jl zOyL~KvFX_KPb=zI=(y0fy}T=;MP>(i2@CG_o~eOnR-gVnVZcM2UbS<2U!mbU-_2!R zp;wp97u{oDmHCJ*i3r>OvxoYNjO|#IV#~p=W&mvJ&ELfF3b*BfaK69PQfcNxz4w4} z(~a%b`m=`|*fMrve=>dC81H`fWO5IRrVlABHqMK^wSP$e_l^F>vOAa$S31mr#l)RP z)hUkq@IE6l^)J`4kN!@Yn-C`4isjUPE#xp{@%2qy)vmpV2H@TTv&HeYGJ^`+c+GLm zQv#Lq?avr-`rD5h<>7cJ)*DWT(fA3nE89`RgM838Qq|{064~Fg!h^pC{?@b5KUV?W z=nP#>N77Da2%7$nk8pne=V|d&>301EC18K^8eG!_?7A|7sHG2ryM3alTXqb)2GeJw zBip$o5&|}r%&jHi7dM+cQP#l-qs=RVYhAhJe(Z4B#lNrhH>-M~fuJ`8zd1OIyD2c0 z3jS8!Nv9P5`9|VFqDLwy62EkR^aYJqe}$c{VWV%opk4o45Mt~c2GK``g-ky=JVg`w z%C*DN3 z)bM<9xYlE4HY1d$6m|61mt~emiblcMD6Y49N^JiG#QXG9{x;t1Vk0-Z78y?M)KB?mQSb5xz}^@D;ODYZ98X2E=MYCbyWWN;M7{`b*GHIe^$F21C@j1X$It&sy9_v6a+FbygI3cM*6fZ~iZYKkiU=pXFRrLT#J}@t&0%@EPw2)^ zXL^ZikI{t?NLV_kz?N(Q2W9bKaIqn;r+ll8_{Z7Ow>i&%kvXM>T`mr$mx-CUg{AT`T1u1yqg7S!(#HL>5Xj}&t$pzOJ$7>pB##70l^sw5& zX?!IN{e~Bd!w`Fo%X56A@vF5nMP=}VN2BxPIXdLRj#?Hstdo?&#GOA%pL(O)|2`~$ zaYw*DDrqbM+ZJip#f{NqLvMZgi1Te;^1|>Pl)507m&DOQCKrdu^?tz0Y31I&fVcQ>n`_@jOt0vwE&RLN zf|^i-2enCYK9}4rXdiy|V^957NV;nJ>ZtcGB4 zc3XmA8R+GzqpRSAdc3>=ZrwsQ-OMPS<P9t z9Zr(Ri_2>u!CbX*rPU`zSKm$hzUZQ}52C6xmb>Y71FvcWZ;FevX8+-n5s9sIp#1pQ9`f~`%;W;KL7Uz|@;dgMSz(jMWMPj?suStUs zbyuC}j+1h`9*?*&*Rh5}f@gRvx&1zG$S~lJqQiTk?D1h$=4ASf!(-;J_)h)Vgb(9y zeT4*!laKdj;stQT99@y}qkV3m!MRsx3=H_*KL8DNO#}->rvn*3jwcxMl;1KTUD>hF*%>j6GX{+P{*DX+_yQ7 zCG$A|jBrQ9LkfTU&}R2}s9YG>`7f%YyhG}6s$Gbbz7DsTRBwcM&JEQhqy_g@ffBDA zJTQhpelZJ1R2;Zaj5@)8Wp=O!)DU`XB+>$ilLvg_)WEfYMWdpLOmsUJVxg2fbSaR? ztGa8jceSM~?fXx0jBD_Y)Ci-}{*{Soh@@f`dK8$u*p@x0QjiVs$0$3pbrS+MjWi%{s_W4A*{qeSU^zJd!{@ zq&^BnV0);zbt-K8PPYf9KbM7PJfnZBCQ}J=Ku)b`#_4@{&WW}}tFWVju5YG#^Bw>p z#%11HH!2NeqLEYUzd)LC=-SDQxYFKKlQDp~10kRf(~R(HEJ38M0>i&%)DXCrxJaSw zt_~5ByXjb-NfKpg(L$)z`On|4V!zF z#e?l!(H&5vSHk3UG$iv|4AW9poC)}a)x9NzuFned>Gxqart^!9R7#MWic^o)RzK?$ z%m-{i$y>{<_qb_O^7{rH{|=D&SppxZlG;j3p(Remo6mC`%TAKlP()me@!jbYNlZ}C zsK$rBI#e$FKI^W7m;D1_)u2H;9iiPLSKlUy_CQ>HARIT{mv2~hrL6#tY=5DluL*_9yVFfAnWGUBAuo@37bNN7GksLQc3-jV9G=eT=hTRi{E z;H;1X8qIbxlbW(2;o2%Cl@7yR`drChcmitNCJdm_aQHsfQd5V$G9LqVkBLjSek^Lh z+*3xG&8JzIeMWuZ=cqOTGymWm>VUzY=2?9cQT>75PzGVIH_(}qzQtQBKFzOT(VRcj zd(aPce39GF{8&efE~n6AZeuK7sr`i!qdE@W{VuAp?NI$Y4jKsB!%#kbKb5whk8p&Y zJ71_V`UjdGM4Y@UQ=s=2Q5f2$gHk+)Y*yg^r8^uIHR}xWCcmWC%ym0X5u0jmy( z;T`2A9CfM*0nCKHLOKdY>HVN7#I$HB<=Zy5Sim=E$)gJ6Z14Z0b?DwcZA<) zDQ8`;)FN1$M0^GJ;n*A3y1Te3*gv$D=sT@tLCNilVIDvtSWg&nwK#n*`00nLG_CC_ zO?6^Tg$lW0RUj9Jh4l>}ZVdp$GdC}OzpXthx;kWB*SHh1otPWTbiCXv&w^d3^1O*M zaYJN#i;bP}M#HtS*SNdrFldyr+Cd+36Pf@MmfY+72cU91K~7@xfT1J;N*uX#i^_q9 z-cNC~{0sIMpa=b+HimUkbVfI<-Tx4I+M3xx&-RuU)v;HVPzj4fxxp}GJ{rv+ct(<& z)d+5!EGe?e6rbQ`ydb4J-e02Rh9Li59;v%}m7K&xpxNa3r1dV*rYBn;^sz?Cr9f1$ z7m8t3Nf4n@es%`d0-ogx*rGHh-BrB>yX2>IG#2fV)X}A}D1#F7miK-${e^ZAavM23 zd?4xXl`R&iTO+6i=aLSRl&N0h(&LJ)QG(96xP!5LM`*UU%TTHJC|yUcDDzCPFs#D& z%5(X!b13DSR_hf%U3sbx3&um;c{@S#vFBvxQ*aiY>@MV>wd+m9NSk&Yy!Yu4+SGx>Z=-h|xTC{}_c&MJrd{zedOZ~R<;DoTT-*R^sASyGmZ@^RU8Nr=1tKP(X0|M~gf{{YH)ad2& ztJvBJtuXbhcYUF!Yk4(2??Sb?u}2{Stx2goT9cnsMoE?Z6%Z#6`P@lo{FuBW_27d1 z6pkEsmyB$5m6K2U1s=-ILR{w+y%wx_ah zMMmU#@^F7Z9ck6(qe?I6NZS_^i)Iv`@}~a)N~`lbYr*oGx=dK(KXruVH)^uvSV^&35 zW19ctK8UH57@DquC)eHTfJT~}M(4q&WP^IDx$dXyL*KNLRlaYR3`S#cTCUpM**g^d zHu@m9VdYzbhvKg!F%J^`I1v6HL)Douo~_aWWy44xg*O;>7#m3R9o4Gugg9RprcDS& zS`r=^*}Us92D(!}qjxVuwfLdnknL}kKIOt--{(q~05Rc7E|qm5?CTj*LU_lJR0!AV zT*s6|qrxvD#VzofXv=!6YrLvocBO_c4x;H*gg{UV1hn^*C#P?CHi4S;spL+FBODJ)C@g9|D`V3Qx}nbgB6Nxc z$2tTT{232@N=wKPp!Pg2=~bTC0dmyK5KxP~8#bX#9@#w&$doasWEMfk{cghaUCsnq zmBdVNC3qwW!L{T6zcBM`)0>RAS;OY`>YppL_dUc;gCtYS&l|tH{<}9VS zA-G9Y&-x@(4uWs)Luk>U7y3*ULzM5i$2!3grJRS|HAH3luM zko%;1TtCr<8wrf=-J{P|jpH?)*L<>m535q3Hsy?XeN{*DJmF%|VaSDZO#J+V<%fcc zLVA+`ruCSAKRCDwt_ZG;9lS3TfA%h4h$5{!{f=$?kTd;~^fkh|udIYg4o_bw)?iZa zr794URwVGG{3kwg3tjVAG=7Y`0Ue{_xkuHm|Ey09z@wr06M6ggdoe0-xPZ=3w<<0< z1V^Gvp)nJ4d@r*4sP`vHl(Y{&_}YSsuw{k{3K6I<9a|2B{OTPp^qPDy8P6_I=zgAb z>%Quu&v#`jo}|42kEsAB6$(9b5|)7u3u}_Rcjj_9&fnjZJd}>t(?;EIV@`P0lyH&M zAN_myvDpQBLEGqI`8O6?nDn7oawD@BMAM)tHZ7`jgIhw3%kSCE&UCedeXuv+>`$^j zjogoEcIdMt60^@9I}YBY{aH$M8&J6@3&qt}C?o`DtLRfaA_)swwBp3UXn~(^pU*fh z0O~Ap)84C;+$m@oaz-B2+QKr-FD9#$kK4tCDKswqgM) z#PNv5A5w$H>t)ZphH-_HpX%zG%Bt3SSAEj-HM^}^hv7<3;JmQ;6uw)l^-~vWZmuwF z{491toA-(_@8Rom2}^5HLLb6K9XA9!5o_}=D8441B>wtb9x-hFn(y*VmPg%#bf6TEzx&*mdUQ2;}jvTT(^F`nS%dneC**qkTj7pdzWIPjMXP?yQPK zsf$^~lhmC`UjQ_U+k_aOz3x{2lM3KpU!jN}AwC;!VjWIHIsKtJuYaa_lK=!2Wh4Tw z0^F<2K=l!7PVd0^=VG3-lO~)!k`JcEx#~p)WggA873?Sf&f<@&-9J1LLAgx3pOg;J z_ve|~0*MgJ&l|E`qyh>U

10pUC8w6kdmV<=F-4XQNPDAjryXnRB?pZh|!@_cf%C zVerTr$u?k86wu<|f~k%@fa5O93NbvDk5v|R=2QqWioGKSTD^JQ<*ZEVEFnb(6|aLI zjlt-Ty{4kKHsLd9Hq|HZ_BG!qZ%4L+PF|$r>+q=F)GQT@oOPV*TFas_A5>N76BOI> z4Z;*(c>{h=b4YOFe4082TtXzofzh8NuNHa|hC83F7TJpjpvznQg~mQ{PcNJoe~pQ{ zwx)1|UCd+pOVQRYLBdVjV#8IR5T-sK4d}0pR9xz5guPd0%NP786LxNw^*8}YPIEZ5 zUdm++dI7$BP^@$wczzAn$_y1;e5TSUoE_qJgwzRUvdhvQkfviO6N~9T!xhe z;?{sCRziZQTaOyT4ePjP2Fm%2eo>2$Jr|Vl+ceRsC6um%-l$RQjbA&+uVw;u!#tqF za1^}H?~?Lj_^gVP`3ckj6u5qUy#ji;y4;rzf&>M7?!Mx{PQX)N75drP>5z0C@Y%yZ za#|hw3*xEMeJ&bI?lK|Vx&irJ=*ru}J%?@a@-eJxjXk%=kpOaDv(mf4KAW$_%TOx% zEe1dJ+roQI(`>qtmH)s?ixhvR`xW|IP%K&ap1FuU4y$>=3eOt7`%elR211#=L_(Rvsy zX;b1HW0J#d1dBYu_%t zi!%Uoo;d(9oi`_)&`1+CovPJ+2FT=B#;M=3_M-JOy^vaUi}&FF`Q=)0Oh3$pYQtlnuwedOewaeQ)GzR}+Ag z6fouDQDDL(4&Zl^vq7FFi>|2=Z$RFSoAWz;8p>%swl4X=esu)2Z2y32?X_;GK^>?E zmh_(5NPOJ0jo5FoKvg9GGrxeJxSt4e7%~iwMu2qap~&i7zfsWX^8`&pH8+k?Bsk|u zWiwH9h|Qd?D#;d$#%D9rD|dEyv-)65RlrkRbfX zO`J?cS8jP4jA$M!mE}i!G$eL!h1|+8v`h{3=hiT^38+Zt*sy&>)ObW{gS=ZoI+WKv zqfqd{fX4CcXU?%zDB3E)uXz9(O_PsT9MAf=Dz9K$SC_BHZuN{2nyQ>>c)bdQw#Qqw z9Zp{Q^1MMDi?_Nzp9D#UkX8G!Rr75&S+7r&i9pY0qRGXcy^eTY?H(iEhP+sD8S8;3 zk!z4&r|{BPl{f}8E%}4f2AsxsW>*P?!`xyArczE?!wb)-&FcoEj(PRI$!O;+>n;#k z)QLU7pIdfv$mS4}?s|x?WHwQxiP@1?n-GF2gtm@aWdUUDY^93U55DCUh~WhUvuzy| zKSs-EPs%5sGE?nJTCZwWJzl&h?mqd^HpW&<)n{e8?@g9r%(uK;A_WP*x|E!gptJ5r zQ5K|-nqb37bc9y2)_f|;6PtI;nFh6V;B2j18rs2a_;9?w&=Y%XpfwtZk z+4f9^l}@7{f~SKVeo(AsO;;&?;VDQn82B#Q!Amyi0^vru{%*R8x4{kU&qLv37s6w1 zN5QZ`RTtYQnJ|ADjm*4(EO_kfNZC|_0*B)Ro9PQ*ceyua*&kSM&{=AjZ&3xiM~m8z zhY=(%O4z|Dkn^_10?lGj$%1NOcAu!}^-VzDFQHMUIs=)5J8EJnEJBA@{NTt?%Tv+r zKxtFeUwbLQw-J;tH7de=888^Bv;X~T&GD3~`Sg7jrT5h#%v#Y?@D&b-lKkO)w5IMR zM~jo0@XT3`z>qMePtjw#d44SWu4r~@$rcL9#AxbxMqlCVKN0Imr8=PsL+NdDg zXGC3L%HlOM8ho@c*u7=wbl=vSY&_@ksw{VBAeK83$s*f-I}DY(yqsq1>Md^8T+VW< zR8HpF>K^T-(Y^r|>_cE$qnW1WCjFBF6;3>J8Qmv(#x9^8eA2Z*;2r{UYh|o&MiqTl zj4D3aXWZd8q7wV4XeQV@pt(Y7&`tCJ0%>>vv}|Ls&X$ZtlC3>B(_eE;0)3Aw4aNM;T&$ zG}^f|ws;=x3yO`p?m6APinAn>`dVWs0#Sd#R>WDK;L8@k6*a9xbze-*JSlK{hzRh! z@>sg8R@McTUfvjaQtk_26nC5WENjOJ>8c2-vV2(v$iDAHuNrB}r9z7Ez}7yM{wB2A zo>cJ$bs1;hL~~G!QHMU74v>Uo8eWXK68wt9o4v+aD$7)5FLXEZmoD1?0D zy2^ZRaS%8>nTg1_X-QpZDpRoT0CVxrX#%Zqj0!grn>)ZM;x*_3R9BfIy4Mxibwez} ze8BOinx+kEME!IoLzz8^6u=uXV|!{iRb%s=;iI8Q$R0r*7$`Mt4&K3$X6fgKD&AZ` zeQ4c+!APB;ZEtIRd9>-_7-te43^3gcuLNVcyvKLJvNq8HEyt933ULb{M8Ha8l%*4A z71>0=6xLtviU^ed(->x7h4-3bh6FDuNU|#+To?jI$i%cdx_V;;C~&pxybk3Yn}xQr z)4TR(z15Ds8K~U1MWmU8dDU#QB88b(z-KgkiOO3)p%tCe+y&rVt-N%wtC&f~0Qjp- z$({|)o>mi7p>@cWqAg+nRQ`BOkx3<=*Dxd?LzgxtosL+pb5bRVSrOT9_QS-zyf#|e z2X4L?hFw23eO>WJY+ByY?2wh{GkIQx&?mTYgjmw|-6_<}3dv{5-mkNEyV+q-K_*Yu zd2Rh0J%KebbJw28P*qKdYJcO!UE4HFqP{9*Ue@ zQ4h3O)0X;6ZJ35cz8(-^oIoMl^}r75a2hn1y@1s=xY6g(2o78C>k~|DHMzgZ_9)5K z>bxUqzv*+*eML4>%#nXFd*-&>>z}8018BDo>blm4_k*@0H7q<%n0)AM|BJE{SGH5$ z4eH2`S*;2bdiYM7^YOUVD;+6w85r$+EfuEN4B40r?kGx~3HEh$jXh#IDw`bI<1oS+ zZ|}=C%9#3zO1Z43^BXdckye3Zu2odP+`1wYJ9&_BB>CL+wqNhP=K?suqNQlRk z_*s5OOd3gaO5(U$=pnMco&8pZRA0&`slRa_BcvYY+QX8j5b`1h%>Z+y$xGRQC?Q zpL<@02#c5Euh_$bz$A(D(j%{uwV`+)56lXQ`^|lc+q21yLqzQrpHK> zTyUg}z0YT?Hi>d?co1x&$b6V3x%Iieh0wTtngjHx)%Q#F`D%D69^F3N7I*wJ4%42` zo7_uiDKECQ1mgXakXU2MYX)+>Aw9whf=yUx|8KP<*Ve02v(OVv0Abi88FYV3sGpA8)B;ABzrc~_J3e4;&#Km@lp?Hg)DoFU87kBj7nZ#VsmRq(^|ZFJCPjf) zzyx#*o<6~e{dt0%ZFPa8NBFN)w+*?^q+o8gcL2`ATO}2?03wGm zHGT4WvFy3z2f3*vKQ4y?+fW|B$~V~!KEqhG6bW$3JnZ-vp+$T>GrEVS&sMufqJk>n z+^#|g3bx(D~ih^X52MKc}Mr2dvo$&EE zN6Jel?@c~GN0z1J3s~X%P@{6|?a+{TD1Wc`KM#Ivx%9!jQnZOl{GrMxDbu^y>gDq% zyJR<49xqz?qK$t8nzrThQBr>k2Y+d442{NTE>89|4*q>;r?gTX1}am2@)6m^cvs=O zYiD;umvIS3nO8k7$E3s`0_srEqsjZ`mNkg;YNd(QHdePZSNFUF@KY^a1@w>RXbjFP z2^Ukn$|}&^|KqIX>o4TKKox+Tx)*XP@8Ky#DEpIB|B3Qr($ZaFeL&d?&FH;7Ug_I> z%Fo?`Nz>hX;>%Vd6KjmGjz+8in&_W3$vsqH+tZx^vv+42W6grobZDZcOqo8GM^i(Z zC@fzJ&UXJvqF;Zy=i~s)zo#2+)l{|aTG$3@c_A7$$af$7dkX&kisqaC-7m9;DwRbO zMb0&<*H%bTPq}LykNC`pyGV|z52cD|bZ1M7|2-e~zobv-RSQTgWIfuSo@80MkLeX+{Fs3aqu9FQCq$fO; zSru*(@kIW%IRE}nD#kPB%~<*~m;6;yBt)6G%#Rr2A)}l>jHpY)skdlk%3g()FyeH` zNm=7_-J2$IWor3pS+e>%=Nnr8{R2!0vsNtJfYDdmr#k|_s^xd8y43ykYL`vC*? z1~7IGB=6;ba}I+oiaAWFOQ7hFq4`asf_}3 z6Ue6|=CYg8J1{WbPpu?hZ8pV%kY_)lPdP=A~2fZ~}BQ+nYNa1nH} z6loE|#s2BdUE4E_tyF+kmEMgO=<}lpvoYM8r&S2iIZ;J`N|a=+;S>JACgiMk&-Yg; zk3I{=`f5t@#*4Nr9}euSHc0XKw@?3@Z$Dy)#Y*(-iY>4C@&aw=A5R5LBPK_wIM zjq`Jj0OTYIj?(GJI9S^$%5-`mduJ+pXn{%&aT{a?+4SaW=m3onjYJi8CLB^wZQq6N zUaUC|wj4;-P}BPhiq#qza}K&bt1-0=5hjsf8V6_1R9c?s6$<MDT(v&`QrL$1ES3;j&+gtr)FnPn%a@Q0U zNJ$UQ0<2yNV?crWa1n8Lly}k%8K^^OA^aNYF%kv$k9sdiB3~kh`S&%RP%JqeD$zOo zXbG?rxbcH(^}Y^(fQ9f}Jjbm$IVAS-^GrxH&|{e0UsuJfS6*pLKx}8N-YZN;mw-F( zJjmI*lmV)n)=|)N9_djs!pkZ}vq$yfi)zM?Cc+p*1sGCOZoMUETanr8{Ne|w|1)2V zrOi=GV_Po#HyGfU$QSRZRh+GhDSI7ed)r#)QxavtGaI@q%Z(V+QfXoCWXQY>qqi!v z>R{!hkqhvTN8vat&aRI{E5Rg-qAjDqX_h^R<3bEl59WcVOWl7`>V4J{k$D*GzpRmj zcSwL`UmN28tjyVkNaq{Xl!o3#gQ}6eMic?;a{-e($QhZ=03OQrdv&VEs*I)zeA2hU zS5LS@D*pGm#;tslobGYXwn8bcH6FcB=!tZQvzuI+_I+h9Cmbx>2$}bg` zvJ8#Y(Kw0i?zoc}m;)_4`aUU-d~%sFkhLS^Ul9okwUI95cfDVoGWj-62-6;I>K zaQV?Ia+V%*@!nxG0HdGuK@AG7&zP#IXDYIh&;H$Z|$z1bbv<;fx8hSknyCRK`?c?(vy2^-8R zrh}{uRNoO98p^Jr4>uN7u9_CHc9jCpzUW?%Y%9<%x+tH^bj8P^<8A z=Js1hoRBo~L&RRFQtCR|UdM1j>!!%#wE>>cB_ObBB3$AUzn9Ri?PW&@CG9^Z(g{V% z+;7v%?6>YoJ)k(RcXqhlAW`7ePIwe@dv-uHC_e8W! zaZdBa0c#_!wFe@2a($+>yP=Sq>RR^_jO^F?uf=A!JjS(h2EYaxhG%v}?3lR8>;j(K zPZv@5C%reo9*-gcHr>lcR>M5r8sYrOVAFQpSZV3VIbR7GYt~CWR{dD{8xjBw7|*F@ zH5P)RAu&`|ss2*h*+Ythye^t!*?}q)aa=kDj}f@vcToGNjbMFln!yBKjKx~7Sw7pOj|Bnnl4iCCRW%!gG}w87e1hFS6KzlTdu>NhX?&jLlU`Qs9v1k*hcg!Li5 zI3RL>QQ*4mvH1PuL$lSObFyzw66L8+7E&y)ktbRU6>M*yC3nS*Y!w*;0kkj~=YzDr zaQ+DvOCSl#8G)#EaHOQR$cu@J-)O$gZyA)#0w8nPw$|JtsMF z%AG}r)kNqg$Xbk+a;G1VEN+iGgOdi3&_a!djDs^QUq_dK%S$VK*nK&LDGy|)8^^i< zcXM7{e7Ab@MFy=h&}=SUJoZ=NfJ%#iCP*5r5$k1D*@mx@mEbo^oDF}AC(A1BP8@;R zss}wAC``&BEQlzTndxBzL;bt!kSI1W%VOWVeeo(J6g>07JHF$-DZo*p)4^^6p}>gQ zkaZYl#+N~5bM->xQQ-%fQ{sl0i{Zzn=3ja*Pk#-3E!(qMBdZ2Tik3jGqP~1n^cjw* zMWFm&9eX6(AuFkI+z%xbdDX0bZOKkh3|15Ca*|v_kT7o3F#eKb{j$!x4lqvoJf7A- z8&84}55E>i7@kG3I=YwCw*jR|b#HBOw!9`Gij?=F)`USRbEWN2htW|wMuE737Nh@U zNhUQY>8?|whvy1^%R}anX8oLyS60rE(d+r7KG`_&j8`>Q07%_PjRm3>*J{=m_NZob zCgv(Q<1rFca7covWN5Rpd)MO|uBzAm0Aglu!>=DoY_*)VVRzNl!exci85jaw!Ig7< zSBtEGwYfQ-A8{*W-+;N6XveDdNn%$TUX&$edLg7;=eSx{SIhuMmn)i?QYGUBY0V1h zPO@ZkE(WcOfWfJe&_gI=Kln5(jhzPy@B5Oqi422uxLUUakc#&{CEadx2&2nDKw~1Z;w!50d0tL^siR<AkYlSk3MA)|9Or zhVeYp_wIJyyKLnjNv#K<_Z&DH+rD=!dpmF(h21ChTawm&4&1x#u^KRh)D zJT(D}UNf4%V%f;DB2+UI_fG7K*Dgh;`BnHE7hEe+${8JIT!PU`RVYs_JN285R4LxN z*|Xjt2`GdVon4!WO%J$a6^Wycbmkz_5|m=O&6!;2y9zL9O+totzD@=FsM-#dD#sC{ zdiAVY#8W8r+xlHJZnZi;lKeCNr^M95s!`wJdV2;wt*%FS$>pnA+4DehN1~4Gdq--8 zHHoC|Sx!abWp*!jxF?ZCLS`5L6VUm9IG6C``#C?W+qAq|&qxA=5B~E` z9|K8Ct5BUF(dn9qx`!&8GaE*1&@$~k705}hEJ%6FIz(M-^SRgZv?!xMkB7o7J?hIS zk3#9S%|HKJc|z%hO&=D1LCW$oh#$G?zQ;$(9KKNd8R^(o6|UCvtEoXl_XMcg{1u;g{=&!b|H3=@KrXOWaxIA<2^f81G@laobxsg3w(O1+EJMBEx z7TM>e>B#Ac4**T z;OVS{@olx`t>}>UWtF?C+Fjy%pL#PEl*~dAE4(n%VrFKO)u|!bfPMJeDgE%|LuI z?H1B13qfn4>u2}%b&O6abJy=03k~@3JcY?M7m>J`=N@hyYKKAinTcoLKa;gVQ$CvH z;`Kl%+S;fE7iuO>%nX+0i|kpA@3D?=!72T+sUKO9Crw{ z=_Q!O|2F>q@r;3vqx<^{*WbKY@_Q=e@ZO@ulie{<$i93(z3|WhmJq|m#P-Szahm55 z+lP|j4i+)WJ)6k(R7Zy>-pOxs+nn5D@rOc`^^VSSBFZipZBPUJ^8i3CM(y-{0XS1a zB2>WlU2q?>$kWWaFD1+f``BZs+~&GWE|WAJugSJB3Ta!j-RluSNaf*~rvTo0$JOed z0~5>Sjo;ao*4?e5K&{0hN!yOdBY$ak)~>A zVt)yV`|fV;hbQFF7RR1MK7hl3kjBQ@^6_0D-2Dyaf1eW1@DG6+{25g>W8}6{hL+JC z^u(1gI~_tu0jTN~%+#B+r*FWOYE7{`#hz(kZycXXm@9&m#l;m2EgjLApz6=*AXsK9U$n*#b4DzH63_JnS#Z zR=AI~9A;5E2;}46NkBNfdyOLP6=izJz$^?GC4n897^Ous0sqbspkU5O>x_%Jft>(x z+VhucG0g#ZQlL|4fK>F*AP}E~Ov+)nar}E=-N>M++kaT(`CoY=iqV(6;Q<~jMs$3L zcN|rIXa&0elAYoC4tuJkm};m+|Kw~LrH??m^r)DETR^yUPZFaDczK%|v{RxD^Q|g1 zPW=gLitg{BL9Ytd;f-L=a)7H~5&tFeI9vg3RqMw^n1{14$0Smv+sGevw2FaIz}EAM zBrIV$WX6lb4FzZl{7>d!c!{b*tH;ZB{X^cHDOa76s;XV25vqXqYz$;X^T|ww z2S`+yJ;`j~NClA8*Z%2vhhBu{J%f0ePeodI3o?O0@MNq$vJP5g1Jnu8QeZ!lK%bzm4x*zn5^K>;$$s8eXRI{*LVl$3GGInF6ZY z>fZXST^P2wQlVz|@ZYd6dOi)3bJ4|QP2O(T(Cx6V(_#Mzxh&AZWCiAX1u!v5OxHd- z1afyVDt8c^CT^-|q#>fWHVlUsR)L!1MxGnKHF0js21y+N{`?hD1Bzd4y2heK|IUDG z)OQPTq+`=a`T~ABFTQf>noUyDaz;Lq`0%@Z`riT*IJX$?!0^d6i^+J|>o=WFXbZf4 z6S_Hcf_qV+;4qENRlkD%H|C6ylw`q&Tx7u9o$34nh|2;HUlk9S0si-Ij3Fc~+oC52L<9GgBF*S(?*kjWj z78*J?#3mnKBFCqjnV!ipk{s*SO{tVqlRT%Jle+#y&Yj=R+jM%uWjDrLnt}GyDg9&k zbI}d1fHzk`24r18w|R6B?X)?9-o$Xl*|@! z{4=>vFGx4GK!ga`z_2}w+%cEH&=vcU+GWqx24Tnd)-ZUj}5W%3sQ$k`xyE4n;9rs&Cu8AFjDVxnTVq(e{%$vL`*g3%qdx}ZU+?YOq^ zq22)i%fv#NuH=vdXxRL~WCUCbMpj#Z3~bD;ZmCIB%6y?6hRh#M1v;K5CILojzlv9U zXu#=9n|+<14vRT2of;vJO@}w_k>>_Lb z`_=}-CH~$2QyT8T+-HRPXZDzn>sRCNz zb43g*Y%SqRg~YbhbE8W^%o@a+w@7P)xG<(eq?c(=Ch)3PL3M|Vzxk)|`X_jObb>tn z{W~VG(|Y(QqrlLLr3Z{1rL%=|V-BnWk)r|@`)Zj$P2StCiIRq(Q>ku`c(Iv^2oL(< z6N!@%2YRex(^n_T-J1Q+0eCe45<{`*7OEs*+t{9x3oi!??q{4j1(V&5y2U0VQkzR! z@Kndf;R5A2C9c!roC%eNRgS7SZXlkJioP!YB>s9m)8xK8;1~n$2%|fsuz4j@rf_epp|^>Jk1uuT@r^j&sv*X(}j4Ze_z*KMi{IAJbWA;sPZB#!@#D=y?nS?OqOO6^AlwIWV>@G} zD;06t{yIXi4P(OA_*gNe3Jhmuav8>psYrhL-^zTE&?8Rl64bCfwo5QaU^yy~X!3I4 z-{i7`d>CriqbK*AhsP+Sl6mA{sEtp>bB)2yuo_iY=h z`{DCA3r8YWgQK*wjeZX;d)1fBrgZ3p`eB0c8fAFD@#6G zDw~$2qrk;ZATNvdIolN-^aEg&N}y~#t=h}lX9|x%ymJf_fTg+{4NGN=HleQcQ^TwWr_V@avL0>l85w3q634vivgx4kqk^wtcJTSbl`c9O#M1iz zID7ASF55S5yxf#Xeafm(MgxV)2$fYDBtm2tWfiiLN)n1h(@4oEGLmG3l!hqTE2|Wl z71`Op_xWjkbU)ww_q?8e?pH52*L7a!d7Q_5AMfMm9hgS957Z2cr2O{V$}vV>GvhjQ zbDq+Ry$r3>JPuP{Rn8bdMs6QwE*AUIsck+h*I&9xPwf&k7SmbwYs!Yy6!DX}^Oa&X zKaz0|>G1O{8fMq&x8k~E==4^QB}aA$e`_FQ6{(h&{z+UEiR+t;)Knv2A7KMxckUX5&!SPiUYD`Kf<>1v zZRhhcr$1;ZtIWvaFBHN^|6nICD_3Pb@~e&EK>A7n>y>~zrc>QzAYD+vzkrQyax2;C z5x0nuD@^UPWMeJftaXz1$`mXGU3HPN8|GK}@0`Y>s#=xwe63yUbPry}Pj@L<2kPoY z_I1$Nlg1XH`9CqSl_2}A>>-?=`FvC?tDRrhONL90Q(b?M@zAj^Ihpk->zKC?dVwDS zcKz~;EPQOo_g{E=ed~d?%%#Uy2E2WBA!vB14zp-^*TkO0;Fn6g>~zBiUq?F_ixQhq zpgf~cMwV35PVcrIJ;UF%`(*$|33@WJ$-+`|#-77!q2C0`KrEK?}BMer%nq zs>^5gI5AS2&fCpzi`_{8xN4FmIV;>o*k}qYviU6u91>cC2go!-6x7igm)>o z(hF_Jkf)&opTAt|xbh|YSiXK-)A~=}RFvcf2HdZ?jYR2%?KFR>dgjD~9diNR5uQG1 z5@lU`^xm6?@&bEaR$N&7*m{^9l2s$+!GB$8L?u+SMwni{Ori*>4w;@l(0ZjI^>pI8 zOu=q0>gMffc*WdQ*x%3dp%gRu}S!P1mL~-^Z`S&Z3Vr&?pjYclW=(JP3f-ZEp6?(WZsjZ$5ST+%GYz zU*kwZ2l&i4;2-C`uIt&85T$th$v{+M{rzW01a%6#4+ofmXcecYd0GLks2*%+Vv=xUXf&;*NZA6qJnJBEqqNyAsTqjW#tAXx8 zC6T2uE1t>4oUy3=p$}lJuS0{DBu1If%S3>BAT0-|F=ljQPVjl_H1(e4=U2`OmbNv)zy)~*vv_G{5$=nMZ zsb-gcx25Y~bQ$S0CPoZ1DXmuOr^d!5p+KBb7lpnC_1c2rTZ*hJIF}2f?O(jbJNG+% zO@7VyRpjgyHZz`whrC(m^7S{C$A`Q?R*5mTEt_nb|xyP}`SH22&XOK)Q zX5cL5WYI@pV32I;?Z*_D5#RslwG{T5)b_<|ni;5@OriH6l`N#-O-Utc=;Kna8O!oq z!%qabELeKntCZV?BPM6zbWWC$mJMhs^9&T$X{bI_v-hPRM_TL5x8#eGGAv7b-0!>; z(J4Rl!eq1e$R|}3Y>x_kjr;aloHNhfASePAMx!+AKqK%Piq@Md>alOKrHdhmeO?>7 z$azZ3R(GXC z8qZT3e4FKbq{O$9Nrb+7a-(utMEAg&l{!s!J&s?QQZBEb5IJ{m^v&HC1#9nP)74gB z10IkK2x7}`p+h{*eVbB;3M*Xy8}%hIB#;KRkOsU(HbTAxQnz#4`?Xc-36Y79s&B|S z3SkehGV4JT%c|A4P4ZQxc_b_^@;F!SavcnORhMkG6$zd_Dt6BW=ci)qm}qYwfi4NT zRt1lAqw~~HdiQ?IwME#6Yind$?db03v;GJW80nC!dq4$qGkY6H$u{{2WX~cSp?JR_ zdd+EUM5yY`XNC{A?k(i(JM=yK!q?k%&R@TKTGvD;ZLqm;^VpNelPxE-Zd;xW-J0=A zLqC+BBVgUAgO^^~pm`wIb^mtr>OnaqVG8Bk>+f0{r2KRJwhZGP5}s|l2@Uc(rRmi- zf`d?GiUe5a0YC=4US}GMLikW8-A5OR-iSN`v(Qp+|ES~D@GV8Qo*TyGc{L7xTlz!K{ zv#E$sN`pW0zRXZkUmLYz#_Pp=5>Y#IAM|=@XzUMBP+~0lw%UmR9QPW~H>FRWUa=Vo zTlbw7CNk{hD*g$<4sC_kJSm|}(gX^~znxb?DYjj>0G&{QJHKgvijGoZ=gC5!)yzqB z1tpV#eL{RrfPftVK}d-8@#S?$03Jj)av~W8UeA7v>4B3h=PF#`)2#)Kld8P7U)VPy zyZ^-|>y(^by|w+gf#KV*?-TyFzn7k?}Q$E!Q)rO^PIwik~f7h1#Y`pp#*w?)QTR;##$py)ve8hJqVdr zmk#GZP9sxG;Q_^m=KW5hdq8xv%c)>T;;g}I{Hi%rU34-0RF?sPYcuQx4UGk70BC^OO#VG-{ROSFMpy3v*&?~OavrwtvT9K;+mHBkVEL)#u?#|a%8iOu5YXCUKh zp5oS@yJo%;aK(0{)ci1O$-VI*RFIWlYg4H!o=VN~+R0dzSBhMxIbm`FH3T8~Gjt+z zcTE9~jbz>aP`6%n$;se{$4hPp2aY>g2)6XW;{P1(5*udJ9h z`bp1zDb+z^3pn*YeIqj2hBm!OVpIVMWRMY%ZqK0=r)!VF2a?kcK1kKT+i-EuL zqEFKq?@yk#`@!E~VdAQK54SN5*cc9`hXv`gm&j#5bsJQ2F5XU#S7{5clTz&F9ln7G zaiccvzO{vA#knrRzJkf|8QHB2(dkvnAI^&x-zVMuZr46HlF|Xm@>wI@^!V6-%4b!% zALspQOsKNL!!5Sx#V4XOH=lVLyX)3nc#RA7<|TsjHq`wiwkyhO{iwvzu;xs(0=)tjjN!=-Kv^JSX#c48-WK)0R^Cu#=o>gS-9e#3@gVFP!UdE@3EwBL) z6vGv~YXX8c(joP=`nad~T6%s3B1r=L|7S^Z5H?)qMTSSEgQqGrMIdIaXg;;RCg4#z zD-ylK4?(_G3|juZu)dCkS4D>!Cnp}hu>vLHP-5%GlDWDaj*}RCUQ77jAX*N)qJ2Ye zWz|5Tm$8e>uV`E+dM$j70M+mO8F%o?lC1ZGWlEL`5eosiCk3k}g{MO&O)n~0Vza&i z2cg91?|JJ1=mqyaqE}ksMN)-?8b+5nl`vZYWYr2rh{7E@xrXp-j4;(=5Ho8sMhpq| z)5>|bg%j!uRz}mxsXNrv_?q>UTkl;37c?ug!-c&;?BROYLMqDM8p(?E~kLXgj|weet6ZeAquH1JhBpEnyI|8 zvTYb&^MF8yB{Jh#j*a!?8{jvYWSt+mtGAtQ0kuxGlIfAPU(C!82QkBT5WgJzLlGug z?b<)Tq>0z7=^uOE569)#gt@h7A5~@Cy|?tLkQPNrYkEG`qp)( zp`H!`J_FFfd?7uiR_IMTH!%kCi@VlU@%Lid=B)qnfxfcorQZriStZN5tIywhbaMrN zOT4&{Hm%Q_O+6u&Unf+^LgP|zCGvZP_ow^zStq|iN zOch@rZX&)+^H(1TK9IEpV!BIwL)Aw`sg)rwTof+=&cH z+lHEZS)Ib1%cgn__b=S4(i5j@J?c3r>_PlAWB)ANdj*18zAu7c;{z9!s*Wl;pOJSv zgzAv->C>7EgU!gtcwK_!DTQZOLoFS62*;;@ATYq7$` z)m+{cbD4h#9UIBO4b*`?_bYMj_~S>GzyloUdRISV_JWI76%u5?NW_urkuw=Q?Mu&$ z-*SXE?!N#_uJC2v&jJ&zB84OFf0<s z;0+VHb^>!0H!r-8Ucq;8$j!mKWZx8B3;;vCcHHgSap)|PUopsZXt}%DcYgZ**YgQE zEUR{)`Cgo^=!nEM@I7BE4B_ruH@&8L7Uk6ZgEl9AVF7+>1PK2tkWrVOsuB16r#^>-q$}lo*KL+I80~i$TSlbv0||;-e92VX{?( zPm2`v5yZ9 zZ}sfl3`g%O=lo{Yy-UoRQ=I-Bu*Y3ek9$QAb>U%78cq4wmiYWLy>Zl+G1C6qB0?MO z<*y*+{ZUcJL^W2C^H1Q-b{ULO#kg_Mg z*ReI5BG>o0HEXu}9{!WVg?=(6rF`C7zfS4TUF7%5dvB4s(?jphVog)hN@NLbZ$#Wv>*~mD<=gW| zzmj`FIxrOaf8C4UTHirj#0x#hjK_fYLE5V7E{d79V5?g=M{E z9JyzUkiuyYxOhd0l(!B6-=~x1WHn^23U=>9;4d32ai6FlB^E+giIBy1Ve*+MC9#YT zbcEzcGoIxfC3K$S?aDZ4;Hxv*_Wn6rBr0x@Mb3dMM6o;rL<~i#2mFxV9*2jZ*;%S= zapvIt6+3TOI<^(3A&{sgOct2!ce@H*&fu1_CY78yJhB<(P8hv+!Ba?&oQxTr%nE8@ zuBw9@Jr<))fN`fwT9!V2{P+Qy3~GskgNh+=z>Bm->h4P#ZLq2pxZjbiR?Btc5{*J8 zPlD7fTr$;2UG!7343~4e3%yE|%g2x48*^NZZsN_G6fOONsd5aEMnA*^I z7UZAlr0bivk?0PI7qA+D{3~!Xe#)X`ft@^bG z4m%YkExq=jmqOvJPd6y>{TXm^Jyee;h#hWOErwcpVbQDJzs}X`I=Ksjic`08vm#?) z7-*(G6%;-OGEK_5Qt)8S{m9W>=E~H(SuE=-v|^dmFntWBgjyEZoFu$J+-(HexhsnC z)-D%=YLa^T>38AaKP@5_Xw{jiSI*}&AQYuCj;(uuuIn8vT6QMgvh4JEKl{U`M8)ob zti;c&0}9X}`)D#z`CZH#+(|nzEKxCPp%y&rk2qx(Q+r3L$(Nrmyy!;mP%RM>YWVuY z(YyFrsU7g0M0i)CEM3{IeZ-3pebFRz=Y}cr1t|b;hi>er*}!8Y=L+BFAW4aswi67{ z$>r)dzdRq&y}M^0N=NBn72gBAjj641(61)UUw$|@)yw>>QM5WnkznYKm^H#|<{6TZbssKcF|iFFG)DkqN|^I$MFOlbM6J?IT_=TsPy5K_1+t`z3S3@<8k7)r z5n0E!i_9_KPt0K3ax)D~(j6OaxC=u@>01pD?2zG0rZE62T)>+)-awHA_VA z!EKTgCCwj0lM>`k*pnc9=E{`c{O&)Wst3s8qKni2{WL;ldME5iC?R|p1gUI2L_ZM? zn}cv;u5hfIv$GI~HhmN0%UM&_ho$m1V;98(zIXrq)J#F(!&0|h;_m`+3J97kFwHGA zfjP_eoh&9s-zL2uXnn9j8SqFB;RK`jti=TuGzdj1Ur$lVqF3Y^)ogc%ro9S0D%p8IX486#;5VZSsKg^hg0 zw!i!%2Q|h(wPS-{tk4Yckg!)ZWI7Vu)F38j*5>-M66-~{zOt@n&R5+nmI@|S`_;=1 zV(y12VD%Dtc@`bN<@twZI?Yd<4lBHi8ru-g>0A#5QKrcGGVDEZiz0nv8auW|wR_EC zC1g&7WLh8oPSf5kRHZ=NmO`FWEwoS^as;HegK~A!ePaE;AIwnOLJT{6=0@bF_5&`w zs{Qc993mTS5bM_LS$b11$~2E}RNr^YU&;f<2XN0@{=iA1YIcBxsl@sOZ0OyVpq~~G zq9av!XdVAghY33x{Q_MO$EkiGk^--N2{I)OwFc^Z8fa|N=Jx;eY$m04%qBHM&Nl_< zu38x0Lu222$h;W?fEEq`%ULorLpHW2PgIly;awX3Ws&B#Jy$8>37{ zYxtn$Qq{xdNMW-c0GC9ZpANEjLt2lH*c>d4m>y-x&3#Q$iMJKX!mMlBswH$!cV zpL<5^ux>adNR7yO(7!6McN6b*Qcx?MM@?jr<_1#VO8^6rKM2Oh3xP6v0?vM7-vA39 znoM(9@l9v9^W^uO*=vQKNNA?4ZCk})@lua1ca6nMZp}&w3+Ahb6&O9Z+vlk%tJvRM zrY1g_PR7K%(odKexqO8bb*LE`=u7P2I;x*>hPT&QqYAK2$5Bvcxgd zvlPB>KBiDy34RGwfQ|S8rmYonkpPvM9s;?zYnuH~MYQWGx=xiEHy2S0rzp||8lcW83k&Et6>8^1X;L(Sx1Vww(m<GuG28+xUt1|!KV9WVm)3M643`gHd2?nDDCqsWTK4Xz{9_M?4+YQvl_ykg6AFuN zl2^;QFm7zQ8tYm;i}dUV6PP7jUV&CQfW{Gky=KgEUH$@sU?sx1Ca8XJ%W@ zsP@r9?di3Xms`A8cfOK;cR2uL=y^5|-*MzGK7QzibG6Y@M8I$!C<^hU z*o+2;H@i0p6D8`Pg!>Cb=02?2pn)5Nh1bT&i;kjFg*6SG91P(C*53y~c zp@C=uer7g)Mzw=s-5Ai8HWQUrLoM^E4 zRJ3cB8D>%2dMMFH&ax@ns8rFN^S#M$4CRLLl@nua^7+7UJG^a&F1*8)+Ms@|heqII zk3l0`TTK2F0o{TLf8rV}bb$-Ju+B6hrR{K;fTCD-_L@~m$ugce-`JQnuY`T8zEy?_ zOs6wp7u-P^sq(EF+pjhvbls^RbKJ=^zzp>2(??FM|D~{rI#9TVYkYu?bLt)TRcuFJ z;1YC%TC}Q2l*)f*(y$^6~?tEqB%8?r|(TS4IoQ+!2 zBv^Ur&1t+K2SZ5(L@p(Wr^_qqwy^ zqnhwpe(XAX_+oa3g+5ZiFd%k7N&H~w%;P@CtUJ(CasAG%5qb{8stwm8bi-2@{?=F8 z@i0_70_Xj&<&Jz=?;T0n{GXQ;RPIQod3}%#>h)!pj+vR(kaS=g5(~%h(kWWPbbs#unB*?mohN!b)T;PQVH6E=6{X zen(E8M9#||*E3I<)2Q-8w0*@|x(?&K03NQe^)B~Zl2yiA^}4wZE7dYoW`FrKW9d&X zf)cAhh)R89%$;0rn4)I<#p8(8_MPt-_ z(#B;-Dr$_0XJ)>I+W&r;EE=gsf0T9BoDGQcmiLYTxWdHH;q|iN6*@s2kX2`(fiF1u zqe(nps{T;QYKq!%PiRbWS*P~Wh^>5I*My#~1jb0fCAfM>aNxRyTmH~UFLSKxaFPGE zUusIjmDq`@%@qCRmMfB^;|m#$EuP}vZGQva$)}M-5eKS zA2M2k3>*|f*Irq;C3pl53CdIWKqx!x()XI`3Z=MAM&V!gni zZuWSf+VAGZtMUjt+q ztvT(W1_s`$S3QsTgzsBSQn^xFKb>)+Zt#0n`~N=?-gEg>iQ)z><@tgGeGOL?Egjt9 z4Vl7eIS-#xGygWZ7V^6CKsc zmzQ?j(J!A-CbSv-7nZjV_rtgJC*&Gnj~bCRNoe)92y@ms&n>go#Zwe??rYpe`Wx6o zpI22z>c72JX?N3^=k4cEb%mnqCGSy&A%*~|J5YdVJCQ$@?wgE{#jR~rhgOzRZ7@#U z3Ci9cBl_}C$K?nz1CPZ8@I>D?4o~5X3u;w5r7*yxy#uyBuQKx*9ktCGLjjY$M~~W~ zT_zc|syudG!(Df5i&M|G2`AjWJoIhH+q<=MCEVmi7%s}HeXd{+%~Gs*FkNVxno+FP z@TvX^i4IvI3aPt(&epYzYuu19RAwV1uG<%+wbDi9*X1j9!sUxe87~iHm=Yyi9dUf@ zKpz<=YPI`if35RK!!fjhz$&Fx=~T?pXPKKQGoSa{5<2#IUNJdWF=3!N+t-n4W5wlk z0P;(Cf`xK-@I-a2(0MYDXx9?$pp}uCD)3m2;SN|%&LyVTS$|a3dX`wDf}`rDV$`dSdxjGKy%j6nf*={MloDcqP=Dz|3?$*(^T~;?6?|(r@abY+-su zP0ANj6vW|7JZun>b?yD+&2v-r`Uxh@iU%2>M0VoFjHF~(|JLwRGT=^b1}y6h-@#26 z^0M~@LP>N$*LGBv4%tqTMPV-=V)X&q$JSqleH)@3U?G<(nngA z$C>pZ-C5#c(V4wtpLypTHwejt*TB0`(CJ_hcDfXi9=~YDhtu1RAFt+)8=pj_-3KTU z85d`%e#=5G9c&Mm?98@6$mAOfo26I*n-Q-!V}L31dAV<~rs}0%;`PZ!_5Sk2X*X(J zt78j)W+*cjULd5gOVKj)sQ&VG43X|572yvyF-gdSfQEQ|W=QiD9CxZ7*mf{dJyb%7 zxKr8Lw&)1z({CTBUE*YKCfUl8K<-D{x2#rHjlR~Db!+j|&`rD|aqgH;SmiqTZKUBA zB~ngbq&uy%zSp`n;PZ+P3zNciJxxRyZB~b|&Y}qVW3N=2+l2%9i27d>zEby$&m-b& zy@JlT6CymI9> zBTX5uzB9W!*Odefz1rQ#li)f~)o>mWjjadzaFs{)-A(0oAL&H$?6rayufu!ENw*>)15N|26VubOdH}S}qnl;^?#^)5YeM;xYH-0;=-FT*6CglL* z(Y!a>D-`FNK5qJj1^D5oKfO$UVop=7AW|RUZFwi|87|dzlwa z-!fN%%i${SE8ozE8@)^tx@}) z+3r)iH;cpGG(O2w;Sxjj_=s>>Qht~6P16H~Y83s2&WAba3b|7EqOd3W*wJe?BrtH%ue^!fqn1&mC0IJSid7`UW`6TdpW(W zmM>-}^sG{Jk<@v2W0r&mjQ%VQqs!AgMi{?y--Vza+5>ulk32r zQtcfUm8KZ&SY93hYwcvbeBD zwkoA?t&9Sk9vh>bCY=Wt{m?^O)bkiZ4dFc8sF_AAg`fwpmU*b->y}STH9vj&!8u{) z*LGXnRCjisrjOUcjSh2OcaV-9rtKn=@EHTE)Snlmq5X9Ru{QDA5lyE{Q@ti;heDxX%7z zetp-Cv9k9yjAUgT9&A@-&WWrZd3x*IM#U5Xg@_K4wX!=zkHZ#*rJcJCmbPcZ5)if8 z!;iHHQkK^>?jKcNE6z#0&rpSz43U5k`G9Xi2gCWI@7Z}M|`w)$}G$2km#p)Non7cEm`?eJ|teN`0j7hj|D!2 zq`%+46Y4jWPHv`9;++J^Bn7kZHUJS?Y_xRt?DJkAeR%H;u>aU&)CR%;fnJMhf~>rO z5@XT&?YD46BR-HTNe|IX*>Wf_*5EYwI2i; zU?B=c1#h~lEqohNbKk*dG5!AS13FKJHn)%I-R&X0Qd5m=-Uge2k92J*}Vk2B;RxVpi*ib~NGMMPu)! z=VeZ|Oq)TfuVGP3R49re^P$}gfp8ba?|F{p1$H?@>-V6J{nmBKea&pw>fyY{_xW*) z_zONP|K;T3RL^45WjhZA(F^Z++Q!Rn$o){q&O>uU2_|M}d_g{7SiB=tz&jGCP#0sC zGHNDDZKZ}i0-lvxx|aIOKVb5vFNWEPOV#Z4h1NCx{lw)_F2iI+f(n*qZ~ z9Im`|P>6T0-7^UfD0SM|IhKtakmFWseTQ}4w>lP$j}DcnF0337e0kl%D8DzWfP#IF z^(3wWxfWQm20QdlykN@q`LX*EQEEFlHyF4vs0?g)>*QU1K0MvyI`o;FUH)exR1wpu zdnbIjQ@Mm4arC}kWb_h>KG<$3goC9<0kc8v(*(RDEnsOzQMv+;Hs`~t#0{E3gI(9Cv z>SJvtu8K?>;0BCG+m@vk+5*7NzWGb&OojuF)ob$2Fh0Ru9~Ke9sl$hwHfi&fgyt5s!@g?%lRmmTCCXbvRz^8uvnz?M1?A8I@zh7e~+Jo=)JlV5=>sI!!uqEd(4E)mEC!s_36gPJ(-lQ-{ux+iaK4&v1|Fd z?SGcD03v8wS+p*gnCTpHKfJzkz_@O`*>a!GGs=TP8n&3eP@O~x}PpNhX@ zyLS(N?BOsgHG{Vw6`F!kCu7VxOGd$BD=K(V$A`L-Nzml-q%RD0mzk!hjh{n9fU^FR z^zs0*!lW zAH}rsW)g}#KxFKLvYCYYBogK4FlbUT)+}Z1n@{(C%PsXE3Ov|tXNBJRG)En;9HGn* zX*FDrr2L9|&^|;9883E0^tk5a*mvRu?Fd=T_x3BgEq&3fn9aXlDWT=rT6LOdKu~5t zM~X*qUH<(U(>zqzEfJn{GLH9LaOXx!)`fU!%xWYx9J0>Nm|DO=$5gKQb(^3e7Loz@ zeGb^ucv9Hfov9W?^TcT8)L2A@Ezq~;kp*7?vKkH;?A<0))&kkUCGFIsecn?g4-Tl} z7W>h`uh)YgKkkfAaA>o*E@gA|>YwLRFB!?f(e7I5TF+8ob8^w39PC*dY6e{%V3S_E zygUg3m2TI3_VdS{(^xsYTo`6|x89K}%l~}O-X1!Ucmav8^Nzug=3_5730$2hNR!ar z5EN>HL?pe2e-QqZ<-4r3tlxP~>b7`vq9~xFo>)Y)S`U2&eM1V2`PsY+C;M;Jpt|C) zU<57qZAoAbefiJa4HV!;Epac37I0u+kGM7P{8F$#{$K1P zn9VL7<3F0=l)^_m)LSfh)_hvIQmDdZO^B&nPW!1}@t!*W?9+eVLtW`?tn^Lnl}Ep` zzgW^LvZU2qe|^+dQihtbe-q7>DAmN(6^YjDx-#{tm8jSxs=W%Q*+C6uJXBaXJ#Oks zpJDwBV)r|K=Nn;hmtaY5so6AljI31q0lxlnLi2l?dmxyCpA~*M0|P_505!bcX01La zjjTts%wkkBFfM9XGj&h*WTUoog|^n^;f)IS+<&J2n&3ISNH9=hF3sVq@Ip$bN2${6 z2bSS+4sxQid0_)R%@YFoupj4TGni@q9G_8o6kpoRV&w5>@gC1{bj(^RtT5O#KaJHqd6pn1NhaIx8`*H^-U7S3Y5B|n{zbwGiK2|ftp*S#`jZS--AWvab?lbY@RoiDM!rJRYMSyTi)-ZGyzi&hfx8L^D`B;~8m)M%cdv5?MtjuHt>NR$GP zMH$^oeZe%RLsrVBgAcDM4_!dB>;X=}LljvIKM&d9>M+)!?tYF`8%ZHxfuyxIX)K;l zDW+Kz*(nidlDhfvpBB@YD8VZVe4Yd<`{}78a@=K@Sbh{%tqF-zKa7>hLlS`l4uv3V z60j|_&r?{4qdbr+#YVHJKvC4(`%&QV0Sc8DpzTGC-}Bb-kIQ-A>}xA2dGj%*@y=lh zhFkLE9qMLar6d(yl_dYszx2oeZdi56n*`crrYi8AE@eApo7EB1gW0BAkh*LJ4m1Gk zld=VnW-JHr|G@Q|Do#cUEnxTJ7`Y}TLn9E3+DW(}#}3-H(g@VpS@O`z;acDkz#YE6 z30rspbrb2EN1J@VVuO+Xc>Kps9}D0&g4n7E*ergOn9pMBvcz*(`IU7alt2C#hAq`RcsA8=|^HT{b>Vy=xb|Zqm zGxizP&nCcr0!Ru2r@eBv&3EzU9aA0@tBvPG>*Tr7n2FNabl$U-Dl{`OCH=_x1#9@x zS`jid^D7`IH&6t=b`%A~s8GM{|G>9R*e<__CC@(KJ7{w4xJ1L2J52-9M@6 zW|($n563tUk?azZd;n<<-%a<4;S7|)n{Qv)c}`U{b1CUrtv?X@sl!$~@ z+7Ss{wOWeuBaOOy>W-_qk&R{xD0>7o$`sp8CVGQIR_{|HDYRtF^~pnBeF0EOIyX@E zFGGLDvLJh8ijpycr0qirxSlT!0>aV`mnvwz;#}GGh#6x3qo}tl17l+b400L7=a{^T z5Tl6oo;{y3s~(jit?|cVC^%vr*EhWjp;?J`EVZOOTt|u*Pmnpx1FEKJedF-XyV<=3 zGBsp+Cg+(OevWF9ZGe%_hpT08XjX(QWHrY z?0a#h>d{^0Im~_=9v_Visc*}reiD@jnCl0#2#D2+mABkc|dVif%}#H0&tgK zKrEY?BF+P>OaS5-St%5VmOVI$X$!6e1i z>`_S1|M1MS8I&*nWG*X!0aTR|RDcxfgk&C&H>7e%d}o;j?|_kTTwwtX`q}7mCCjm% zmz-NWb<|Y*2D=+`-Wx^!+!py&|Ch%TH!A*hUZ_$j+FQ0DUT!K{*La?&8y&p^ff-1+ z2QM;@;T^!Bg8;}3V)#my&_S+u_>JD)tbvUjUuPuBJmmZJ+7UT1WA#5mG0npqc1Mc& zks{4yh~43FZnl!v;mq3sMbYDWEcW^%gD3!AAh?5j$Y)5Sy6s5G!sYsOz_}v4YDgN` zKhsaIE@g5#A^Y@3wJYXcDcEvfqEP<%L?-`PZ{?c?Va#8=r-;E$A_kpY*Z-I{4<^>w z5MC#6!)1OJXYdEMp@`4CvtdW0Jf_+)*iu}AUlW;`-l305X2F|5 zGz>_%oT+XRbf8|`=yc;vQ;rDUpVaYL4S!ZeO2r!U6fO0;2DAH${m?=$Nl2c!eU5yt z7LkGg*0>G5GeectAY-YG(Ic58+~x)1<~!bNNjP7LnI_>KEM%z0bIZ!T_#X8>K+kqFaBrk5-&UZkc!v!r`GTA$L!rVb!NHT6RY-FOk zfXJ>MdykAAyrxa?V0Gj=So)vZs?ci&xqz9<-L@`pK=DlVy|ks>yg2-DN0i>gxZenoRK zg>?lOsD;SD8jI@H!lF>nHZvAQHF4b5xG7z9YbPVmhNFk)XrYJdD}kRn=vo#% zx#gUhciC9r$3Co44|F6KF#@x>XC|E-t#51{f2BnUo!IyQwfi9RV^xD6kA!w-JqpUR zeRf*;UPY&TQf7#qi2VmKqlB9ehpOzYAM0$i{gUuDK0nSRR5@{|KTxL4K4|qGx1=U0F=9z8YMh=0N~_Z!_X_4p6cT znhULqC(3h{aI&7Ry6orHvD|w2VY{Z?_k`wE1Ns9QaW9STEWWeO`lRcPWwWanBut(< zw0U)Eds?T%wX8bHBPUG?+9m~-+0~>pt}1!X&#HIX$PDS*MhByV{mWh^-Ovs`9{*X| zIkUQ4SYN#O`R7XK56cXl{CO4)j!FCdzBBj-1GUKP9fH}+ngqt8W8yYYgX>A$xI)!B zFaYxsZ^E)Z{j27ii-U{QrV|vd!%V9z^Ex{x^xGvv1oJVTY|-v5itSCq^pXC@?uLH) zRJkimKD2-3S^b;;lx!QAco;S4oaE+q7cE=dsN(fJUoA1d?COL~uji66-Y%%iGm~K^ z{)(|1mAEFdrD@FVkK3%#LQPf!7^&$1jh54lmc9$b(}6S00sO@U5DZEM03)RzoUf+X z9%Nv<5YWSM&7n3_qZf18X6);tsF{)C3X>Ao$d{{q*_EzWpHGI)>Es{Rznyv8BI;G2 zP;bLI{q>Tg?mT+_&eE7X+6?sN=jShQaEIqyMN7|)8^5t!zg3Wi z8(E_qhOkimF$}!ElyGVAJ2Z~2iJSA?v}*n2&-c>0Sp$QjRE@$L$6D7lqbGVqbEcL4 zS{K~>GC(zE=o(Oj8EwA3Xx>bex?uLxB@^GyN@I|aDXJBh=t@a4suW%r)9O^W*jk~t4nKo^S^7hS&+RL)HkpnO6Yc`}>@zT8> zY+gFFmW2AU->N%eGZC`lkTn890AX)Ix&kYNQs=-N-<}HzQ-f7C*XZ1L!hb-hL=g?p_|-{3hf<$0K8HC z9CNP%waS+uc`zdak7|wVyPZGW-JLH7_^CVa`##bB!{gjMU}2n4WGai?bas+)Wg9Z6 zLqj{>MIw7`B)peN2wM4l!}L(uO^=*EbC3wSSEi$((izlY`LdiJG4{m|0KZR^G#p0{ za8bE%vu<=B_*nwnio(EWr^i2?zjL3Jr*8U`{a_a$#XbGJLC{nL04yR-Mqm?&b=T7! zitRJF4pC5|&`(7DPf%;GE4s$=fZc9F=;MzATXr^(p#C>tcz}#{V#y_0c|e_-PAma# zjax0!8eHIJDBC(e(f+Y1#SDW=|FtO;0uYM3&Fcu}I9H*%O4rOf>x353LQqah&b%PB zz^Lw0P>GRq)$YE_>16ms13axY`<{F4LB+0#IAf%HlS`f<996YseaG9MZ|_aJ_b#F% z`^Qc^Ub0}tl<;usC6Gu^P@WOFeW|nYdx)H?T`9r}G3@A-N1MoCffe_GyOuvQd*S65 zZ&J@%bV{gAx3=NyEEm0*rKFV+db+N1SLh%^qWZd6-qfAZegAR#aHlvBbETS_3q9R& z{A9J{G$ZY$&SzpikU1XyXH+=%q^?Zm!@3tKcZII%ZBXf*mN_-oH{~l7YUzdL7!wn6 z0snO5?AW!HhaDwuVZHCeRXqm>nG(C-M@YUE`r+OV$n?wp#|acrEH5dx!)`j9wN$%f zsUI^)Mo=F2A)O1ZGVf53h}!Oj`sB~3cc6|7%4KQG;a&gxnbYa~*e&f9+X)Vd%q%yA z!`?IA?Jh6XaQFrBPN=00pqba7oE&Y;$6zB)af{;faLcs~Yqj3)zjT2?S46P4zi>)l z)Kr0v@Ma`+ps$>=C%%6J&W@Q13MBZ_?)qy1h|c8D$o`ZRM5t0i(6<=paDhW}#zUHf z{#0ooTdW|&ps!7lMQ*O)>u^f=_$nX?&u+_c|LDzW`rK=lJ!Z-0;VNFYfuH^8C@MKC z9|)vged#FjU)w>?Ayyi7n%WYE`4#M>+lm_=1hd6eS!GhtBj%rE-L`Pquo-1J?Q;Cj zhsA4?YF*qGfRT?&mQw@2jWu?#n5=j|Nge8<@Bf)$%21w!8%*R2JE{%@V;BH zf?JTn_#IP}#MJGEkLgV(0t^y#B{#aSdZ0SCBpE+8{Z$Ifa_L@7P!>i}r+2AK(*!X8 zdQK+?DGAmIP%*p&+lg${;>q#G$?@pU=+#c}{|VAN5504&=!A^x>I~iZUyl=}iW-JD zE&%J64=P@(HCK-iKn6Np%)ksy`or=FDtcvNswN;^JjqD;=~(Hh(L}=kdcJ})wf+^@ zNwh8zCKCKFSiM()z))O;8bFxWl0=#`Fl_JI0FS_~fJ^%HSh;yV;7iul( z*jktYf0G)%eLDo)8hdbQB~3PfHiz`%kH5z&_{~=G)5xo=l{GI6X@8q!;&-hWhN+pv z=xAGh7;^l`t-tq~=9{X7ko|Xjr#0;uq&Sa()9_+&m)g5Tk(PGs(fi zO_`zpeK%s|kU+R*ZOnR#a9xyP%Ep)^`nRM3o_gan(|<`TX3Y7L*<>c|BomD00_M$J zC7rZIj43J%-=>C=jtyO~0@92RlqpC!YscJ*?wS`G^dfDkZQ`;ay8nH>JDAjgx?P-hvdCL1?&- zq?0l?v@^zB1|U}~JqJ191?aUS9HaCu&3_fnet#YHAHoKx-P8`ec3I~>F?MvM&Z$OY{hNBry19bj8>Eg0Sw@!Y*`mO~+3S>&3DKKA!eC

i!y&`NhTCpUlQpLBb`QN=;%a#Rjdhq@_96Utu-&cacyX(QK}!< z$CmiBNY(J_z%Ogq-uZ8h_2(zlf*!-}UXY{t!gF)^$YX>?B$@UG(%6sGD}NVtgF0%A+Doo2AK)Rs+jikJfOe1Y^^qOKHba(!Y04`R`Q=M)K%YXHZUHs~FL z-<-Ew*C;Tl`_Fs-Eki$7vXB%)p-f*=TZXf1DeM?8%>1-lerb1Rx|ip&Ijt~3pLs>Nm~?#%)@w+%_oqb z3V0*>XqEfq4aZ_%Qn7OJi3upFWFh!;YOCj-1A)7vFt*e6LTT!3!b#Hv!F`2ZA2X-2 zos~=i_Ur#kdOAtvX`Noh&5N?~6%QWDkCa_7MWI7x8maP-HnZVTt0kqCe6qjKyqmK2ADcVH0uVMDaT_8|L6JkX$5 zi8AGy>5?B0Fo_=w=zq+1JBA|pwddM|%Sc+QWJifsNFDM%-fg7zlvyF+?}o;MPn3RS zxaWCE-MdlzAE9h4FAT9byTI3;T3k`mjdlxg@JmW%q}?1ixW4%rbHU+68qd@mT&%7& zq;^~C&tE5!NZ7{P#y4@Y?76;wFDDIFW~%BUY2oF>)<_NDhe`@+aaAW7W3e`J}HF4(n03-udxp+ui`m7hRgrs*wzDdE1bg#lAkCT)QV?!P@Q=7D=H_$#i)iNS|v zq*rZ}+EX?>2QF#K*f`S7xg?83dJe9S5+UQy0MBMsh6!lxWk>)5`gPs{_46wYuKfIL9 zFvd-hWVEG$0XD0-zmGY%EphJFtYQ@BuBzACKKdH^<>Y5abz{~4*fL&7@S8DiR4G;x zm|{K}bFK{0tA$+^(%69I^qq`E4yZ)Yiiu;g&3$48O1(8pU8K=dN09gppb;p$qaHrv z!X;Tf*zMsA0O?4%=-@Z!|NP*HtN36|>*|mCVCgxB4(7J#!{GqJ?tvvI_P`!{E!wcI zAI8sz7%!CN!`H97-jo5e*^KOqA25wCAEG&J#@rqx#qt1i4DELF6G^xHzkG3zKA9ge z)*GC2o+O=SMiQ+>dLr#XER>rwF_1)}+}m$Yq;W}n?a)hEVo|g>s^fUaA}il_l}55I zT@5EVg%s3A-A|&%IDEXLgyWq{D()UZCbTYdtr)5}SfN{V>E88XwYKPk?gJ&|Asq*y zK-XB7Ml?LOzECPFH_e#ZxbnA7`Qcyvq#2ngnXY+C8c`Z@*z0=b!h{rdJUh_I5r*3J zdzTatH8HF2@c4w1{sL-ibN5Dc&?J+libV(S&TAZfKjffmKB5w7EsGLBhzaHz)wea0 zgf$fod*Mo!8m8k0PJThP{Q?*PT5XGgOK2(WgN5@YffuMyf=kp!gUFb4nb~1$%(f6! zk_@oIr)dNAjT1+1zJXDxSvq=6YLn~i|L{LGrs1M{bFT4@AZ!Ki^ToYylOZDK7fA|( z+NFbA1AJxC$w)GOdaNv#`RTGWG7il!y#c^KZVW)Cl+=Uyon4M}Rtk}%JKXjIbCk5+ zc90mFOqB8X0@x@A$wph0XO`)ls_(w_7#|(=Luo5tF?w&NlK5DUU1TjZL+AwkrEus~vCv58G_Iu1+{7SKd^m?F8;f-QD>g-%3 zN@)V$s2jP2_J~WV_8Z;|>9QDC>BjHC#bBkj78$3n`^sDg66c29S#XndGH%!Z1)ZUl zr&yxAy>-_!+!VUI!()GuaTtMY%-K*e5f=Bzk$6)A0n+>^{%2#ESHj1Z61OG;K4G6j znw5MxDh+&M&ew}o!y<@n`)H+qdWe7yjJLR;;+Wao7vVXlK=lK-cjJ3N?yxaHwllV2 zUa0I2Eyhq;t%}EnPG!n#pvnoH$kdx%C-(66hyS6zHPoOKn34ubv<_2t0ZgmV0TSpz zf1>$n7>s0Fp=O)mKECEN*DsKIEH~Z_v_s1Q`wEAQI7e|2*Xx6ej7|=>G{a?m!xJqe ztu}f#h9dEGeds-BBy!tCOG5=7$C&#zkmeMzd;`b#*o`31a;Gmy;IQB87@VsArM*8Wqci9G)Kf9H^ zUKTVl3fh6iK4ax;W8Li8-$a0`8{8iQH?f|5zV!N%)LhVIV!)x^bN_FiuM-0vqTu;X z0kpd-F6sNM>~$wkzghNw?z-vUlze|rShk=PxRSl{=AAFIwoJ$0rc?#3(p$*oy|Z1! ztq(X4re8dp=^SvIpmBN4vH+t};L7KM;D$=jG|K(uf9-*{`X1W>^u%J|yuYOdXk9$0 z4BiM#jnAJJT#j3qy=LRFLxI3UH;)2$j9S^3PFwcZ5jeMT_|Nl~w^nMK>YfMs3Uu;F z&D-qw(+hK6Y56v0{kjiY7XaEce#rDL^0^Yo=@ZyUci0MaYP7@B%SJnZeX)fZw`Oln ztG`h4I`if2E#Voz>Z~qJ+WIp4BGA!=%arF%KXk5SN%qp$%O0<0OslH>wYlW%tXj|P z8CmZZKa;x@(Y@P^JE0U`w}=(Ez*lU?j%9d?Uf^K_z>ekBsLIcHT3!kP3Bat?*qsfW zlSZ^>&^sNgLV>Af?Nsx8+`W9@oaLyKMnh>dEsf^5(Q;{I7nP0w`Aw=N>_T^QYB2zT Mr>mdKI;Vst03cWwLjV8( literal 0 HcmV?d00001 diff --git a/docs/source/architecture/index.md b/docs/source/architecture/index.md new file mode 100644 index 0000000..c457efe --- /dev/null +++ b/docs/source/architecture/index.md @@ -0,0 +1,54 @@ +# Architecture + +This section explains the design and architecture of torchTextClassifiers. + +```{toctree} +:maxdepth: 2 + +overview +``` + +## Overview + +torchTextClassifiers is built on a **modular, component-based pipeline** that balances simplicity for beginners with flexibility for advanced users. + +The core pipeline consists of four main components: + +1. **Tokenizer**: Converts text strings into numerical tokens +2. **Text Embedder**: Creates semantic embeddings from tokens (with optional attention) +3. **Categorical Handler**: Processes additional categorical features (optional) +4. **Classification Head**: Produces final class predictions + +This design allows you to: + +- Understand the clear data flow through the model +- Mix and match components for your specific needs +- Start simple and add complexity as required +- Use the high-level API or drop down to PyTorch for full control + +## Quick Links + +- {doc}`overview`: Complete architecture explanation with examples +- {doc}`../api/index`: API reference for all components + +## Design Philosophy + +The architecture follows these principles: + +**Modularity** +: Each component (Tokenizer, Embedder, Categorical Handler, Classification Head) is independent and can be used separately or replaced with custom implementations + +**Clear Data Flow** +: The pipeline shows exactly how data moves from text input through embeddings to predictions, making the model transparent and understandable + +**Composability** +: Components can be mixed and matched to create custom architectures—use text-only, add categorical features, or build entirely custom combinations + +**Flexibility** +: Start with the high-level `torchTextClassifiers` wrapper for simplicity, or compose components directly with PyTorch for maximum control + +**Type Safety** +: Extensive use of type hints and dataclasses for better IDE support and fewer runtime errors + +**Framework Integration** +: All components are standard `torch.nn.Module` objects with seamless PyTorch and PyTorch Lightning integration diff --git a/docs/source/architecture/overview.md b/docs/source/architecture/overview.md new file mode 100644 index 0000000..d3e47a1 --- /dev/null +++ b/docs/source/architecture/overview.md @@ -0,0 +1,619 @@ +# Architecture Overview + +torchTextClassifiers is a **modular, component-based framework** for text classification. Rather than a black box, it provides clear, reusable components that you can understand, configure, and compose. + +## The Pipeline + +At its core, torch Text Classifiers processes data through a simple pipeline: + +```{mermaid} +flowchart LR + TextInput["Text Input"] --> Tokenizer + Tokenizer --> TextEmbedder["Text Embedder"] + + CatInput["Categorical Features
(Optional)"] --> CatEmbedder["Categorical Embedder"] + + TextEmbedder --> ClassHead["Classification Head"] + CatEmbedder --> ClassHead + + ClassHead --> Predictions + + style CatInput stroke-dasharray: 5 5 + style CatEmbedder stroke-dasharray: 5 5 +``` + +**Data Flow:** +1. **Text** is tokenized into numerical tokens +2. **Tokens** are embedded into dense vectors (with optional attention) +3. **Categorical variables** (optional) are embedded separately +4. **All embeddings** are combined +5. **Classification head** produces final predictions + +## Component 1: Tokenizer + +**Purpose:** Convert text strings into numerical tokens that the model can process. + +### Available Tokenizers + +torchTextClassifiers supports three tokenization strategies: + +#### NGramTokenizer (FastText-style) + +Character n-gram tokenization for robustness to typos and rare words. + +```python +from torchTextClassifiers.tokenizers import NGramTokenizer + +tokenizer = NGramTokenizer( + vocab_size=10000, + min_n=3, # Minimum n-gram size + max_n=6, # Maximum n-gram size +) +tokenizer.train(training_texts) +``` + +**When to use:** +- Text with typos or non-standard spellings +- Morphologically rich languages +- Limited training data + +#### WordPieceTokenizer + +Subword tokenization for balanced vocabulary coverage. + +```python +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +tokenizer = WordPieceTokenizer(vocab_size=5000) +tokenizer.train(training_texts) +``` + +**When to use:** +- Standard text classification +- Moderate vocabulary size +- Good balance of coverage and granularity + +#### HuggingFaceTokenizer + +Use pre-trained tokenizers from HuggingFace. + +```python +from torchTextClassifiers.tokenizers import HuggingFaceTokenizer +from transformers import AutoTokenizer + +hf_tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased") +tokenizer = HuggingFaceTokenizer(tokenizer=hf_tokenizer) +``` + +**When to use:** +- Transfer learning from pre-trained models +- Need specific language support +- Want to leverage existing tokenizers + +### Tokenizer Output + +All tokenizers produce the same output format: + +```python +output = tokenizer(["Hello world!", "Text classification"]) +# output.input_ids: Token indices (batch_size, seq_len) +# output.attention_mask: Attention mask (batch_size, seq_len) +``` + +## Component 2: Text Embedder + +**Purpose:** Convert tokens into dense, semantic embeddings that capture meaning. + +### Basic Text Embedding + +```python +from torchTextClassifiers.model.components import TextEmbedder, TextEmbedderConfig + +config = TextEmbedderConfig( + vocab_size=5000, + embedding_dim=128, +) +embedder = TextEmbedder(config) + +# Forward pass +text_features = embedder(token_ids) # Shape: (batch_size, 128) +``` + +**How it works:** +1. Looks up embedding for each token +2. Averages embeddings across the sequence +3. Produces a fixed-size vector per sample + +### With Self-Attention (Optional) + +Add transformer-style self-attention for better contextual understanding: + +```python +from torchTextClassifiers.model.components import AttentionConfig + +attention_config = AttentionConfig( + n_embd=128, + n_head=4, # Number of attention heads + n_layer=2, # Number of transformer blocks + dropout=0.1, +) + +config = TextEmbedderConfig( + vocab_size=5000, + embedding_dim=128, + attention_config=attention_config, # Add attention +) +embedder = TextEmbedder(config) +``` + +**When to use attention:** +- Long documents where context matters +- Tasks requiring understanding of word relationships +- When you have sufficient training data + +**Configuration:** +- `embedding_dim`: Size of embedding vectors (e.g., 64, 128, 256) +- `n_head`: Number of attention heads (typically 4, 8, or 16) +- `n_layer`: Depth of transformer (start with 2-3) + +## Component 3: Categorical Variable Handler + +**Purpose:** Process categorical features (like user demographics, product categories) alongside text. + +### When to Use + +Add categorical features when you have structured data that complements text: +- User age, location, or demographics +- Product categories or attributes +- Document metadata (source, type, etc.) + +### Setup + +```python +from torchTextClassifiers.model.components import ( + CategoricalVariableNet, + CategoricalForwardType +) + +# Example: 3 categorical variables +# - Variable 1: 10 possible values +# - Variable 2: 5 possible values +# - Variable 3: 20 possible values + +cat_handler = CategoricalVariableNet( + vocabulary_sizes=[10, 5, 20], + embedding_dims=[8, 4, 16], # Embedding size for each + forward_type=CategoricalForwardType.AVERAGE_AND_CONCAT +) +``` + +### Combination Strategies + +The `forward_type` controls how categorical embeddings are combined: + +#### AVERAGE_AND_CONCAT + +Average all categorical embeddings, then concatenate with text: + +![Average and Concatenate](diagrams/avg_concat.png) + +```python +forward_type=CategoricalForwardType.AVERAGE_AND_CONCAT +``` + +**Output size:** `text_embedding_dim + sum(categorical_embedding_dims)/n_categoricals` + +**When to use:** When categorical variables are equally important + +#### CONCATENATE_ALL + +Concatenate each categorical embedding separately: + +![Full Concatenation](diagrams/full_concat.png) + +```python +forward_type=CategoricalForwardType.CONCATENATE_ALL +``` + +**Output size:** `text_embedding_dim + sum(categorical_embedding_dims)` + +**When to use:** When each categorical variable has unique importance + +#### SUM_TO_TEXT + +Sum all categorical embeddings, then concatenate: + +```python +forward_type=CategoricalForwardType.SUM_TO_TEXT +``` + +**Output size:** `text_embedding_dim + categorical_embedding_dim` + +**When to use:** To minimize output dimension + +### Example with Data + +```python +# Text data +texts = ["Sample 1", "Sample 2"] + +# Categorical data: (n_samples, n_categorical_variables) +categorical = np.array([ + [5, 2, 14], # Sample 1: cat1=5, cat2=2, cat3=14 + [3, 1, 8], # Sample 2: cat1=3, cat2=1, cat3=8 +]) + +# Process +cat_features = cat_handler(categorical) # Shape: (2, total_emb_dim) +``` + +## Component 4: Classification Head + +**Purpose:** Take the combined features and produce class predictions. + +### Simple Classification + +```python +from torchTextClassifiers.model.components import ClassificationHead + +head = ClassificationHead( + input_dim=152, # 128 (text) + 24 (categorical) + num_classes=5, # Number of output classes +) + +logits = head(combined_features) # Shape: (batch_size, 5) +``` + +### Custom Classification Head + +For more complex classification, provide your own architecture: + +```python +import torch.nn as nn + +custom_head = nn.Sequential( + nn.Linear(152, 64), + nn.ReLU(), + nn.Dropout(0.2), + nn.Linear(64, 5) +) + +head = ClassificationHead(linear=custom_head) +``` + +## Complete Architecture + +![Complete Architecture](diagrams/NN.drawio.png) +*Complete model architecture showing all components* + +### Full Model Assembly + +The framework automatically combines all components: + +```python +from torchTextClassifiers.model import TextClassificationModel + +model = TextClassificationModel( + text_embedder=text_embedder, + categorical_variable_net=cat_handler, # Optional + classification_head=head, +) + +# Forward pass +logits = model(token_ids, categorical_data) +``` + +## Usage Examples + +### Example 1: Text-Only Classification + +Simple sentiment analysis with just text: + +```python +from torchTextClassifiers import torchTextClassifiers, ModelConfig, TrainingConfig +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +# 1. Create tokenizer +tokenizer = WordPieceTokenizer(vocab_size=5000) +tokenizer.train(texts) + +# 2. Configure model +model_config = ModelConfig( + embedding_dim=128, + num_classes=2, # Binary classification +) + +# 3. Train +classifier = torchTextClassifiers(tokenizer=tokenizer, model_config=model_config) +training_config = TrainingConfig(num_epochs=10, batch_size=32, lr=1e-3) +classifier.train(texts, labels, training_config=training_config) + +# 4. Predict +predictions = classifier.predict(new_texts) +``` + +### Example 2: Mixed Features (Text + Categorical) + +Product classification using both description and category: + +```python +import numpy as np + +# Text + categorical data +texts = ["Product description...", "Another product..."] +categorical = np.array([ + [3, 1], # Product 1: category=3, brand=1 + [5, 2], # Product 2: category=5, brand=2 +]) +labels = [0, 1] + +# Configure model with categorical features +model_config = ModelConfig( + embedding_dim=128, + num_classes=3, + categorical_vocabulary_sizes=[10, 5], # 10 categories, 5 brands + categorical_embedding_dims=[8, 4], +) + +# Train +classifier = torchTextClassifiers(tokenizer=tokenizer, model_config=model_config) +classifier.train( + X_text=texts, + y=labels, + X_categorical=categorical, + training_config=training_config +) +``` + +### Example 3: With Attention + +For longer documents or complex text: + +```python +from torchTextClassifiers.model.components import AttentionConfig + +# Add attention for better understanding +attention_config = AttentionConfig( + n_embd=128, + n_head=8, + n_layer=3, + dropout=0.1, +) + +model_config = ModelConfig( + embedding_dim=128, + num_classes=5, + attention_config=attention_config, # Enable attention +) + +classifier = torchTextClassifiers(tokenizer=tokenizer, model_config=model_config) +``` + +### Example 4: Custom Components + +For maximum flexibility, compose components manually: + +```python +from torch import nn +from torchTextClassifiers.model.components import TextEmbedder, ClassificationHead + +# Create custom model +class CustomClassifier(nn.Module): + def __init__(self): + super().__init__() + self.text_embedder = TextEmbedder(text_config) + self.custom_layer = nn.Linear(128, 64) + self.head = ClassificationHead(64, num_classes) + + def forward(self, input_ids): + text_features = self.text_embedder(input_ids) + custom_features = self.custom_layer(text_features) + return self.head(custom_features) +``` + +## Using the High-Level API + +For most users, the `torchTextClassifiers` wrapper handles all the complexity: + +```python +from torchTextClassifiers import torchTextClassifiers, ModelConfig, TrainingConfig + +# Simple 3-step process: +# 1. Create tokenizer and train it +# 2. Configure model architecture +# 3. Train and predict + +classifier = torchTextClassifiers(tokenizer=tokenizer, model_config=model_config) +classifier.train(texts, labels, training_config=training_config) +predictions = classifier.predict(new_texts) +``` + +**What the wrapper does:** +- Creates all components automatically +- Sets up PyTorch Lightning training +- Handles data loading and batching +- Provides simple train/predict interface +- Manages configurations + +**When to use the wrapper:** +- Standard classification tasks +- Quick experimentation +- Don't need custom architecture +- Want simplicity over control + +## For Advanced Users + +### Direct PyTorch Usage + +All components are standard `torch.nn.Module` objects: + +```python +# All components work with standard PyTorch +isinstance(text_embedder, nn.Module) # True +isinstance(cat_handler, nn.Module) # True +isinstance(head, nn.Module) # True + +# Use in any PyTorch code +model = TextClassificationModel(text_embedder, cat_handler, head) +optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) + +# Standard PyTorch training loop +for batch in dataloader: + optimizer.zero_grad() + logits = model(batch.input_ids, batch.categorical) + loss = criterion(logits, batch.labels) + loss.backward() + optimizer.step() +``` + +### PyTorch Lightning Integration + +For automated training with advanced features: + +```python +from torchTextClassifiers.model import TextClassificationModule +from pytorch_lightning import Trainer + +# Wrap model in Lightning module +lightning_module = TextClassificationModule( + model=model, + loss=nn.CrossEntropyLoss(), + optimizer=torch.optim.Adam, + lr=1e-3, +) + +# Use Lightning Trainer +trainer = Trainer( + max_epochs=20, + accelerator="gpu", + devices=4, # Multi-GPU + callbacks=[EarlyStopping(), ModelCheckpoint()], +) +trainer.fit(lightning_module, train_dataloader, val_dataloader) +``` + +## Design Philosophy + +### Modularity + +Each component is independent and can be used separately: + +```python +# Use just the tokenizer +tokenizer = NGramTokenizer() + +# Use just the embedder +embedder = TextEmbedder(config) + +# Use just the classifier head +head = ClassificationHead(input_dim, num_classes) +``` + +### Flexibility + +Mix and match components for your use case: + +```python +# Text only +model = TextClassificationModel(text_embedder, None, head) + +# Text + categorical +model = TextClassificationModel(text_embedder, cat_handler, head) + +# Custom combination +model = MyCustomModel(text_embedder, my_layer, head) +``` + +### Simplicity + +Sensible defaults for quick starts: + +```python +# Minimal configuration +model_config = ModelConfig(embedding_dim=128, num_classes=2) + +# Or detailed configuration +model_config = ModelConfig( + embedding_dim=256, + num_classes=10, + categorical_vocabulary_sizes=[50, 20, 100], + categorical_embedding_dims=[32, 16, 64], + attention_config=AttentionConfig(n_embd=256, n_head=8, n_layer=4), +) +``` + +### Extensibility + +Easy to add custom components: + +```python +class MyCustomEmbedder(nn.Module): + def __init__(self): + super().__init__() + # Your custom implementation + + def forward(self, input_ids): + # Your custom forward pass + return embeddings + +# Use with existing components +model = TextClassificationModel( + text_embedder=MyCustomEmbedder(), + classification_head=head, +) +``` + +## Configuration Guide + +### Choosing Embedding Dimension + +| Task Complexity | Data Size | Recommended embedding_dim | +|----------------|-----------|---------------------------| +| Simple (binary) | < 1K samples | 32-64 | +| Medium (3-5 classes) | 1K-10K samples | 64-128 | +| Complex (10+ classes) | 10K-100K samples | 128-256 | +| Very complex | > 100K samples | 256-512 | + +### Attention Configuration + +| Document Length | Recommended Setup | +|----------------|-------------------| +| Short (< 50 tokens) | No attention needed | +| Medium (50-200 tokens) | n_layer=2, n_head=4 | +| Long (200-512 tokens) | n_layer=3-4, n_head=8 | +| Very long (> 512 tokens) | n_layer=4-6, n_head=8-16 | + +### Categorical Embedding Size + +Rule of thumb: `embedding_dim ≈ min(50, vocabulary_size // 2)` + +```python +# For categorical variable with 100 unique values: +categorical_embedding_dim = min(50, 100 // 2) = 50 + +# For categorical variable with 10 unique values: +categorical_embedding_dim = min(50, 10 // 2) = 5 +``` + +## Summary + +torchTextClassifiers provides a **component-based pipeline** for text classification: + +1. **Tokenizer** → Converts text to tokens +2. **Text Embedder** → Creates semantic embeddings (with optional attention) +3. **Categorical Handler** → Processes additional features (optional) +4. **Classification Head** → Produces predictions + +**Key Benefits:** +- Clear data flow through intuitive components +- Mix and match for your specific needs +- Start simple, add complexity as needed +- Full PyTorch compatibility + +## Next Steps + +- **Tutorials**: See {doc}`../tutorials/index` for step-by-step guides +- **API Reference**: Check {doc}`../api/index` for detailed documentation +- **Examples**: Explore complete examples in the repository + +Ready to build your classifier? Start with {doc}`../getting_started/quickstart`! diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..753979f --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,137 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +import os +import sys +sys.path.insert(0, os.path.abspath('../..')) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'torchTextClassifiers' +copyright = '2024-2025, Cédric Couralet, Meilame Tayebjee' +author = 'Cédric Couralet, Meilame Tayebjee' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', # Auto-generate API docs from docstrings + 'sphinx.ext.napoleon', # Support Google/NumPy style docstrings + 'sphinx.ext.viewcode', # Add links to highlighted source code + 'sphinx.ext.intersphinx', # Link to other project documentation + 'sphinx.ext.autosummary', # Generate summary tables + 'sphinx_autodoc_typehints', # Include type hints in documentation + 'sphinx_copybutton', # Add copy button to code blocks + 'myst_parser', # Parse Markdown files + 'sphinx_design', # Modern UI components (cards, grids, etc.) + 'nbsphinx', # Render Jupyter notebooks + 'sphinxcontrib.mermaid', # Render Mermaid diagrams +] + +templates_path = ['_templates'] +exclude_patterns = [] + +# The suffix(es) of source filenames. +source_suffix = { + '.rst': 'restructuredtext', + '.md': 'markdown', +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'pydata_sphinx_theme' +html_static_path = ['_static'] +html_css_files = ['custom.css'] + +html_theme_options = { + "github_url": "https://github.com/InseeFrLab/torchTextClassifiers", + "logo": { + "image_light": "_static/logo-ttc-light.svg", + "image_dark": "_static/logo-ttc-dark.svg", + "text": "torchTextClassifiers", + }, + "show_nav_level": 2, + "navigation_depth": 3, + "show_toc_level": 2, + "navbar_align": "left", + "navbar_end": ["theme-switcher", "navbar-icon-links"], + "footer_start": ["copyright"], + "footer_end": ["sphinx-version"], + "secondary_sidebar_items": ["page-toc", "edit-this-page"], + "collapse_navigation": False, + "navigation_with_keys": True, +} + +# -- Extension configuration ------------------------------------------------- + +# Autodoc configuration +autodoc_default_options = { + 'members': True, + 'member-order': 'bysource', + 'special-members': '__init__', + 'undoc-members': True, + 'exclude-members': '__weakref__' +} + +autodoc_typehints = 'description' +autodoc_typehints_description_target = 'documented' + +# Mock imports for documentation (packages that aren't installed) +autodoc_mock_imports = ['transformers', 'tokenizers', 'datasets', 'captum'] + +# Napoleon configuration (for Google/NumPy style docstrings) +napoleon_google_docstring = True +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = True +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = True +napoleon_use_admonition_for_notes = True +napoleon_use_admonition_for_references = False +napoleon_use_ivar = False +napoleon_use_param = True +napoleon_use_rtype = True +napoleon_preprocess_types = False +napoleon_type_aliases = None +napoleon_attr_annotations = True + +# Intersphinx configuration (link to other documentation) +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'torch': ('https://pytorch.org/docs/stable/', None), + 'numpy': ('https://numpy.org/doc/stable/', None), + 'lightning': ('https://lightning.ai/docs/pytorch/stable/', None), +} + +# MyST parser configuration (for Markdown) +myst_enable_extensions = [ + "colon_fence", # ::: for admonitions + "deflist", # Definition lists + "html_image", # HTML images + "linkify", # Auto-link URLs + "replacements", # Text replacements + "smartquotes", # Smart quotes + "tasklist", # Task lists +] + +myst_heading_anchors = 3 + +# nbsphinx configuration (for Jupyter notebooks) +nbsphinx_execute = 'never' # Don't execute notebooks during build +nbsphinx_allow_errors = True + +# Autosummary configuration +autosummary_generate = True + +# Copy button configuration +copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " +copybutton_prompt_is_regexp = True + +# Syntax highlighting +pygments_style = 'sphinx' diff --git a/docs/source/getting_started/index.md b/docs/source/getting_started/index.md new file mode 100644 index 0000000..2bf16d2 --- /dev/null +++ b/docs/source/getting_started/index.md @@ -0,0 +1,41 @@ +# Getting Started + +Welcome to torchTextClassifiers! This section will help you get up and running quickly. + +```{toctree} +:maxdepth: 2 + +installation +quickstart +``` + +## What You'll Learn + +In this section, you'll learn: + +1. **Installation**: How to install torchTextClassifiers and its dependencies +2. **Quick Start**: Build your first text classifier in minutes + +## Prerequisites + +Before you begin, make sure you have: + +- Python 3.11 or higher +- Basic familiarity with Python and PyTorch +- A working Python environment (we recommend using `uv` or `conda`) + +## Next Steps + +After completing the quick start, you can: + +- Explore the {doc}`../architecture/overview` to understand how the framework is designed +- Follow {doc}`../tutorials/index` for specific use cases +- Check the {doc}`../api/index` for detailed API documentation + +## Need Help? + +If you encounter any issues: + +- Check our {doc}`../tutorials/index` for common patterns +- Visit our [GitHub Issues](https://github.com/InseeFrLab/torchTextClassifiers/issues) to report bugs +- Join the discussion on [GitHub Discussions](https://github.com/InseeFrLab/torchTextClassifiers/discussions) diff --git a/docs/source/getting_started/installation.md b/docs/source/getting_started/installation.md new file mode 100644 index 0000000..e1e746e --- /dev/null +++ b/docs/source/getting_started/installation.md @@ -0,0 +1,155 @@ +# Installation + +## Requirements + +torchTextClassifiers requires: + +- **Python**: 3.11 or higher +- **PyTorch**: Will be installed automatically as a dependency via pytorch-lightning +- **Operating System**: Linux, macOS, or Windows + +## Installation from Source + +Currently, torchTextClassifiers is available only from source. Clone the repository and install using [uv](https://github.com/astral-sh/uv), a fast Python package installer and resolver. + +```bash +# Clone the repository +git clone https://github.com/InseeFrLab/torchTextClassifiers.git +cd torchTextClassifiers + +# Install with uv +uv sync +``` + +## Optional Dependencies + +torchTextClassifiers comes with optional dependency groups for additional features: + +### Explainability Support + +For model interpretation and explainability features: + +```bash +uv sync --extra explainability +``` + +This installs: +- `captum`: For attribution analysis +- `nltk`: For text preprocessing +- `unidecode`: For text normalization + +### HuggingFace Integration + +To use HuggingFace tokenizers: + +```bash +uv sync --extra huggingface +``` + +This installs: +- `tokenizers`: Fast tokenizers +- `transformers`: HuggingFace transformers +- `datasets`: HuggingFace datasets + +### Text Preprocessing + +For additional text preprocessing utilities: + +```bash +uv sync --extra preprocess +``` + +This installs: +- `nltk`: Natural language toolkit +- `unidecode`: Text normalization + +### All Optional Dependencies + +Install all extras at once: + +```bash +uv sync --all-extras +``` + +### Development Dependencies + +If you want to contribute to the project: + +```bash +uv sync --group dev +``` + +## Verification + +Verify your installation by running: + +```python +import torchTextClassifiers +print(torchTextClassifiers.__version__) # Should print: 0.0.0-dev +``` + +Or try a simple import: + +```python +from torchTextClassifiers import torchTextClassifiers, ModelConfig, TrainingConfig +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +print("Installation successful!") +``` + +## GPU Support + +torchTextClassifiers uses PyTorch Lightning, which automatically detects and uses GPUs if available. + +To use GPUs, make sure you have: +1. CUDA-compatible GPU +2. CUDA toolkit installed +3. PyTorch installed with CUDA support + +Check GPU availability: + +```python +import torch +print(f"GPU available: {torch.cuda.is_available()}") +print(f"GPU count: {torch.cuda.device_count()}") +``` + +## Troubleshooting + +### Import Errors + +If you encounter import errors, make sure you've installed the package: + +```bash +# Reinstall +uv sync +``` + +### Dependency Conflicts + +If you have dependency conflicts, try creating a fresh virtual environment: + +```bash +# Create new virtual environment with uv +uv venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +uv sync +``` + +### PyTorch Installation Issues + +If PyTorch installation fails, uv will handle it automatically through pytorch-lightning. If you need a specific PyTorch version, you can specify it in your environment before running: + +```bash +# For CPU-only PyTorch +export PYTORCH_INDEX_URL="https://download.pytorch.org/whl/cpu" +uv sync + +# For GPU (CUDA 11.8) +export PYTORCH_INDEX_URL="https://download.pytorch.org/whl/cu118" +uv sync +``` + +## Next Steps + +Now that you have torchTextClassifiers installed, head over to the {doc}`quickstart` to build your first classifier! diff --git a/docs/source/getting_started/quickstart.md b/docs/source/getting_started/quickstart.md new file mode 100644 index 0000000..c6fe663 --- /dev/null +++ b/docs/source/getting_started/quickstart.md @@ -0,0 +1,289 @@ +# Quick Start + +This guide will walk you through building your first text classifier with torchTextClassifiers in just a few minutes. + +## Overview + +In this quick start, you'll: + +1. Create sample training data +2. Train a tokenizer +3. Configure a model +4. Train the classifier +5. Make predictions + +## Complete Example + +Here's a complete, runnable example for sentiment analysis: + +```python +import os +os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" # For Mac users + +from torchTextClassifiers import torchTextClassifiers, ModelConfig, TrainingConfig +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +# Step 1: Prepare training data +texts = [ + "I love this product! It's amazing!", + "Terrible experience, would not recommend.", + "Pretty good, meets expectations.", + "Awful quality, very disappointed.", + "Excellent service and great value!", + "Not worth the money.", + "Fantastic! Exceeded my expectations!", + "Poor quality, broke after one use.", + "Highly recommend, very satisfied!", + "Waste of money, terrible product.", +] +labels = [1, 0, 1, 0, 1, 0, 1, 0, 1, 0] # 1 = positive, 0 = negative + +# Step 2: Create and train tokenizer +print("Training tokenizer...") +tokenizer = WordPieceTokenizer() +tokenizer.train(texts, vocab_size=500, min_frequency=1) +print(f"Tokenizer trained with vocabulary size: {len(tokenizer)}") + +# Step 3: Configure model +model_config = ModelConfig( + embedding_dim=64, # Size of text embeddings + num_classes=2, # Binary classification +) + +# Step 4: Configure training +training_config = TrainingConfig( + num_epochs=10, + batch_size=4, + lr=1e-3, + patience_early_stopping=5, + accelerator="cpu", # Use "gpu" if available +) + +# Step 5: Create classifier +print("\nCreating classifier...") +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config, +) + +# Step 6: Train the model +print("\nTraining model...") +classifier.train( + X_text=texts, + y=labels, + training_config=training_config, +) + +# Step 7: Make predictions +print("\nMaking predictions...") +test_texts = [ + "This is the best thing I've ever bought!", + "Completely useless, don't buy this.", + "Pretty decent for the price.", +] + +predictions = classifier.predict(test_texts) +probabilities = classifier.predict_proba(test_texts) + +# Display results +print("\nPredictions:") +for text, pred, proba in zip(test_texts, predictions, probabilities): + sentiment = "Positive" if pred == 1 else "Negative" + confidence = proba[pred] + print(f"\nText: {text}") + print(f"Sentiment: {sentiment} (confidence: {confidence:.2%})") +``` + +## Understanding the Code + +Let's break down each step: + +### Step 1: Prepare Training Data + +```python +texts = ["I love this product!", "Terrible experience", ...] +labels = [1, 0, ...] # Binary labels +``` + +- `texts`: List of text samples +- `labels`: Corresponding labels (0 or 1 for binary classification) + +### Step 2: Train Tokenizer + +```python +tokenizer = WordPieceTokenizer() +tokenizer.train(texts, vocab_size=500, min_frequency=1) +``` + +The tokenizer learns to split text into subwords: +- `vocab_size`: Maximum vocabulary size +- `min_frequency`: Minimum frequency for a token to be included + +### Step 3: Configure Model + +```python +model_config = ModelConfig( + embedding_dim=64, + num_classes=2, +) +``` + +- `embedding_dim`: Dimension of the embedding vectors +- `num_classes`: Number of output classes (2 for binary classification) + +### Step 4: Configure Training + +```python +training_config = TrainingConfig( + num_epochs=10, + batch_size=4, + lr=1e-3, + patience_early_stopping=5, +) +``` + +- `num_epochs`: Maximum number of training epochs +- `batch_size`: Number of samples per batch +- `lr`: Learning rate +- `patience_early_stopping`: Stop if validation loss doesn't improve for this many epochs + +### Step 5-6: Create and Train + +```python +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config, +) + +classifier.train(X_text=texts, y=labels, training_config=training_config) +``` + +The classifier orchestrates the entire training process using PyTorch Lightning. + +### Step 7: Make Predictions + +```python +predictions = classifier.predict(test_texts) +probabilities = classifier.predict_proba(test_texts) +``` + +- `predict()`: Returns class predictions +- `predict_proba()`: Returns class probabilities + +## Expected Output + +When you run this example, you should see output similar to: + +``` +Training tokenizer... +Tokenizer trained with vocabulary size: 245 + +Creating classifier... + +Training model... +Epoch 0: 100%|██████████| 3/3 [00:00<00:00, 15.23it/s, v_num=0] +Epoch 1: 100%|██████████| 3/3 [00:00<00:00, 18.45it/s, v_num=0] +... + +Making predictions... + +Predictions: + +Text: This is the best thing I've ever bought! +Sentiment: Positive (confidence: 92.34%) + +Text: Completely useless, don't buy this. +Sentiment: Negative (confidence: 88.76%) + +Text: Pretty decent for the price. +Sentiment: Positive (confidence: 65.43%) +``` + +## Running with Your Own Data + +To use your own data, simply replace the `texts` and `labels` with your dataset: + +```python +# Your own data +texts = [...] # List of strings +labels = [...] # List of integers (0, 1, 2, ... for multiclass) + +# For multiclass classification (e.g., 3 classes) +model_config = ModelConfig( + embedding_dim=64, + num_classes=3, # Change this to your number of classes +) +``` + +## Using Validation Data + +For better model evaluation, split your data into training and validation sets: + +```python +from sklearn.model_selection import train_test_split + +# Split data +X_train, X_val, y_train, y_val = train_test_split( + texts, labels, test_size=0.2, random_state=42 +) + +# Train with validation +classifier.train( + X_text=X_train, + y=y_train, + X_val=X_val, + y_val=y_val, + training_config=training_config, +) +``` + +## What's Next? + +Now that you've built your first classifier, you can: + +- **Explore tutorials**: See {doc}`../tutorials/index` for more advanced examples +- **Understand the architecture**: Read {doc}`../architecture/overview` to learn how it works +- **Customize your model**: Check the {doc}`../api/index` for all configuration options +- **Add categorical features**: See {doc}`../tutorials/index` for combining text with other data + +## Common Issues + +### Small Dataset Warning + +If you see warnings about small datasets, that's expected for this quick example. For real applications, use larger datasets (hundreds or thousands of samples). + +### Training on GPU + +To use GPU acceleration: + +```python +training_config = TrainingConfig( + ... + accelerator="gpu", # or "mps" for Mac M1/M2 +) +``` + +### Reproducibility + +For reproducible results, set seeds: + +```python +import random +import numpy as np +import torch + +random.seed(42) +np.random.seed(42) +torch.manual_seed(42) +``` + +## Summary + +In this quick start, you: + +- ✅ Trained a WordPiece tokenizer +- ✅ Configured a text classification model +- ✅ Trained the model with PyTorch Lightning +- ✅ Made predictions on new text + +You're now ready to explore more advanced features and build production-ready classifiers! diff --git a/docs/source/index.md b/docs/source/index.md new file mode 100644 index 0000000..bba98fc --- /dev/null +++ b/docs/source/index.md @@ -0,0 +1,202 @@ +# torchTextClassifiers + +**A unified, extensible framework for text classification with categorical variables built on PyTorch and PyTorch Lightning.** + +```{toctree} +:maxdepth: 2 +:hidden: + +getting_started/index +architecture/index +tutorials/index +api/index +``` + +## Welcome + +torchTextClassifiers is a Python package designed to simplify building, training, and evaluating deep learning text classifiers. Whether you're working on sentiment analysis, document categorization, or any text classification task, this framework provides the tools you need while maintaining flexibility for customization. + +## Key Features + +::::{grid} 1 1 2 2 +:gutter: 3 + +:::{grid-item-card} Complex Input Support +:text-align: center + +Handle text data alongside categorical variables seamlessly +::: + +:::{grid-item-card} Highly Customizable +:text-align: center + +Use any tokenizer from HuggingFace or FastText's n-gram tokenizer +::: + +:::{grid-item-card} Multiclass & Multilabel +:text-align: center + +Support for both multiclass and multi-label classification tasks +::: + +:::{grid-item-card} PyTorch Lightning +:text-align: center + +Automated training with callbacks, early stopping, and logging +::: + +:::{grid-item-card} Modular Architecture +:text-align: center + +Mix and match components to create custom architectures +::: + +:::{grid-item-card} Built-in Explainability +:text-align: center + +Understand predictions using Captum integration +::: + +:::: + +## Quick Example + +Here's a minimal example to get you started: + +```python +from torchTextClassifiers import torchTextClassifiers, ModelConfig, TrainingConfig +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +# Sample data +texts = ["I love this product!", "Terrible experience", "It's okay"] +labels = [1, 0, 1] # Binary classification + +# Create and train tokenizer +tokenizer = WordPieceTokenizer() +tokenizer.train(texts, vocab_size=1000) + +# Configure model +model_config = ModelConfig(embedding_dim=64, num_classes=2) +training_config = TrainingConfig(num_epochs=5, batch_size=16, lr=1e-3) + +# Create classifier +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config, +) + +# Train +classifier.train(texts, labels, training_config=training_config) + +# Predict +predictions = classifier.predict(["Best product ever!"]) +``` + +## Installation + +Currently, install from source: + +```bash +# Clone the repository +git clone https://github.com/InseeFrLab/torchTextClassifiers.git +cd torchTextClassifiers + +# Install with uv (recommended) +uv sync +``` + +### Optional Dependencies + +Install additional features as needed: + +```bash +# For explainability features +uv sync --extra explainability + +# For HuggingFace tokenizers +uv sync --extra huggingface + +# For text preprocessing +uv sync --extra preprocess + +# Install all extras +uv sync --all-extras +``` + +## Get Started + +::::{grid} 1 1 2 2 +:gutter: 3 + +:::{grid-item-card} {fas}`rocket` Quick Start +:link: getting_started/quickstart +:link-type: doc + +Get up and running in 5 minutes with a complete working example +::: + +:::{grid-item-card} {fas}`layer-group` Architecture +:link: architecture/overview +:link-type: doc + +Understand the component-based pipeline and design philosophy +::: + +:::{grid-item-card} {fas}`graduation-cap` Tutorials +:link: tutorials/index +:link-type: doc + +Step-by-step guides for different use cases and features +::: + +:::{grid-item-card} {fas}`book` API Reference +:link: api/index +:link-type: doc + +Complete API documentation for all classes and functions +::: + +:::: + +## Why torchTextClassifiers? + +### Unified API + +Work with a consistent, simple API whether you're doing binary, multiclass, or multilabel classification. The `torchTextClassifiers` wrapper class handles all the complexity. + +### Flexible Components + +All components (`TextEmbedder`, `CategoricalVariableNet`, `ClassificationHead`) are standard `torch.nn.Module` objects. Mix and match them or create your own custom components. + +### Production Ready + +Built on PyTorch Lightning for robust training with automatic: +- Early stopping +- Checkpointing +- Logging +- Multi-GPU support + +### Explainability First + +Understand what your model is learning with built-in Captum integration for word-level and character-level attribution analysis. + +## Use Cases + +- **Sentiment Analysis**: Binary or multi-class sentiment classification +- **Document Categorization**: Classify documents into multiple categories +- **Mixed Feature Classification**: Combine text with categorical variables (e.g., user demographics) +- **Multilabel Classification**: Assign multiple labels to each text sample +- **Model Interpretation**: Understand which words contribute to predictions + +## License + +This project is licensed under the MIT License - see the [LICENSE](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/LICENSE) file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request on [GitHub](https://github.com/InseeFrLab/torchTextClassifiers). + +## Support + +- **GitHub Issues**: [Report bugs or request features](https://github.com/InseeFrLab/torchTextClassifiers/issues) +- **GitHub Discussions**: [Ask questions and share ideas](https://github.com/InseeFrLab/torchTextClassifiers/discussions) diff --git a/docs/source/tutorials/basic_classification.md b/docs/source/tutorials/basic_classification.md new file mode 100644 index 0000000..4568588 --- /dev/null +++ b/docs/source/tutorials/basic_classification.md @@ -0,0 +1,415 @@ +# Binary Classification Tutorial + +Learn how to build a binary sentiment classifier for product reviews. + +## Learning Objectives + +By the end of this tutorial, you will be able to: + +- Create and train a WordPiece tokenizer +- Configure a binary classification model +- Train the model with validation data +- Make predictions and evaluate performance +- Understand the complete workflow from data to predictions + +## Prerequisites + +- Basic Python knowledge +- torchTextClassifiers installed +- Familiarity with classification concepts + +## Overview + +In this tutorial, we'll build a **sentiment classifier** that predicts whether a product review is positive or negative. We'll use: + +- **Dataset**: Product reviews (30 training, 8 validation, 10 test samples) +- **Task**: Binary classification (positive vs. negative) +- **Tokenizer**: WordPiece +- **Architecture**: Simple text embedder + classification head + +## Complete Code + +Here's the complete code we'll walk through: + +```python +import os +import numpy as np +import torch +from torchTextClassifiers import ModelConfig, TrainingConfig, torchTextClassifiers +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +# For Mac M1/M2 users +os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" + +# Step 1: Prepare Data +X_train = np.array([ + "I love this product! It's amazing and works perfectly.", + "This is terrible. Worst purchase ever made.", + "Great quality and fast shipping. Highly recommend!", + "Poor quality, broke after one day. Very disappointed.", + "Excellent customer service and great value for money.", + "Overpriced and doesn't work as advertised.", + # ... (30 total samples) +]) +y_train = np.array([1, 0, 1, 0, 1, 0, ...]) # 1=positive, 0=negative + +X_val = np.array([ + "Good product, satisfied with purchase.", + "Not worth the money, poor quality.", + # ... (8 total samples) +]) +y_val = np.array([1, 0, ...]) + +X_test = np.array([ + "This is an amazing product with great features!", + "Completely disappointed with this purchase.", + # ... (10 total samples) +]) +y_test = np.array([1, 0, ...]) + +# Step 2: Create and Train Tokenizer +tokenizer = WordPieceTokenizer(vocab_size=5000, output_dim=128) +tokenizer.train(X_train.tolist()) + +# Step 3: Configure Model +model_config = ModelConfig( + embedding_dim=50, + num_classes=2 +) + +# Step 4: Create Classifier +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config +) + +# Step 5: Train Model +training_config = TrainingConfig( + num_epochs=20, + batch_size=4, + lr=1e-3, + patience_early_stopping=5, + num_workers=0, +) + +classifier.train( + X_train, y_train, + X_val, y_val, + training_config=training_config, + verbose=True +) + +# Step 6: Make Predictions +result = classifier.predict(X_test) +predictions = result["prediction"].squeeze().numpy() +confidence = result["confidence"].squeeze().numpy() + +# Step 7: Evaluate +accuracy = (predictions == y_test).mean() +print(f"Test accuracy: {accuracy:.3f}") +``` + +## Step-by-Step Walkthrough + +### Step 1: Prepare Your Data + +First, organize your data into training, validation, and test sets: + +```python +X_train = np.array([ + "I love this product! It's amazing and works perfectly.", + "This is terrible. Worst purchase ever made.", + # ... more samples +]) +y_train = np.array([1, 0, ...]) # Binary labels +``` + +**Key Points:** + +- **Training set**: Used to train the model (30 samples) +- **Validation set**: Used for early stopping and hyperparameter tuning (8 samples) +- **Test set**: Used for final evaluation (10 samples) +- **Labels**: 0 = negative, 1 = positive + +:::{tip} +For real projects, use at least hundreds of samples per class. This example uses small numbers for demonstration. +::: + +### Step 2: Create and Train Tokenizer + +The tokenizer converts text into numerical tokens: + +```python +tokenizer = WordPieceTokenizer(vocab_size=5000, output_dim=128) +tokenizer.train(X_train.tolist()) +``` + +**Parameters:** + +- `vocab_size`: Maximum vocabulary size (5000 subwords) +- `output_dim`: Output dimension for tokenized sequences (128 tokens max) + +**What happens during training:** + +1. Analyzes the training corpus +2. Learns common subwords and character combinations +3. Builds a vocabulary of frequent patterns + +:::{note} +The tokenizer only sees the training data, never validation or test data, to avoid data leakage. +::: + +### Step 3: Configure the Model + +Define your model architecture: + +```python +model_config = ModelConfig( + embedding_dim=50, + num_classes=2 +) +``` + +**Parameters:** + +- `embedding_dim`: Dimension of learned text embeddings (50) +- `num_classes`: Number of output classes (2 for binary classification) + +**Architecture:** + +The model will have: +- Embedding layer: Maps tokens to 50-dimensional vectors +- Pooling: Averages token embeddings +- Classification head: Linear layer outputting 2 logits + +### Step 4: Create the Classifier + +Instantiate the classifier with the tokenizer and configuration: + +```python +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config +) +``` + +This creates the complete pipeline: tokenizer → embedder → classifier. + +### Step 5: Configure and Run Training + +Set up training hyperparameters: + +```python +training_config = TrainingConfig( + num_epochs=20, # Maximum training epochs + batch_size=4, # Samples per batch + lr=1e-3, # Learning rate + patience_early_stopping=5, # Stop if no improvement for 5 epochs + num_workers=0, # Data loading workers +) +``` + +**Key Hyperparameters:** + +- **num_epochs**: How many times to iterate through the dataset +- **batch_size**: Smaller = more updates but slower; larger = faster but less stable +- **lr (learning rate)**: How big the optimization steps are +- **patience_early_stopping**: Prevents overfitting by stopping early + +Train the model: + +```python +classifier.train( + X_train, y_train, # Training data + X_val, y_val, # Validation data + training_config=training_config, + verbose=True # Show training progress +) +``` + +**Expected Output:** + +``` +Epoch 0: 100%|██████████| 8/8 [00:00<00:00, 25.32it/s, v_num=0] +Epoch 1: 100%|██████████| 8/8 [00:00<00:00, 28.41it/s, v_num=0] +... +``` + +:::{tip} +Watch the validation metrics during training. If validation loss increases while training loss decreases, you may be overfitting. +::: + +### Step 6: Make Predictions + +Use the trained model to predict on new data: + +```python +result = classifier.predict(X_test) +predictions = result["prediction"].squeeze().numpy() +confidence = result["confidence"].squeeze().numpy() +``` + +**Output:** + +- `predictions`: Predicted class labels (0 or 1) +- `confidence`: Confidence scores (0-1 range) + +**Example output:** + +```python +predictions = [1, 0, 1, 0, 1, 0, 1, 0, 1, 0] +confidence = [0.95, 0.88, 0.92, 0.76, 0.98, 0.85, 0.91, 0.79, 0.94, 0.87] +``` + +### Step 7: Evaluate Performance + +Calculate accuracy: + +```python +accuracy = (predictions == y_test).mean() +print(f"Test accuracy: {accuracy:.3f}") +``` + +Show detailed results: + +```python +for i, (text, pred, true) in enumerate(zip(X_test, predictions, y_test)): + sentiment = "Positive" if pred == 1 else "Negative" + correct = "✅" if pred == true else "❌" + print(f"{i+1}. {correct} Predicted: {sentiment}") + print(f" Text: {text[:50]}...") +``` + +**Example output:** + +``` +1. ✅ Predicted: Positive + Text: This is an amazing product with great features... + +2. ✅ Predicted: Negative + Text: Completely disappointed with this purchase... + +Test accuracy: 0.900 +``` + +## Understanding the Results + +### What Does Good Performance Look Like? + +- **Accuracy > 0.80**: Good for simple binary classification +- **Accuracy > 0.90**: Excellent performance +- **Confidence scores high**: Model is certain about predictions + +### When to Worry + +- **Accuracy < 0.60**: Model barely better than random guessing +- **Validation loss increasing**: Possible overfitting +- **Low confidence scores**: Model is uncertain + +## Customization Options + +### Using Different Tokenizers + +Try the NGramTokenizer (FastText-style): + +```python +from torchTextClassifiers.tokenizers import NGramTokenizer + +tokenizer = NGramTokenizer( + vocab_size=5000, + min_n=3, # Minimum n-gram size + max_n=6, # Maximum n-gram size +) +tokenizer.train(X_train.tolist()) +``` + +### Adjusting Model Size + +For better performance with more data: + +```python +model_config = ModelConfig( + embedding_dim=128, # Larger embeddings + num_classes=2 +) +``` + +### Training Longer + +```python +training_config = TrainingConfig( + num_epochs=50, # More epochs + batch_size=16, # Larger batches + lr=5e-4, # Lower learning rate + patience_early_stopping=10, # More patience +) +``` + +### Using GPU + +If you have a GPU: + +```python +training_config = TrainingConfig( + ... + accelerator="gpu", # Use GPU +) +``` + +## Common Issues and Solutions + +### Issue: Low Accuracy + +**Solutions:** + +1. Increase `embedding_dim` (e.g., 128 or 256) +2. Train for more epochs +3. Collect more training data +4. Try different learning rates (1e-4, 5e-4, 1e-3) + +### Issue: Model Overfitting + +**Symptoms:** High training accuracy, low validation accuracy + +**Solutions:** + +1. Reduce `embedding_dim` +2. Add more training data +3. Reduce `patience_early_stopping` for earlier stopping +4. Use data augmentation + +### Issue: Training Too Slow + +**Solutions:** + +1. Increase `batch_size` (if memory allows) +2. Reduce `num_epochs` +3. Use `accelerator="gpu"` +4. Increase `num_workers` (for data loading) + +## Next Steps + +Now that you've built a binary classifier, you can: + +1. **Try multiclass classification**: See {doc}`multiclass_classification` +2. **Add categorical features**: Learn about mixed features +3. **Use explainability**: Understand which words drive predictions +4. **Explore architecture**: Read {doc}`../architecture/overview` + +## Complete Working Example + +You can find the complete working example in the repository: +- [examples/basic_classification.py](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/examples/basic_classification.py) + +## Summary + +In this tutorial, you learned: + +- ✅ How to prepare training, validation, and test data +- ✅ How to create and train a WordPiece tokenizer +- ✅ How to configure a binary classification model +- ✅ How to train the model with early stopping +- ✅ How to make predictions and evaluate performance +- ✅ How to customize hyperparameters + +You're now ready to build your own text classifiers! diff --git a/docs/source/tutorials/explainability.md b/docs/source/tutorials/explainability.md new file mode 100644 index 0000000..55c8c06 --- /dev/null +++ b/docs/source/tutorials/explainability.md @@ -0,0 +1,525 @@ +# Model Explainability + +Understand which words and characters drive your model's predictions using attribution analysis. + +## Learning Objectives + +By the end of this tutorial, you'll be able to: + +- Generate explanations for individual predictions +- Visualize word-level and character-level contributions +- Identify the most influential tokens +- Use interactive explainability for debugging +- Understand Captum integration for attribution analysis + +## Prerequisites + +- Completed {doc}`basic_classification` tutorial +- Familiarity with model predictions +- (Optional) Understanding of gradient-based attribution methods + +## What Is Explainability? + +**Model explainability** reveals which parts of the input contribute most to a prediction. For text classification: + +- **Word-level**: Which words influence the prediction? +- **Character-level**: Which characters matter most? +- **Attribution scores**: How much each token contributes (positive or negative) + +### Why Use Explainability? + +✅ **Debugging**: Identify if model focuses on correct features +✅ **Trust**: Understand and validate model decisions +✅ **Bias detection**: Discover unwanted correlations +✅ **Feature engineering**: Guide feature selection + +## Complete Example + +```python +import numpy as np +from torchTextClassifiers import ModelConfig, TrainingConfig, torchTextClassifiers +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +# Training data +X_train = np.array([ + "I love this product", + "Great quality and excellent service", + "Amazing design and fantastic performance", + "This is terrible quality", + "Poor design and cheap materials", + "Awful experience with this product" +]) + +y_train = np.array([1, 1, 1, 0, 0, 0]) # 1 = Positive, 0 = Negative + +X_val = np.array([ + "Good product with decent quality", + "Bad quality and poor service" +]) +y_val = np.array([1, 0]) + +# Create and train tokenizer +tokenizer = WordPieceTokenizer(vocab_size=5000) +tokenizer.train(X_train.tolist()) + +# Create model +model_config = ModelConfig( + embedding_dim=50, + num_classes=2 +) + +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config +) + +# Train +training_config = TrainingConfig( + num_epochs=25, + batch_size=8, + lr=1e-3 +) + +classifier.train( + X_train, y_train, X_val, y_val, + training_config=training_config +) + +# Test with explainability +test_text = "This product is amazing!" + +result = classifier.predict( + np.array([test_text]), + explain=True # Enable explainability +) + +# Extract results +prediction = result["prediction"][0][0].item() +confidence = result["confidence"][0][0].item() +attributions = result["attributions"][0][0] # Token-level attributions + +print(f"Prediction: {'Positive' if prediction == 1 else 'Negative'}") +print(f"Confidence: {confidence:.4f}") +print(f"Attribution shape: {attributions.shape}") +``` + +## Step-by-Step Walkthrough + +### 1. Enable Explainability + +Add `explain=True` to `predict()`: + +```python +result = classifier.predict( + X_test, + explain=True # Generate attribution scores +) +``` + +### 2. Understanding the Output + +The result dictionary contains additional keys: + +```python +{ + "prediction": tensor, # Class predictions + "confidence": tensor, # Confidence scores + "attributions": tensor, # Token-level attribution scores + "offset_mapping": list, # Character positions of tokens + "word_ids": list # Word IDs for each token +} +``` + +**Attributions shape:** `(batch_size, top_k, sequence_length)` +- Higher values = stronger influence on prediction +- Positive values = supports predicted class +- Negative values = opposes predicted class + +### 3. Visualize Word Contributions + +Map token attributions to words: + +```python +from torchTextClassifiers.utilities.plot_explainability import map_attributions_to_word + +# Get attribution data +attributions = result["attributions"][0][0] # Shape: (seq_len,) +word_ids = result["word_ids"][0] # List of word IDs + +# Map to words +words = test_text.split() +word_attributions = [] + +for word_idx in range(len(words)): + # Find tokens belonging to this word + token_mask = [wid == word_idx for wid in word_ids] + token_attrs = attributions[token_mask] + + if len(token_attrs) > 0: + word_attr = token_attrs.mean().item() + word_attributions.append((words[word_idx], word_attr)) + +# Display results +print("\nWord-Level Contributions:") +print("-" * 50) +for word, score in word_attributions: + print(f"{word:>15} | {'█' * int(score * 40)} {score:.4f}") +``` + +### 4. Character-Level Visualization + +For finer-grained analysis: + +```python +from torchTextClassifiers.utilities.plot_explainability import map_attributions_to_char + +# Map token attributions to characters +char_attributions = map_attributions_to_char( + attributions.unsqueeze(0), # Add batch dimension + result["offset_mapping"][0], + test_text +)[0] + +# Visualize +print("\nCharacter-Level Contributions:") +for i, char in enumerate(test_text): + if i < len(char_attributions): + score = char_attributions[i] + bar = "█" * int(score * 20) + print(f"{char} | {bar} {score:.4f}") +``` + +## Complete Visualization Example + +Here's a complete function to visualize word importance: + +```python +def explain_prediction(classifier, text): + """Generate and visualize explanations for a prediction.""" + import numpy as np + + # Get prediction with explainability + result = classifier.predict( + np.array([text]), + top_k=1, + explain=True + ) + + # Extract prediction info + prediction = result["prediction"][0][0].item() + confidence = result["confidence"][0][0].item() + sentiment = "Positive" if prediction == 1 else "Negative" + + print(f"Text: '{text}'") + print(f"Prediction: {sentiment} (confidence: {confidence:.4f})") + print("\n" + "="*60) + + # Get attributions + attributions = result["attributions"][0][0] + offset_mapping = result["offset_mapping"][0] + + # Map to characters + from torchTextClassifiers.utilities.plot_explainability import map_attributions_to_char + char_attrs = map_attributions_to_char( + attributions.unsqueeze(0), + offset_mapping, + text + )[0] + + # Group by words + words = text.split() + char_idx = 0 + word_scores = [] + + for word in words: + word_len = len(word) + word_attrs = char_attrs[char_idx:char_idx + word_len] + + if len(word_attrs) > 0: + avg_attr = sum(word_attrs) / len(word_attrs) + word_scores.append((word, avg_attr)) + + char_idx += word_len + 1 # +1 for space + + # Visualize + max_score = max(score for _, score in word_scores) if word_scores else 1 + + print("Word Contributions:") + print("-" * 60) + for word, score in word_scores: + bar_length = int((score / max_score) * 40) + bar = "█" * bar_length + print(f"{word:>15} | {bar:<40} {score:.4f}") + + # Show top contributor + if word_scores: + top_word, top_score = max(word_scores, key=lambda x: x[1]) + print("-" * 60) + print(f"Most influential: '{top_word}' (score: {top_score:.4f})") + +# Use it +explain_prediction(classifier, "This product is amazing!") +explain_prediction(classifier, "Poor quality and terrible service") +``` + +## Interactive Explainability + +Create an interactive tool for exploring predictions: + +```python +def interactive_explainability(classifier): + """Interactive mode for exploring model predictions.""" + print("\n" + "="*60) + print("Interactive Explainability Mode") + print("="*60) + print("Enter text to see predictions and explanations!") + print("Type 'quit' to exit.\n") + + while True: + user_text = input("Enter text: ").strip() + + if user_text.lower() in ['quit', 'exit', 'q']: + print("Goodbye!") + break + + if not user_text: + print("Please enter some text.") + continue + + try: + explain_prediction(classifier, user_text) + print("\n" + "-"*60 + "\n") + except Exception as e: + print(f"Error: {e}") + +# Use it +interactive_explainability(classifier) +``` + +## Understanding Attribution Scores + +### What Do Scores Mean? + +- **High positive scores**: Strong support for predicted class +- **Low/negative scores**: Opposition to predicted class +- **Zero scores**: Neutral contribution + +### Example Interpretation + +For positive sentiment prediction: + +``` +Word Contributions: +------------------------------------------------------------ + This | █████ 0.1234 + product | ████████████████ 0.4567 + is | ██ 0.0543 + amazing | ██████████████████████████████ 0.8901 + ! | ███ 0.0876 +------------------------------------------------------------ +Most influential: 'amazing' (score: 0.8901) +``` + +**Interpretation:** +- "amazing" strongly indicates positive sentiment (0.89) +- "product" moderately supports positive (0.46) +- "is" is nearly neutral (0.05) + +## Debugging with Explainability + +### Case 1: Unexpected Predictions + +```python +test_text = "This product is not good" +explain_prediction(classifier, test_text) + +# Output might show: +# Word Contributions: +# not | ████ 0.12 <- Low attribution! +# good | ██████████ 0.45 <- High attribution for "good" +``` + +**Problem**: Model ignores "not", focuses on "good" +**Solution**: Add more negation examples to training data + +### Case 2: Correct Predictions, Wrong Reasons + +```python +test_text = "Product from China is excellent" +explain_prediction(classifier, test_text) + +# If "China" has high attribution, model may have learned spurious correlation +``` + +**Problem**: Model uses irrelevant features +**Solution**: Audit training data for bias, balance dataset + +### Case 3: Low Confidence + +```python +test_text = "Product arrived on time" +result = classifier.predict(np.array([test_text]), explain=True) +confidence = result["confidence"][0][0].item() # Low confidence + +explain_prediction(classifier, test_text) +# All words have similar low attribution scores +``` + +**Interpretation**: Text doesn't contain strong sentiment indicators +**This is correct behavior**: Model appropriately uncertain + +## Advanced: Custom Attribution Methods + +By default, torchTextClassifiers uses integrated gradients. For custom attribution: + +```python +from torchTextClassifiers.utilities.plot_explainability import generate_attributions +from captum.attr import LayerIntegratedGradients + +# Access the underlying model +model = classifier.model + +# Create custom attribution method +attribution_method = LayerIntegratedGradients( + model, + model.text_embedder.embedding +) + +# Generate attributions +attributions = generate_attributions( + classifier, + texts=["Your text here"], + attribution_method=attribution_method +) +``` + +## Common Issues + +### Issue 1: Explainability Fails + +**Error:** "explain=True requires captum package" + +**Solution:** Install explainability dependencies: +```bash +uv sync --extra explainability +``` + +### Issue 2: All Attributions Near Zero + +**Possible causes:** +- Model not well-trained +- Text contains no discriminative features +- Attribution method sensitivity + +**Try:** +- Train longer or with more data +- Check prediction confidence +- Verify model performance on test set + +### Issue 3: Inconsistent Attributions + +**Problem:** Same word has different attributions in different contexts + +**This is expected!** Attribution considers: +- Surrounding context +- Position in sentence +- Interaction with other words + +## Best Practices + +1. **Always check confidence:** Low confidence = less reliable attributions +2. **Compare multiple examples:** Look for patterns across predictions +3. **Validate with domain knowledge:** Do highlighted words make sense? +4. **Use for debugging, not blind trust:** Attributions are approximations +5. **Check training data:** High attribution may reveal training biases + +## Real-World Use Cases + +### Sentiment Analysis + +```python +positive_review = "Excellent product with amazing quality" +negative_review = "Terrible product with poor quality" + +for review in [positive_review, negative_review]: + explain_prediction(classifier, review) + print("\n" + "="*60 + "\n") +``` + +Verify that sentiment words ("excellent", "terrible") have highest attribution. + +### Spam Detection + +```python +spam_text = "Click here for free money now!" +explain_prediction(spam_classifier, spam_text) +``` + +Check if "free", "click", "now" are highlighted (common spam indicators). + +### Topic Classification + +```python +sports_text = "The team won the championship game" +explain_prediction(topic_classifier, sports_text) +``` + +Verify "team", "championship", "game" drive sports prediction. + +## Customization + +### Batch Explainability + +Explain multiple texts at once: + +```python +test_texts = [ + "Great product", + "Terrible experience", + "Average quality" +] + +result = classifier.predict( + np.array(test_texts), + explain=True +) + +for i, text in enumerate(test_texts): + print(f"\nText {i+1}: {text}") + attributions = result["attributions"][i][0] + print(f"Max attribution: {attributions.max():.4f}") +``` + +### Save Explanations + +Export attributions for analysis: + +```python +import json + +explanations = [] +for text in test_texts: + result = classifier.predict(np.array([text]), explain=True) + + explanations.append({ + "text": text, + "prediction": int(result["prediction"][0][0].item()), + "confidence": float(result["confidence"][0][0].item()), + "attributions": result["attributions"][0][0].tolist() + }) + +# Save to JSON +with open("explanations.json", "w") as f: + json.dump(explanations, f, indent=2) +``` + +## Summary + +**Key takeaways:** +- Use `explain=True` to generate attribution scores +- Visualize word and character contributions +- High attribution = strong influence on prediction +- Use explainability for debugging and validation +- Check if model focuses on correct features + +Ready for multilabel classification? Continue to {doc}`multilabel_classification`! diff --git a/docs/source/tutorials/index.md b/docs/source/tutorials/index.md new file mode 100644 index 0000000..845c221 --- /dev/null +++ b/docs/source/tutorials/index.md @@ -0,0 +1,267 @@ +# Tutorials + +Step-by-step guides to learn torchTextClassifiers through practical examples. + +```{toctree} +:maxdepth: 2 + +basic_classification +multiclass_classification +mixed_features +explainability +multilabel_classification +``` + +## Overview + +These tutorials guide you through common text classification tasks, from basic binary classification to advanced multiclass scenarios. + +## Available Tutorials + +### Getting Started + +::::{grid} 1 +:gutter: 3 + +:::{grid-item-card} {fas}`star` Binary Classification +:link: basic_classification +:link-type: doc + +**Recommended first tutorial** + +Build a sentiment classifier for product reviews. Learn the complete workflow from data preparation to evaluation. + +**What you'll learn:** +- Creating and training tokenizers +- Configuring models +- Training with validation data +- Making predictions +- Evaluating performance + +**Difficulty:** Beginner | **Time:** 15 minutes +::: + +:::: + +### Intermediate Tutorials + +::::{grid} 1 1 2 2 +:gutter: 3 + +:::{grid-item-card} {fas}`layer-group` Multiclass Classification +:link: multiclass_classification +:link-type: doc + +Classify text into 3+ categories with proper handling of class imbalance and evaluation metrics. + +**What you'll learn:** +- Multiclass model configuration +- Class distribution analysis +- Reproducibility with seeds +- Confusion matrices +- Advanced evaluation metrics + +**Difficulty:** Intermediate | **Time:** 20 minutes +::: + +:::{grid-item-card} {fas}`puzzle-piece` Mixed Features +:link: mixed_features +:link-type: doc + +Combine text with categorical variables for improved classification performance. + +**What you'll learn:** +- Adding categorical features alongside text +- Configuring categorical embeddings +- Comparing performance improvements +- Feature combination strategies + +**Difficulty:** Intermediate | **Time:** 25 minutes +::: + +:::: + +### Advanced Tutorials + +::::{grid} 1 1 2 2 +:gutter: 3 + +:::{grid-item-card} {fas}`lightbulb` Explainability +:link: explainability +:link-type: doc + +Understand which words and characters drive your model's predictions. + +**What you'll learn:** +- Generating attribution scores with Captum +- Word-level and character-level visualizations +- Identifying influential tokens +- Interactive explainability mode + +**Difficulty:** Advanced | **Time:** 30 minutes +::: + +:::{grid-item-card} {fas}`tags` Multilabel Classification +:link: multilabel_classification +:link-type: doc + +Assign multiple labels to each text sample for complex classification scenarios. + +**What you'll learn:** +- Ragged lists vs. one-hot encoding +- Configuring BCEWithLogitsLoss +- Multilabel evaluation metrics +- Handling variable labels per sample + +**Difficulty:** Advanced | **Time:** 30 minutes +::: + +:::: + +## Learning Path + +We recommend following this learning path: + +```{mermaid} +graph LR + A[Quick Start] --> B[Binary Classification] + B --> C[Multiclass Classification] + C --> D[Mixed Features] + C --> F[Multilabel Classification] + D --> E[Explainability] + F --> E + + style A fill:#e3f2fd + style B fill:#bbdefb + style C fill:#90caf9 + style D fill:#64b5f6 + style E fill:#1976d2 + style F fill:#42a5f5 +``` + +1. **Start with**: {doc}`../getting_started/quickstart` - Get familiar with the basics +2. **Then**: {doc}`basic_classification` - Understand the complete workflow +3. **Next**: {doc}`multiclass_classification` - Handle multiple classes +4. **Branch out**: {doc}`mixed_features` for categorical features OR {doc}`multilabel_classification` for multiple labels +5. **Master**: {doc}`explainability` - Understand your model's predictions + +## Tutorial Format + +Each tutorial follows a consistent structure: + +**Learning Objectives** +: What you'll be able to do after completing the tutorial + +**Prerequisites** +: What you need to know before starting + +**Complete Code** +: Full working example you can copy and run + +**Step-by-Step Walkthrough** +: Detailed explanation of each step + +**Customization** +: How to adapt the code to your needs + +**Common Issues** +: Troubleshooting tips and solutions + +**Next Steps** +: Where to go after finishing + +## Tips for Learning + +### Run the Code + +Don't just read - run the examples! Modify them to see what happens: + +```python +# Try different values +model_config = ModelConfig( + embedding_dim=128, # Was 64 - what changes? + num_classes=2 +) +``` + +### Start Simple + +Begin with the Quick Start, then move to Binary Classification. Don't skip ahead! + +### Use Your Own Data + +Once you understand the examples, try them with your own text data: + +```python +# Your data +my_texts = ["your", "text", "samples"] +my_labels = [0, 1, 0] + +# Same workflow +classifier.train(my_texts, my_labels, training_config) +``` + +### Experiment + +- Try different tokenizers (WordPiece vs NGram) +- Adjust hyperparameters (learning rate, embedding dim) +- Compare model sizes +- Test different batch sizes + +### Read the Errors + +Error messages are helpful! They often tell you exactly what's wrong: + +```python +# Error: num_classes=2 but got label 3 +# Solution: Check your labels - should be 0, 1 (not 1, 2, 3) +``` + +## Getting Help + +Stuck on a tutorial? Here's how to get help: + +1. **Check Common Issues**: Each tutorial has a troubleshooting section +2. **Read the API docs**: {doc}`../api/index` for detailed parameter descriptions +3. **Review architecture**: {doc}`../architecture/overview` for how components work +4. **Ask questions**: [GitHub Discussions](https://github.com/InseeFrLab/torchTextClassifiers/discussions) +5. **Report bugs**: [GitHub Issues](https://github.com/InseeFrLab/torchTextClassifiers/issues) + +## Additional Resources + +### Example Scripts + +All tutorials are based on runnable examples in the repository: + +- [examples/basic_classification.py](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/examples/basic_classification.py) +- [examples/multiclass_classification.py](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/examples/multiclass_classification.py) +- [examples/using_additional_features.py](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/examples/using_additional_features.py) +- [examples/advanced_training.py](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/examples/advanced_training.py) +- [examples/simple_explainability_example.py](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/examples/simple_explainability_example.py) + +### Jupyter Notebooks + +Interactive notebooks for hands-on learning: + +- [Basic example notebook](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/notebooks/example.ipynb) +- [Multilabel classification notebook](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/notebooks/multilabel_classification.ipynb) + +## Contributing + +Want to contribute a tutorial? We welcome: + +- New use cases +- Alternative approaches +- Real-world examples +- Performance tips + +See our [contributing guidelines](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/CONTRIBUTING.md) to get started! + +## What's Next? + +Ready to start? Choose your path: + +- **New to text classification?** Start with {doc}`../getting_started/quickstart` +- **Want to dive deeper?** Begin with {doc}`basic_classification` +- **Ready for multiclass?** Jump to {doc}`multiclass_classification` +- **Need API details?** Check {doc}`../api/index` diff --git a/docs/source/tutorials/mixed_features.md b/docs/source/tutorials/mixed_features.md new file mode 100644 index 0000000..163f3d7 --- /dev/null +++ b/docs/source/tutorials/mixed_features.md @@ -0,0 +1,450 @@ +# Mixed Features Classification + +Learn how to combine text with categorical variables for improved classification performance. + +## Learning Objectives + +By the end of this tutorial, you'll be able to: + +- Combine text and categorical features in a single model +- Configure categorical embeddings +- Compare performance with and without categorical features +- Understand when categorical features improve results + +## Prerequisites + +- Completed {doc}`basic_classification` tutorial +- Familiarity with categorical data (e.g., user demographics, product categories) +- Understanding of embeddings + +## What Are Categorical Features? + +Categorical features are non-numeric variables like: +- **User attributes**: Age group, location, membership tier +- **Product metadata**: Category, brand, seller +- **Document properties**: Source, type, language + +These features can significantly improve classification when they contain relevant information. + +## When to Use Categorical Features + +✅ **Good use cases:** +- Product descriptions + (category, brand) +- Reviews + (user location, verified purchase) +- News articles + (source, publication date) + +❌ **Poor use cases:** +- Text already contains the categorical information +- Random or high-cardinality features (e.g., user IDs) +- Categorical features with no relationship to labels + +## Complete Example + +```python +import numpy as np +from sklearn.preprocessing import LabelEncoder +from sklearn.model_selection import train_test_split + +from torchTextClassifiers import ModelConfig, TrainingConfig, torchTextClassifiers +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +# Sample data: Product reviews with category +texts = [ + "Great phone with excellent camera", + "Battery dies too quickly", + "Love this laptop's performance", + "Screen quality is poor", + "Best headphones I've ever owned", + "Sound quality is disappointing", + "Fast shipping and great quality", + "Product arrived damaged" +] + +# Categorical feature: Product category (0=Electronics, 1=Audio) +categories = [0, 0, 0, 0, 1, 1, 0, 0] + +# Labels: Positive (1) or Negative (0) +labels = [1, 0, 1, 0, 1, 0, 1, 0] + +# Prepare data +X_text = np.array(texts) +X_categorical = np.array(categories).reshape(-1, 1) # Shape: (n_samples, 1) +y = np.array(labels) + +# Split data +X_text_train, X_text_test, X_cat_train, X_cat_test, y_train, y_test = train_test_split( + X_text, X_categorical, y, test_size=0.25, random_state=42 +) + +# Create tokenizer +tokenizer = WordPieceTokenizer(vocab_size=1000) +tokenizer.train(X_text_train.tolist()) + +# Configure model WITH categorical features +model_config = ModelConfig( + embedding_dim=64, + num_classes=2, + categorical_vocabulary_sizes=[2], # 2 categories (Electronics, Audio) + categorical_embedding_dims=[8], # Embed each category into 8 dimensions +) + +# Create classifier +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config +) + +# Training configuration +training_config = TrainingConfig( + num_epochs=20, + batch_size=4, + lr=1e-3 +) + +# Combine text and categorical features +X_train_mixed = np.column_stack([X_text_train, X_cat_train]) +X_test_mixed = np.column_stack([X_text_test, X_cat_test]) + +# Train model +classifier.train( + X_train_mixed, y_train, + training_config=training_config +) + +# Predict +result = classifier.predict(X_test_mixed) +predictions = result["prediction"].squeeze().numpy() + +# Evaluate +accuracy = (predictions == y_test).mean() +print(f"Test Accuracy: {accuracy:.3f}") +``` + +## Step-by-Step Walkthrough + +### 1. Prepare Categorical Features + +Categorical features must be **encoded as integers** (0, 1, 2, ...): + +```python +from sklearn.preprocessing import LabelEncoder + +# Example: Encode product categories +categories = ["Electronics", "Audio", "Electronics", "Audio"] +encoder = LabelEncoder() +categories_encoded = encoder.fit_transform(categories) +# Result: [0, 1, 0, 1] +``` + +Shape your categorical data as `(n_samples, n_categorical_features)`: + +```python +# Single categorical feature +X_categorical = categories_encoded.reshape(-1, 1) + +# Multiple categorical features +X_categorical = np.column_stack([ + categories_encoded, + brands_encoded, + regions_encoded +]) # Shape: (n_samples, 3) +``` + +### 2. Configure Categorical Embeddings + +Specify vocabulary sizes and embedding dimensions: + +```python +model_config = ModelConfig( + embedding_dim=64, # For text + num_classes=2, + categorical_vocabulary_sizes=[10, 5, 20], # Vocab size for each feature + categorical_embedding_dims=[8, 4, 16] # Embedding dim for each feature +) +``` + +**Rule of thumb for embedding dimensions:** +```python +embedding_dim = min(50, vocabulary_size // 2) +``` + +Examples: +- 10 categories → embedding_dim = 5 +- 100 categories → embedding_dim = 50 +- 1000 categories → embedding_dim = 50 (capped) + +### 3. Combine Features + +Stack text and categorical data: + +```python +# For training +X_train_mixed = np.column_stack([X_text_train, X_cat_train]) + +# For prediction +X_test_mixed = np.column_stack([X_text_test, X_cat_test]) +``` + +The framework automatically separates text (first column) from categorical features (remaining columns). + +### 4. Train and Predict + +Training and prediction work the same way: + +```python +# Train +classifier.train(X_train_mixed, y_train, training_config=training_config) + +# Predict +result = classifier.predict(X_test_mixed) +``` + +## Comparison: Text-Only vs. Mixed Features + +Let's compare performance: + +```python +# Text-only model +model_config_text_only = ModelConfig( + embedding_dim=64, + num_classes=2 +) + +classifier_text_only = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config_text_only +) + +classifier_text_only.train(X_text_train, y_train, training_config=training_config) +result_text_only = classifier_text_only.predict(X_text_test) +accuracy_text_only = (result_text_only["prediction"].squeeze().numpy() == y_test).mean() + +# Mixed features model (from above) +accuracy_mixed = (predictions == y_test).mean() + +print(f"Text-Only Accuracy: {accuracy_text_only:.3f}") +print(f"Mixed Features Accuracy: {accuracy_mixed:.3f}") +print(f"Improvement: {(accuracy_mixed - accuracy_text_only):+.3f}") +``` + +## Combination Strategies + +The framework offers different ways to combine categorical embeddings: + +### AVERAGE_AND_CONCAT (Default) + +Average all categorical embeddings, then concatenate with text: + +```python +from torchTextClassifiers.model.components import CategoricalForwardType + +model_config = ModelConfig( + embedding_dim=64, + num_classes=2, + categorical_vocabulary_sizes=[10, 5], + categorical_embedding_dims=[8, 4], + categorical_forward_type=CategoricalForwardType.AVERAGE_AND_CONCAT +) +``` + +**Output size:** `text_embedding_dim + avg(categorical_embedding_dims)` + +### CONCATENATE_ALL + +Concatenate each categorical embedding separately: + +```python +model_config = ModelConfig( + # ... same as above ... + categorical_forward_type=CategoricalForwardType.CONCATENATE_ALL +) +``` + +**Output size:** `text_embedding_dim + sum(categorical_embedding_dims)` + +**When to use:** Each categorical variable has unique importance. + +### SUM_TO_TEXT + +Sum all categorical embeddings first: + +```python +model_config = ModelConfig( + # ... same as above ... + categorical_forward_type=CategoricalForwardType.SUM_TO_TEXT +) +``` + +**Output size:** `text_embedding_dim + categorical_embedding_dim` + +**When to use:** To minimize model size. + +## Real-World Example: AG News with Source + +```python +import pandas as pd +from sklearn.preprocessing import LabelEncoder + +# Load AG News dataset +df = pd.read_parquet("path/to/ag_news.parquet") +df = df.sample(10000, random_state=42) + +# Combine title and description +df['text'] = df['title'] + ' ' + df['description'] + +# Encode news source as categorical feature +source_encoder = LabelEncoder() +df['source_encoded'] = source_encoder.fit_transform(df['source']) + +# Prepare data +X_text = df['text'].values +X_categorical = df['source_encoded'].values.reshape(-1, 1) +y_encoded = LabelEncoder().fit_transform(df['category']) + +# Split data +X_text_train, X_text_test, X_cat_train, X_cat_test, y_train, y_test = train_test_split( + X_text, X_categorical, y_encoded, test_size=0.2, random_state=42 +) + +# Train model +tokenizer = WordPieceTokenizer(vocab_size=5000) +tokenizer.train(X_text_train.tolist()) + +n_sources = len(source_encoder.classes_) +n_categories = len(np.unique(y_encoded)) + +model_config = ModelConfig( + embedding_dim=128, + num_classes=n_categories, + categorical_vocabulary_sizes=[n_sources], + categorical_embedding_dims=[min(50, n_sources // 2)] +) + +classifier = torchTextClassifiers(tokenizer=tokenizer, model_config=model_config) + +X_train_mixed = np.column_stack([X_text_train, X_cat_train]) +X_test_mixed = np.column_stack([X_text_test, X_cat_test]) + +training_config = TrainingConfig( + num_epochs=50, + batch_size=128, + lr=1e-3, + patience_early_stopping=3 +) + +classifier.train(X_train_mixed, y_train, training_config=training_config) + +# Evaluate +result = classifier.predict(X_test_mixed) +accuracy = (result["prediction"].squeeze().numpy() == y_test).mean() +print(f"Test Accuracy: {accuracy:.3f}") +``` + +## Common Issues + +### Issue 1: Shape Mismatch + +**Error:** "Expected 2D array, got 1D array" + +**Solution:** Reshape single features: +```python +X_categorical = categories.reshape(-1, 1) # Add column dimension +``` + +### Issue 2: Non-Integer Categories + +**Error:** "Expected integer values" + +**Solution:** Use `LabelEncoder`: +```python +encoder = LabelEncoder() +categories_encoded = encoder.fit_transform(categories) +``` + +### Issue 3: Missing Vocabulary Sizes + +**Error:** "Must specify categorical_vocabulary_sizes" + +**Solution:** Provide vocab size for each categorical feature: +```python +vocab_sizes = [int(np.max(X_cat_train[:, i]) + 1) for i in range(X_cat_train.shape[1])] +model_config = ModelConfig( + categorical_vocabulary_sizes=vocab_sizes, + categorical_embedding_dims=[min(50, v // 2) for v in vocab_sizes] +) +``` + +### Issue 4: No Performance Improvement + +**Possible reasons:** +- Categorical features not predictive of labels +- Text already contains categorical information +- Need more training data +- Categorical embeddings too small + +**Try:** +- Increase embedding dimensions +- Check feature-label correlation +- Try different combination strategies + +## Customization + +### Custom Embedding Dimensions + +Different dimensions for different importance: + +```python +model_config = ModelConfig( + embedding_dim=128, + num_classes=5, + categorical_vocabulary_sizes=[100, 10, 50], + categorical_embedding_dims=[32, 4, 16] # Vary by importance +) +``` + +### With Attention + +Combine categorical features with attention-based text embeddings: + +```python +from torchTextClassifiers.model.components import AttentionConfig + +attention_config = AttentionConfig( + n_embd=128, + n_head=8, + n_layer=3 +) + +model_config = ModelConfig( + embedding_dim=128, + num_classes=5, + attention_config=attention_config, + categorical_vocabulary_sizes=[100], + categorical_embedding_dims=[32] +) +``` + +## Best Practices + +1. **Start simple:** Begin with text-only model, add categorical features if needed +2. **Check correlation:** Ensure categorical features correlate with labels +3. **Normalize vocabulary sizes:** Use embedding_dim ≈ vocabulary_size // 2 +4. **Avoid overfitting:** Don't use too many high-dimensional categorical features +5. **Compare performance:** Always compare mixed vs. text-only models + +## Next Steps + +- **Explainability**: Learn which features (text or categorical) drive predictions in {doc}`explainability` +- **Multilabel**: Apply mixed features to multilabel tasks in {doc}`multilabel_classification` +- **Advanced Training**: Explore hyperparameter tuning with mixed features + +## Summary + +**Key takeaways:** +- Categorical features can improve classification performance +- Encode categories as integers (0, 1, 2, ...) +- Configure vocabulary sizes and embedding dimensions +- Combine text and categorical data using `np.column_stack` +- Compare performance to validate improvement + +Ready to understand your model's predictions? Continue to {doc}`explainability`! diff --git a/docs/source/tutorials/multiclass_classification.md b/docs/source/tutorials/multiclass_classification.md new file mode 100644 index 0000000..6db8dc1 --- /dev/null +++ b/docs/source/tutorials/multiclass_classification.md @@ -0,0 +1,459 @@ +# Multiclass Classification Tutorial + +Learn how to build a multiclass sentiment classifier with 3 classes: negative, neutral, and positive. + +## Learning Objectives + +By the end of this tutorial, you will be able to: + +- Handle multiclass classification problems (3+ classes) +- Configure models for multiple output classes +- Ensure reproducible results with proper seeding +- Evaluate multiclass performance +- Understand class distribution and balance + +## Prerequisites + +- Completion of {doc}`basic_classification` tutorial (recommended) +- Basic understanding of classification +- torchTextClassifiers installed + +## Overview + +In this tutorial, we'll build a **3-class sentiment classifier** that categorizes product reviews as: + +- **Negative** (class 0): Bad reviews +- **Neutral** (class 1): Mixed or moderate reviews +- **Positive** (class 2): Good reviews + +**Key Difference from Binary Classification:** + +- Binary: 2 classes (positive/negative) +- Multiclass: 3+ classes (negative/neutral/positive) + +## Complete Code + +```python +import os +import numpy as np +import torch +from pytorch_lightning import seed_everything +from torchTextClassifiers import ModelConfig, TrainingConfig, torchTextClassifiers +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +# Step 1: Set Seeds for Reproducibility +SEED = 42 +os.environ['PYTHONHASHSEED'] = str(SEED) +seed_everything(SEED, workers=True) +torch.backends.cudnn.deterministic = True +torch.use_deterministic_algorithms(True, warn_only=True) + +# Step 2: Prepare Multi-class Data +X_train = np.array([ + # Negative (class 0) + "This product is terrible and I hate it completely.", + "Worst purchase ever. Total waste of money.", + "Absolutely awful quality. Very disappointed.", + "Poor service and terrible product quality.", + "I regret buying this. Complete failure.", + + # Neutral (class 1) + "The product is okay, nothing special though.", + "It works but could be better designed.", + "Average quality for the price point.", + "Not bad but not great either.", + "It's fine, meets basic expectations.", + + # Positive (class 2) + "Excellent product! Highly recommended!", + "Amazing quality and great customer service.", + "Perfect! Exactly what I was looking for.", + "Outstanding value and excellent performance.", + "Love it! Will definitely buy again." +]) + +y_train = np.array([0, 0, 0, 0, 0, # negative + 1, 1, 1, 1, 1, # neutral + 2, 2, 2, 2, 2]) # positive + +# Validation data +X_val = np.array([ + "Bad quality, not recommended.", + "It's okay, does the job.", + "Great product, very satisfied!" +]) +y_val = np.array([0, 1, 2]) + +# Test data +X_test = np.array([ + "This is absolutely horrible!", + "It's an average product, nothing more.", + "Fantastic! Love every aspect of it!", + "Really poor design and quality.", + "Works well, good value for money.", + "Outstanding product with amazing features!" +]) +y_test = np.array([0, 1, 2, 0, 1, 2]) + +# Step 3: Create and Train Tokenizer +tokenizer = WordPieceTokenizer(vocab_size=5000, output_dim=128) +tokenizer.train(X_train.tolist()) + +# Step 4: Configure Model for 3 Classes +model_config = ModelConfig( + embedding_dim=64, + num_classes=3 # KEY: 3 classes for multiclass +) + +# Step 5: Create Classifier +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config +) + +# Step 6: Train Model +training_config = TrainingConfig( + num_epochs=30, + batch_size=8, + lr=1e-3, + patience_early_stopping=7, + num_workers=0, + trainer_params={'deterministic': True} +) + +classifier.train( + X_train, y_train, + X_val, y_val, + training_config=training_config, + verbose=True +) + +# Step 7: Make Predictions +result = classifier.predict(X_test) +predictions = result["prediction"].squeeze().numpy() + +# Step 8: Evaluate +accuracy = (predictions == y_test).mean() +print(f"Test accuracy: {accuracy:.3f}") + +# Show results with class names +class_names = ["Negative", "Neutral", "Positive"] +for text, pred, true in zip(X_test, predictions, y_test): + predicted = class_names[pred] + actual = class_names[true] + status = "✅" if pred == true else "❌" + print(f"{status} Predicted: {predicted}, True: {actual}") + print(f" Text: {text}") +``` + +## Step-by-Step Walkthrough + +### Step 1: Ensuring Reproducibility + +For consistent results across runs, set seeds properly: + +```python +SEED = 42 +os.environ['PYTHONHASHSEED'] = str(SEED) +seed_everything(SEED, workers=True) +torch.backends.cudnn.deterministic = True +torch.use_deterministic_algorithms(True, warn_only=True) +``` + +**Why this matters:** + +- Makes experiments reproducible +- Enables fair comparison of hyperparameters +- Helps debug model behavior + +:::{tip} +Always set seeds when reporting results or comparing models! +::: + +### Step 2: Preparing Multiclass Data + +Unlike binary classification, you now have **3 classes**: + +```python +y_train = np.array([0, 0, 0, 0, 0, # negative (class 0) + 1, 1, 1, 1, 1, # neutral (class 1) + 2, 2, 2, 2, 2]) # positive (class 2) +``` + +**Important:** Class labels should be: +- **Integers**: 0, 1, 2, ... (not strings) +- **Continuous**: Start from 0, no gaps (0, 1, 2 not 0, 2, 5) +- **Balanced**: Ideally equal samples per class + +**Check class distribution:** + +```python +print(f"Negative: {sum(y_train==0)}") +print(f"Neutral: {sum(y_train==1)}") +print(f"Positive: {sum(y_train==2)}") +``` + +Output: +``` +Negative: 5 +Neutral: 5 +Positive: 5 +``` + +:::{note} +This example has perfectly balanced classes (5 samples each). Real datasets are often imbalanced. +::: + +### Step 3-4: Model Configuration + +The **only** difference from binary classification: + +```python +model_config = ModelConfig( + embedding_dim=64, + num_classes=3 # Change from 2 to 3 +) +``` + +**Under the hood:** + +- Binary: Uses 2 output neurons + CrossEntropyLoss +- Multiclass: Uses 3 output neurons + CrossEntropyLoss +- The loss function handles both automatically! + +### Step 5-6: Training + +Training is identical to binary classification: + +```python +classifier.train( + X_train, y_train, + X_val, y_val, + training_config=training_config +) +``` + +**Training process:** + +1. Forward pass: Text → Embeddings → Logits (3 values) +2. Loss calculation: Compare logits to true labels +3. Backward pass: Compute gradients +4. Update weights: Optimizer step +5. Repeat for each batch/epoch + +### Step 7: Making Predictions + +Predictions now return values in {0, 1, 2}: + +```python +result = classifier.predict(X_test) +predictions = result["prediction"].squeeze().numpy() +# Example: [0, 1, 2, 0, 1, 2] +``` + +**Probability interpretation:** + +You can also get probabilities for each class: + +```python +probabilities = result["confidence"].squeeze().numpy() +# Shape: (num_samples, 3) +# Each row sums to 1.0 +``` + +### Step 8: Evaluation + +For multiclass, use class names for clarity: + +```python +class_names = ["Negative", "Neutral", "Positive"] + +for pred, true in zip(predictions, y_test): + predicted_label = class_names[pred] + true_label = class_names[true] + print(f"Predicted: {predicted_label}, True: {true_label}") +``` + +**Output:** + +``` +✅ Predicted: Negative, True: Negative + Text: This is absolutely horrible! + +✅ Predicted: Neutral, True: Neutral + Text: It's an average product, nothing more. + +✅ Predicted: Positive, True: Positive + Text: Fantastic! Love every aspect of it! +``` + +## Advanced: Class Imbalance + +Real datasets often have unbalanced classes: + +```python +# Imbalanced example +y_train = [0]*100 + [1]*20 + [2]*10 # 100:20:10 ratio +``` + +**Solutions:** + +### 1. Class Weights + +Weight the loss function to penalize minority class errors more: + +```python +from torch import nn + +# Calculate class weights +class_counts = np.bincount(y_train) +class_weights = 1.0 / class_counts +class_weights = class_weights / class_weights.sum() # Normalize + +# Use weighted loss +training_config = TrainingConfig( + ... + loss=nn.CrossEntropyLoss(weight=torch.FloatTensor(class_weights)) +) +``` + +### 2. Oversampling/Undersampling + +Balance the dataset before training: + +```python +from sklearn.utils import resample + +# Oversample minority classes or undersample majority class +# (Use before creating the classifier) +``` + +### 3. Data Augmentation + +Generate synthetic samples for minority classes. + +## Evaluation Metrics + +For multiclass problems, accuracy isn't enough. Use: + +### Confusion Matrix + +```python +from sklearn.metrics import confusion_matrix, classification_report + +cm = confusion_matrix(y_test, predictions) +print("Confusion Matrix:") +print(cm) +# Pred 0 Pred 1 Pred 2 +# True 0 [[ 2 0 0] +# True 1 [ 0 2 0] +# True 2 [ 0 0 2]] +``` + +### Classification Report + +```python +report = classification_report( + y_test, predictions, + target_names=["Negative", "Neutral", "Positive"] +) +print(report) +``` + +**Output:** + +``` + precision recall f1-score support + + Negative 1.00 1.00 1.00 2 + Neutral 1.00 1.00 1.00 2 + Positive 1.00 1.00 1.00 2 + + accuracy 1.00 6 + macro avg 1.00 1.00 1.00 6 +weighted avg 1.00 1.00 1.00 6 +``` + +**Metrics explained:** + +- **Precision**: Of predicted class X, how many were correct? +- **Recall**: Of true class X, how many did we find? +- **F1-score**: Harmonic mean of precision and recall +- **Support**: Number of samples in each class + +## Extending to More Classes + +For 5 classes (e.g., star ratings 1-5): + +```python +# Data with 5 classes +y_train = np.array([0, 1, 2, 3, 4, ...]) # 0=1-star, 4=5-star + +# Model configuration +model_config = ModelConfig( + embedding_dim=64, + num_classes=5 # Change to 5 +) +``` + +The same code works for any number of classes! + +## Common Issues + +### Issue: Poor Performance on Middle Classes + +**Problem:** Neutral class has low accuracy + +**Solution:** + +1. Collect more neutral examples +2. Make the distinction clearer in your data +3. Consider if neutral is necessary (binary might be better) + +### Issue: Model Always Predicts One Class + +**Symptoms:** All predictions are class 0 or class 2 + +**Solutions:** + +1. Check class balance - might be too imbalanced +2. Verify labels are correct (0, 1, 2 not 1, 2, 3) +3. Lower learning rate +4. Train for more epochs + +### Issue: Overfitting + +**Symptoms:** High training accuracy, low test accuracy + +**Solutions:** + +1. Reduce `embedding_dim` +2. Add more training data +3. Use stronger early stopping (lower `patience`) + +## Next Steps + +Now that you understand multiclass classification: + +1. **Add categorical features**: Combine text with metadata +2. **Try multilabel classification**: Multiple labels per sample +3. **Use explainability**: See which words matter for each class +4. **Explore advanced architectures**: Add attention mechanisms + +## Complete Working Example + +Find the full code in the repository: +- [examples/multiclass_classification.py](https://github.com/InseeFrLab/torchTextClassifiers/blob/main/examples/multiclass_classification.py) + +## Summary + +In this tutorial, you learned: + +- ✅ How to set up multiclass classification (3+ classes) +- ✅ How to configure `num_classes` correctly +- ✅ How to ensure reproducible results with proper seeding +- ✅ How to check and handle class distribution +- ✅ How to evaluate multiclass models with confusion matrices +- ✅ How to handle class imbalance + +You're now ready to tackle real-world multiclass problems! diff --git a/docs/source/tutorials/multilabel_classification.md b/docs/source/tutorials/multilabel_classification.md new file mode 100644 index 0000000..4c0a650 --- /dev/null +++ b/docs/source/tutorials/multilabel_classification.md @@ -0,0 +1,642 @@ +# Multilabel Classification + +Learn how to assign multiple labels to each text sample, enabling more complex classification scenarios. + +## Learning Objectives + +By the end of this tutorial, you'll be able to: + +- Understand multilabel vs. multiclass classification +- Use both ragged-list and one-hot encoding approaches +- Configure appropriate loss functions for multilabel tasks +- Evaluate multilabel predictions +- Handle variable numbers of labels per sample + +## Prerequisites + +- Completed {doc}`multiclass_classification` tutorial +- Understanding of binary classification +- Familiarity with numpy arrays + +## Multilabel vs. Multiclass + +### Multiclass Classification + +Each sample has **exactly one label** from multiple classes: + +```python +texts = ["Sports article", "Tech news", "Business report"] +labels = [0, 1, 2] # Each sample has ONE label +``` + +### Multilabel Classification + +Each sample can have **zero, one, or multiple labels**: + +```python +texts = [ + "Article about AI in healthcare", # Both Tech AND Health + "Sports news from Europe", # Both Sports AND Europe + "Local business report" # Just Business +] + +# Multiple labels per sample +labels = [ + [1, 3], # Tech (1) + Health (3) + [0, 4], # Sports (0) + Europe (4) + [2] # Business (2) only +] +``` + +### Real-World Use Cases + +✅ **Document tagging**: Article can have multiple topics +✅ **Product categorization**: Product can belong to multiple categories +✅ **Symptom detection**: Patient can have multiple symptoms +✅ **Content moderation**: Content can violate multiple rules +✅ **Multi-genre classification**: Movie can have multiple genres + +## Two Approaches to Multilabel + +### Approach 1: Ragged Lists (Recommended) + +Each sample has a **list of label indices**: + +```python +labels = [ + [0, 1, 5], # Sample has labels 0, 1, and 5 + [0, 4], # Sample has labels 0 and 4 + [1, 5], # Sample has labels 1 and 5 +] +``` + +**Pros:** +- Natural representation +- Saves memory +- Easy to construct + +**Cons:** +- Can't directly convert to numpy array +- Variable-length lists + +### Approach 2: One-Hot Encoding + +Each sample has a **binary vector** (1 = label present, 0 = absent): + +```python +labels = [ + [1, 1, 0, 0, 0, 1], # Labels 0, 1, 5 present + [1, 0, 0, 0, 1, 0], # Labels 0, 4 present + [0, 1, 0, 0, 0, 1], # Labels 1, 5 present +] +``` + +**Pros:** +- Fixed-size numpy array +- Can store probabilities (not just 0/1) + +**Cons:** +- Memory-intensive for many labels +- Sparse representation + +## Complete Example: Ragged Lists + +```python +import numpy as np +import torch +from torchTextClassifiers import ModelConfig, TrainingConfig, torchTextClassifiers +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +# Sample data: Each text can have multiple labels +texts = [ + "This is a positive example", + "This is a negative example", + "Another positive case", + "Another negative case", + "Good example here", + "Bad example here" +] + +# Ragged lists: Variable-length label lists +labels = [ + [0, 1, 5], # Has 3 labels + [0, 4], # Has 2 labels + [1, 5], # Has 2 labels + [0, 1, 4], # Has 3 labels + [1, 5], # Has 2 labels + [0] # Has 1 label +] + +# Prepare data +X = np.array(texts) +y = np.array(labels, dtype=object) # dtype=object for ragged lists + +# Create tokenizer +tokenizer = WordPieceTokenizer(vocab_size=1000) +tokenizer.train(X.tolist()) + +# Calculate number of classes +num_classes = max(max(label_list) for label_list in labels) + 1 + +# Configure model +model_config = ModelConfig( + embedding_dim=96, + num_classes=num_classes +) + +# IMPORTANT: Use BCEWithLogitsLoss for multilabel +training_config = TrainingConfig( + lr=1e-3, + batch_size=4, + num_epochs=10, + loss=torch.nn.BCEWithLogitsLoss() # Multilabel loss +) + +# Create classifier with ragged_multilabel=True +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config, + ragged_multilabel=True # Key parameter! +) + +# Train +classifier.train( + X_train=X, + y_train=y, + training_config=training_config +) + +# Predict +result = classifier.predict(X) +predictions = result["prediction"] +``` + +## Complete Example: One-Hot Encoding + +```python +import numpy as np +import torch +from torchTextClassifiers import ModelConfig, TrainingConfig, torchTextClassifiers +from torchTextClassifiers.tokenizers import WordPieceTokenizer + +# Same texts +texts = [ + "This is a positive example", + "This is a negative example", + "Another positive case", + "Another negative case", + "Good example here", + "Bad example here" +] + +# One-hot encoding: Binary vectors +# 6 samples, 6 possible labels (0-5) +labels = [ + [1., 1., 0., 0., 0., 1.], # Labels 0, 1, 5 present + [1., 0., 0., 0., 1., 0.], # Labels 0, 4 present + [0., 1., 0., 0., 0., 1.], # Labels 1, 5 present + [1., 1., 0., 0., 1., 0.], # Labels 0, 1, 4 present + [0., 1., 0., 0., 0., 1.], # Labels 1, 5 present + [1., 0., 0., 0., 0., 0.] # Label 0 present +] + +# Prepare data +X = np.array(texts) +y = np.array(labels) # Can convert to numpy array now! + +# Create tokenizer +tokenizer = WordPieceTokenizer(vocab_size=1000) +tokenizer.train(X.tolist()) + +# Configure model +num_classes = y.shape[1] # Number of columns + +model_config = ModelConfig( + embedding_dim=96, + num_classes=num_classes +) + +training_config = TrainingConfig( + lr=1e-3, + batch_size=4, + num_epochs=10, + loss=torch.nn.BCEWithLogitsLoss() +) + +# Create classifier with ragged_multilabel=False (default) +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config, + ragged_multilabel=False # or omit (default is False) +) + +# Train +classifier.train( + X_train=X, + y_train=y, + training_config=training_config +) + +# Predict +result = classifier.predict(X) +predictions = result["prediction"] +``` + +## Step-by-Step Walkthrough + +### 1. Choose Your Approach + +**Use Ragged Lists if:** +- You have variable numbers of labels per sample +- Memory is a concern +- Data is naturally in list format + +**Use One-Hot if:** +- You need fixed-size arrays +- You want to store probabilities +- You're integrating with systems expecting one-hot + +### 2. Prepare Labels + +#### Ragged Lists + +```python +# List of lists (variable length) +labels = [[0, 1], [1, 2, 3], [0]] + +# Convert to numpy array with dtype=object +y = np.array(labels, dtype=object) +``` + +#### One-Hot Encoding + +```python +# Manual creation +labels = [ + [1, 0, 0, 0], # Label 0 + [0, 1, 1, 1], # Labels 1, 2, 3 + [1, 0, 0, 0] # Label 0 +] + +# Or convert from ragged lists +from sklearn.preprocessing import MultiLabelBinarizer + +ragged_labels = [[0, 1], [1, 2, 3], [0]] +mlb = MultiLabelBinarizer() +one_hot_labels = mlb.fit_transform(ragged_labels) +``` + +### 3. Configure Loss Function + +**Always use `BCEWithLogitsLoss` for multilabel:** + +```python +import torch + +training_config = TrainingConfig( + # ... other params ... + loss=torch.nn.BCEWithLogitsLoss() +) +``` + +**Why not CrossEntropyLoss?** +- `CrossEntropyLoss`: Classes compete (only one can win) +- `BCEWithLogitsLoss`: Each label is independent binary decision + +### 4. Set ragged_multilabel Flag + +```python +# For ragged lists +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config, + ragged_multilabel=True # Must be True +) + +# For one-hot encoding +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config, + ragged_multilabel=False # Must be False (default) +) +``` + +**Warning:** Setting wrong flag leads to incorrect behavior! + +### 5. Understanding Predictions + +#### Ragged Lists + +Predictions are **probability scores** for each label: + +```python +result = classifier.predict(X_test) +predictions = result["prediction"] # Shape: (n_samples, n_classes) + +# For each sample, check which labels are active +threshold = 0.5 +for i, pred in enumerate(predictions): + active_labels = [j for j, prob in enumerate(pred) if prob > threshold] + print(f"Sample {i}: {active_labels}") +``` + +#### One-Hot Encoding + +Same format - probabilities for each label: + +```python +predictions = result["prediction"] # Shape: (n_samples, n_classes) + +# Apply threshold +predicted_labels = (predictions > 0.5).astype(int) +``` + +## Evaluation Metrics + +### Exact Match Accuracy + +All labels must match exactly: + +```python +def exact_match_accuracy(y_true, y_pred, threshold=0.5): + """Calculate exact match accuracy.""" + y_pred_binary = (y_pred > threshold).astype(int) + + # Check if each sample matches exactly + matches = np.all(y_pred_binary == y_true, axis=1) + return matches.mean() + +accuracy = exact_match_accuracy(y_test, predictions) +print(f"Exact Match Accuracy: {accuracy:.3f}") +``` + +### Hamming Loss + +Average per-label error: + +```python +from sklearn.metrics import hamming_loss + +# Convert predictions to binary +y_pred_binary = (predictions > 0.5).astype(int) + +loss = hamming_loss(y_test, y_pred_binary) +print(f"Hamming Loss: {loss:.3f}") # Lower is better +``` + +### F1 Score + +Harmonic mean of precision and recall: + +```python +from sklearn.metrics import f1_score + +# Micro: Calculate globally +f1_micro = f1_score(y_test, y_pred_binary, average='micro') + +# Macro: Average per label +f1_macro = f1_score(y_test, y_pred_binary, average='macro') + +# Weighted: Weighted by support +f1_weighted = f1_score(y_test, y_pred_binary, average='weighted') + +print(f"F1 Micro: {f1_micro:.3f}") +print(f"F1 Macro: {f1_macro:.3f}") +print(f"F1 Weighted: {f1_weighted:.3f}") +``` + +### Subset Accuracy + +Same as exact match accuracy: + +```python +from sklearn.metrics import accuracy_score + +subset_acc = accuracy_score(y_test, y_pred_binary) +print(f"Subset Accuracy: {subset_acc:.3f}") +``` + +## Real-World Example: Document Tagging + +```python +import numpy as np +from sklearn.model_selection import train_test_split +from sklearn.metrics import classification_report + +# Document tagging dataset +texts = [ + "Python tutorial for machine learning", + "Introduction to neural networks", + "Web development with JavaScript", + "Data visualization with Python", + "Deep learning research paper", + "Building REST APIs in Python" +] + +# Labels: 0=Programming, 1=AI/ML, 2=Web, 3=Data, 4=Research +labels = [ + [0, 1], # Programming + AI/ML + [1, 4], # AI/ML + Research + [0, 2], # Programming + Web + [0, 3], # Programming + Data + [1, 4], # AI/ML + Research + [0, 2] # Programming + Web +] + +# Prepare data +X = np.array(texts) +y = np.array(labels, dtype=object) + +# Split +X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=0.33, random_state=42 +) + +# Train model +tokenizer = WordPieceTokenizer(vocab_size=1000) +tokenizer.train(X_train.tolist()) + +num_classes = 5 + +model_config = ModelConfig( + embedding_dim=64, + num_classes=num_classes +) + +training_config = TrainingConfig( + lr=1e-3, + batch_size=2, + num_epochs=50, + loss=torch.nn.BCEWithLogitsLoss() +) + +classifier = torchTextClassifiers( + tokenizer=tokenizer, + model_config=model_config, + ragged_multilabel=True +) + +classifier.train(X_train, y_train, training_config=training_config) + +# Predict and evaluate +result = classifier.predict(X_test) +predictions = result["prediction"] + +# Convert to binary predictions +y_pred_binary = (predictions > 0.5).astype(int) + +# Convert ragged y_test to one-hot for evaluation +from sklearn.preprocessing import MultiLabelBinarizer +mlb = MultiLabelBinarizer(classes=range(num_classes)) +y_test_binary = mlb.fit_transform(y_test) + +# Evaluate +from sklearn.metrics import classification_report + +label_names = ['Programming', 'AI/ML', 'Web', 'Data', 'Research'] +print(classification_report( + y_test_binary, + y_pred_binary, + target_names=label_names +)) +``` + +## Common Issues + +### Issue 1: Wrong ragged_multilabel Setting + +**Error:** Model trains but predictions are incorrect + +**Solution:** Ensure flag matches your data format: +```python +# Ragged lists → ragged_multilabel=True +# One-hot → ragged_multilabel=False +``` + +### Issue 2: Using CrossEntropyLoss + +**Problem:** Model doesn't learn properly + +**Solution:** Always use `BCEWithLogitsLoss`: +```python +training_config = TrainingConfig( + loss=torch.nn.BCEWithLogitsLoss() +) +``` + +### Issue 3: Shape Mismatch + +**Error:** "Expected 2D array for labels" + +**Solution:** For ragged lists, use `dtype=object`: +```python +y = np.array(labels, dtype=object) +``` + +### Issue 4: All Predictions Same + +**Possible causes:** +- Not enough training data +- Learning rate too high/low +- Class imbalance + +**Try:** +- Increase training epochs +- Adjust learning rate +- Check label distribution + +## Customization + +### Custom Threshold + +Adjust sensitivity vs. precision: + +```python +# Conservative (higher precision) +threshold = 0.7 +predicted_labels = (predictions > threshold).astype(int) + +# Aggressive (higher recall) +threshold = 0.3 +predicted_labels = (predictions > threshold).astype(int) +``` + +### Class Weights + +Handle imbalanced labels: + +```python +# Calculate class weights +from sklearn.utils.class_weight import compute_class_weight + +# For one-hot labels +class_weights = compute_class_weight( + 'balanced', + classes=np.arange(num_classes), + y=y_train.argmax(axis=1) # Works for imbalanced data +) + +# Use in loss (requires custom loss function) +``` + +### With Attention + +For long documents: + +```python +from torchTextClassifiers.model.components import AttentionConfig + +attention_config = AttentionConfig( + n_embd=128, + n_head=8, + n_layer=3 +) + +model_config = ModelConfig( + embedding_dim=128, + num_classes=num_classes, + attention_config=attention_config +) +``` + +## Advanced: Probabilistic Labels + +One-hot encoding supports probabilities: + +```python +# Soft labels (not just 0 or 1) +labels = [ + [0.9, 0.8, 0.1, 0.0, 0.0, 0.7], # Confident in 0,1,5 + [0.6, 0.0, 0.0, 0.0, 0.5, 0.0], # Less confident in 0,4 +] + +y = np.array(labels) # Probabilities between 0 and 1 + +# Use same setup, BCEWithLogitsLoss handles probabilities +``` + +## Best Practices + +1. **Choose the right approach:** Ragged lists for most cases, one-hot for probabilities +2. **Always use BCEWithLogitsLoss:** Essential for multilabel +3. **Set ragged_multilabel correctly:** Matches your data format +4. **Use appropriate metrics:** F1, Hamming loss better than accuracy +5. **Tune threshold:** Balance precision vs. recall for your use case +6. **Handle imbalance:** Common in multilabel - consider class weights + +## Summary + +**Key takeaways:** +- Multilabel: Each sample can have multiple labels +- Two approaches: Ragged lists (recommended) or one-hot encoding +- Always use `BCEWithLogitsLoss` for multilabel tasks +- Set `ragged_multilabel=True` for ragged lists +- Evaluate with F1, Hamming loss, or exact match accuracy + +Ready to combine everything? Try adding categorical features to multilabel classification, or use explainability to understand multilabel predictions! + +## Next Steps + +- **Mixed features**: Combine multilabel with categorical features +- **Explainability**: Understand which words trigger which labels +- **API Reference**: See {doc}`../api/index` for detailed documentation diff --git a/pyproject.toml b/pyproject.toml index c01919b..c64127c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,13 +35,17 @@ dev = [ "ipywidgets>=8.1.8", ] docs = [ - "sphinx>=5.0.0", - "sphinx-rtd-theme>=1.2.0", - "sphinx-autodoc-typehints>=1.19.0", - "sphinxcontrib-napoleon>=0.7", + "sphinx>=8.1.0", + "pydata-sphinx-theme>=0.16.0", + "sphinx-autodoc-typehints>=2.0.0", "sphinx-copybutton>=0.5.0", - "myst-parser>=0.18.0", - "sphinx-design>=0.3.0" + "myst-parser>=4.0.0", + "sphinx-design>=0.6.0", + "nbsphinx>=0.9.0", + "ipython>=8.0.0", + "pandoc>=2.0.0", + "linkify-it-py>=2.0.0", + "sphinxcontrib-mermaid>=0.9.0", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index b552fa4..39ef300 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,18 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -145,6 +157,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "bleach" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + [[package]] name = "captum" version = "0.7.0" @@ -169,6 +211,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -353,6 +465,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "dill" version = "0.4.0" @@ -389,6 +510,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + [[package]] name = "filelock" version = "3.18.0" @@ -712,6 +842,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/4f/1195bbac8e0c2acc5f740661631d8d750dc38d4a32b23ee5df3cde6f4e0d/joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a", size = 307746, upload-time = "2025-05-23T12:04:35.124Z" }, ] +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + [[package]] name = "jupyterlab-widgets" version = "3.0.16" @@ -801,6 +996,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/c1/31b3184cba7b257a4a3b5ca5b88b9204ccb7aa02fe3c992280899293ed54/lightning_utilities-0.14.3-py3-none-any.whl", hash = "sha256:4ab9066aa36cd7b93a05713808901909e96cc3f187ea6fd3052b2fd91313b468", size = 28894, upload-time = "2025-04-03T15:59:55.658Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -937,6 +1144,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mistune" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -1056,6 +1272,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, ] +[[package]] +name = "nbclient" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424, upload-time = "2024-12-19T10:32:27.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434, upload-time = "2024-12-19T10:32:24.139Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.16.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715, upload-time = "2025-01-28T09:29:14.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525, upload-time = "2025-01-28T09:29:12.551Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nbsphinx" +version = "0.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "sphinx" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/84/b1856b7651ac34e965aa567a158714c7f3bd42a1b1ce76bf423ffb99872c/nbsphinx-0.9.7.tar.gz", hash = "sha256:abd298a686d55fa894ef697c51d44f24e53aa312dadae38e82920f250a5456fe", size = 180479, upload-time = "2025-03-03T19:46:08.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/2d/8c8e635bcc6757573d311bb3c5445426382f280da32b8cd6d82d501ef4a4/nbsphinx-0.9.7-py3-none-any.whl", hash = "sha256:7292c3767fea29e405c60743eee5393682a83982ab202ff98f5eb2db02629da8", size = 31660, upload-time = "2025-03-03T19:46:06.581Z" }, +] + [[package]] name = "networkx" version = "3.4.2" @@ -1320,6 +1608,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" }, ] +[[package]] +name = "pandoc" +version = "2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "plumbum" }, + { name = "ply" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/9a/e3186e760c57ee5f1c27ea5cea577a0ff9abfca51eefcb4d9a4cd39aff2e/pandoc-2.4.tar.gz", hash = "sha256:ecd1f8cbb7f4180c6b5db4a17a7c1a74df519995f5f186ef81ce72a9cbd0dd9a", size = 34635, upload-time = "2024-08-07T14:33:58.016Z" } + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + [[package]] name = "parso" version = "0.8.5" @@ -1419,15 +1726,24 @@ wheels = [ ] [[package]] -name = "pockets" -version = "0.9.1" +name = "plumbum" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/8e/0601097cfcce2e8c2297db5080e9719f549c2bd4b94420ddc8d3f848bbca/pockets-0.9.1.tar.gz", hash = "sha256:9320f1a3c6f7a9133fe3b571f283bcf3353cd70249025ae8d618e40e9f7e92b3", size = 24993, upload-time = "2019-11-02T14:46:19.433Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/c8/11a5f792704b70f071a3dbc329105a98e9cc8d25daaf09f733c44eb0ef8e/plumbum-1.10.0.tar.gz", hash = "sha256:f8cbf0ecec0b73ff4e349398b65112a9e3f9300e7dc019001217dcc148d5c97c", size = 320039, upload-time = "2025-10-31T05:02:48.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/2f/a4583c70fbd8cd04910e2884bcc2bdd670e884061f7b4d70bc13e632a993/pockets-0.9.1-py2.py3-none-any.whl", hash = "sha256:68597934193c08a08eb2bf6a1d85593f627c22f9b065cc727a4f03f669d96d86", size = 26263, upload-time = "2019-11-02T14:46:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/79/ad/45312df6b63ba64ea35b8d8f5f0c577aac16e6b416eafe8e1cb34e03f9a7/plumbum-1.10.0-py3-none-any.whl", hash = "sha256:9583d737ac901c474d99d030e4d5eec4c4e6d2d7417b1cf49728cf3be34f6dc8", size = 127383, upload-time = "2025-10-31T05:02:47.002Z" }, +] + +[[package]] +name = "ply" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, ] [[package]] @@ -1599,6 +1915,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/03/f335d6c52b4a4761bcc83499789a1e2e16d9d201a58c327a9b5cc9a41bd9/pyarrow-22.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c34fe18094686194f204a3b1787a27456897d8a2d62caf84b61e8dfbc0252ae", size = 29185594, upload-time = "2025-10-24T10:09:53.111Z" }, ] +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydata-sphinx-theme" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "babel" }, + { name = "beautifulsoup4" }, + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/20/bb50f9de3a6de69e6abd6b087b52fa2418a0418b19597601605f855ad044/pydata_sphinx_theme-0.16.1.tar.gz", hash = "sha256:a08b7f0b7f70387219dc659bff0893a7554d5eb39b59d3b8ef37b8401b7642d7", size = 2412693, upload-time = "2024-12-17T10:53:39.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/0d/8ba33fa83a7dcde13eb3c1c2a0c1cc29950a048bfed6d9b0d8b6bd710b4c/pydata_sphinx_theme-0.16.1-py3-none-any.whl", hash = "sha256:225331e8ac4b32682c18fcac5a57a6f717c4e632cea5dd0e247b55155faeccde", size = 6723264, upload-time = "2024-12-17T10:53:35.645Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1673,6 +2016,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1708,6 +2070,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "regex" version = "2024.11.6" @@ -1777,12 +2211,111 @@ wheels = [ ] [[package]] -name = "roman-numerals-py" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, +name = "rpds-py" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/ab/7fb95163a53ab122c74a7c42d2d2f012819af2cf3deb43fb0d5acf45cc1a/rpds_py-0.29.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9b9c764a11fd637e0322a488560533112837f5334ffeb48b1be20f6d98a7b437", size = 372344, upload-time = "2025-11-16T14:47:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/b3/45/f3c30084c03b0d0f918cb4c5ae2c20b0a148b51ba2b3f6456765b629bedd/rpds_py-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fd2164d73812026ce970d44c3ebd51e019d2a26a4425a5dcbdfa93a34abc383", size = 363041, upload-time = "2025-11-16T14:47:58.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e9/4d044a1662608c47a87cbb37b999d4d5af54c6d6ebdda93a4d8bbf8b2a10/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a097b7f7f7274164566ae90a221fd725363c0e9d243e2e9ed43d195ccc5495c", size = 391775, upload-time = "2025-11-16T14:48:00.197Z" }, + { url = "https://files.pythonhosted.org/packages/50/c9/7616d3ace4e6731aeb6e3cd85123e03aec58e439044e214b9c5c60fd8eb1/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cdc0490374e31cedefefaa1520d5fe38e82fde8748cbc926e7284574c714d6b", size = 405624, upload-time = "2025-11-16T14:48:01.496Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/6d7d6941ca0843609fd2d72c966a438d6f22617baf22d46c3d2156c31350/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89ca2e673ddd5bde9b386da9a0aac0cab0e76f40c8f0aaf0d6311b6bbf2aa311", size = 527894, upload-time = "2025-11-16T14:48:03.167Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f7/aee14dc2db61bb2ae1e3068f134ca9da5f28c586120889a70ff504bb026f/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5d9da3ff5af1ca1249b1adb8ef0573b94c76e6ae880ba1852f033bf429d4588", size = 412720, upload-time = "2025-11-16T14:48:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e2/2293f236e887c0360c2723d90c00d48dee296406994d6271faf1712e94ec/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8238d1d310283e87376c12f658b61e1ee23a14c0e54c7c0ce953efdbdc72deed", size = 392945, upload-time = "2025-11-16T14:48:06.252Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/ceea6147acd3bd1fd028d1975228f08ff19d62098078d5ec3eed49703797/rpds_py-0.29.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2d6fb2ad1c36f91c4646989811e84b1ea5e0c3cf9690b826b6e32b7965853a63", size = 406385, upload-time = "2025-11-16T14:48:07.575Z" }, + { url = "https://files.pythonhosted.org/packages/52/36/fe4dead19e45eb77a0524acfdbf51e6cda597b26fc5b6dddbff55fbbb1a5/rpds_py-0.29.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:534dc9df211387547267ccdb42253aa30527482acb38dd9b21c5c115d66a96d2", size = 423943, upload-time = "2025-11-16T14:48:10.175Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7b/4551510803b582fa4abbc8645441a2d15aa0c962c3b21ebb380b7e74f6a1/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d456e64724a075441e4ed648d7f154dc62e9aabff29bcdf723d0c00e9e1d352f", size = 574204, upload-time = "2025-11-16T14:48:11.499Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/071ccdd7b171e727a6ae079f02c26f75790b41555f12ca8f1151336d2124/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a738f2da2f565989401bd6fd0b15990a4d1523c6d7fe83f300b7e7d17212feca", size = 600587, upload-time = "2025-11-16T14:48:12.822Z" }, + { url = "https://files.pythonhosted.org/packages/03/09/96983d48c8cf5a1e03c7d9cc1f4b48266adfb858ae48c7c2ce978dbba349/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a110e14508fd26fd2e472bb541f37c209409876ba601cf57e739e87d8a53cf95", size = 562287, upload-time = "2025-11-16T14:48:14.108Z" }, + { url = "https://files.pythonhosted.org/packages/40/f0/8c01aaedc0fa92156f0391f39ea93b5952bc0ec56b897763858f95da8168/rpds_py-0.29.0-cp311-cp311-win32.whl", hash = "sha256:923248a56dd8d158389a28934f6f69ebf89f218ef96a6b216a9be6861804d3f4", size = 221394, upload-time = "2025-11-16T14:48:15.374Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/a8b21c54c7d234efdc83dc034a4d7cd9668e3613b6316876a29b49dece71/rpds_py-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:539eb77eb043afcc45314d1be09ea6d6cafb3addc73e0547c171c6d636957f60", size = 235713, upload-time = "2025-11-16T14:48:16.636Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1f/df3c56219523947b1be402fa12e6323fe6d61d883cf35d6cb5d5bb6db9d9/rpds_py-0.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:bdb67151ea81fcf02d8f494703fb728d4d34d24556cbff5f417d74f6f5792e7c", size = 229157, upload-time = "2025-11-16T14:48:17.891Z" }, + { url = "https://files.pythonhosted.org/packages/3c/50/bc0e6e736d94e420df79be4deb5c9476b63165c87bb8f19ef75d100d21b3/rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", size = 376000, upload-time = "2025-11-16T14:48:19.141Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/46676277160f014ae95f24de53bed0e3b7ea66c235e7de0b9df7bd5d68ba/rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", size = 360575, upload-time = "2025-11-16T14:48:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ba/411d414ed99ea1afdd185bbabeeaac00624bd1e4b22840b5e9967ade6337/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", size = 392159, upload-time = "2025-11-16T14:48:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b1/e18aa3a331f705467a48d0296778dc1fea9d7f6cf675bd261f9a846c7e90/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", size = 410602, upload-time = "2025-11-16T14:48:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/04f27f0c9f2299274c76612ac9d2c36c5048bb2c6c2e52c38c60bf3868d9/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", size = 515808, upload-time = "2025-11-16T14:48:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/83/56/a8412aa464fb151f8bc0d91fb0bb888adc9039bd41c1c6ba8d94990d8cf8/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", size = 416015, upload-time = "2025-11-16T14:48:26.782Z" }, + { url = "https://files.pythonhosted.org/packages/04/4c/f9b8a05faca3d9e0a6397c90d13acb9307c9792b2bff621430c58b1d6e76/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", size = 395325, upload-time = "2025-11-16T14:48:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/34/60/869f3bfbf8ed7b54f1ad9a5543e0fdffdd40b5a8f587fe300ee7b4f19340/rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", size = 410160, upload-time = "2025-11-16T14:48:29.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/aa/e5b496334e3aba4fe4c8a80187b89f3c1294c5c36f2a926da74338fa5a73/rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", size = 425309, upload-time = "2025-11-16T14:48:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/4e24a34189751ceb6d66b28f18159922828dd84155876551f7ca5b25f14f/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", size = 574644, upload-time = "2025-11-16T14:48:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/474a005ea4ea9c3b4f17b6108b6b13cebfc98ebaff11d6e1b193204b3a93/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", size = 601605, upload-time = "2025-11-16T14:48:33.252Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b1/c56f6a9ab8c5f6bb5c65c4b5f8229167a3a525245b0773f2c0896686b64e/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", size = 564593, upload-time = "2025-11-16T14:48:34.643Z" }, + { url = "https://files.pythonhosted.org/packages/b3/13/0494cecce4848f68501e0a229432620b4b57022388b071eeff95f3e1e75b/rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", size = 223853, upload-time = "2025-11-16T14:48:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6a/51e9aeb444a00cdc520b032a28b07e5f8dc7bc328b57760c53e7f96997b4/rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", size = 239895, upload-time = "2025-11-16T14:48:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d4/8bce56cdad1ab873e3f27cb31c6a51d8f384d66b022b820525b879f8bed1/rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", size = 230321, upload-time = "2025-11-16T14:48:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/c5de60d9d371bbb186c3e9bf75f4fc5665e11117a25a06a6b2e0afb7380e/rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", size = 375710, upload-time = "2025-11-16T14:48:41.063Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b3/0860cdd012291dc21272895ce107f1e98e335509ba986dd83d72658b82b9/rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", size = 360582, upload-time = "2025-11-16T14:48:42.423Z" }, + { url = "https://files.pythonhosted.org/packages/92/8a/a18c2f4a61b3407e56175f6aab6deacdf9d360191a3d6f38566e1eaf7266/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", size = 391172, upload-time = "2025-11-16T14:48:43.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/49/e93354258508c50abc15cdcd5fcf7ac4117f67bb6233ad7859f75e7372a0/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", size = 409586, upload-time = "2025-11-16T14:48:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8d/a27860dae1c19a6bdc901f90c81f0d581df1943355802961a57cdb5b6cd1/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", size = 516339, upload-time = "2025-11-16T14:48:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/a75e603161e79b7110c647163d130872b271c6b28712c803c65d492100f7/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", size = 416201, upload-time = "2025-11-16T14:48:48.615Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/555b4ee17508beafac135c8b450816ace5a96194ce97fefc49d58e5652ea/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", size = 395095, upload-time = "2025-11-16T14:48:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f0/c90b671b9031e800ec45112be42ea9f027f94f9ac25faaac8770596a16a1/rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", size = 410077, upload-time = "2025-11-16T14:48:51.515Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/9af8b640b81fe21e6f718e9dec36c0b5f670332747243130a5490f292245/rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", size = 424548, upload-time = "2025-11-16T14:48:53.237Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0b/b5647446e991736e6a495ef510e6710df91e880575a586e763baeb0aa770/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", size = 573661, upload-time = "2025-11-16T14:48:54.769Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/1b1c9576839ff583d1428efbf59f9ee70498d8ce6c0b328ac02f1e470879/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", size = 600937, upload-time = "2025-11-16T14:48:56.247Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/b6cfca2f9fee4c4494ce54f7fb1b9f578867495a9aa9fc0d44f5f735c8e0/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", size = 564496, upload-time = "2025-11-16T14:48:57.691Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fb/ba29ec7f0f06eb801bac5a23057a9ff7670623b5e8013bd59bec4aa09de8/rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", size = 223126, upload-time = "2025-11-16T14:48:59.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/0229d3bed4ddaa409e6d90b0ae967ed4380e4bdd0dad6e59b92c17d42457/rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", size = 239771, upload-time = "2025-11-16T14:49:00.872Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/d2868f058b164f8efd89754d85d7b1c08b454f5c07ac2e6cc2e9bd4bd05b/rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", size = 229994, upload-time = "2025-11-16T14:49:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/52/91/5de91c5ec7d41759beec9b251630824dbb8e32d20c3756da1a9a9d309709/rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", size = 365886, upload-time = "2025-11-16T14:49:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/85/7c/415d8c1b016d5f47ecec5145d9d6d21002d39dce8761b30f6c88810b455a/rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", size = 355262, upload-time = "2025-11-16T14:49:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/bf83e2daa4f980e4dc848aed9299792a8b84af95e12541d9e7562f84a6ef/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", size = 384826, upload-time = "2025-11-16T14:49:07.301Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/53330c50a810ae22b4fbba5e6cf961b68b9d72d9bd6780a7c0a79b070857/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", size = 394234, upload-time = "2025-11-16T14:49:08.782Z" }, + { url = "https://files.pythonhosted.org/packages/cc/32/01e2e9645cef0e584f518cfde4567563e57db2257244632b603f61b40e50/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", size = 520008, upload-time = "2025-11-16T14:49:10.253Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/0d1b95a81affae2b10f950782e33a1fd2edd6ce2a479966cac98c9a66f57/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", size = 409569, upload-time = "2025-11-16T14:49:12.478Z" }, + { url = "https://files.pythonhosted.org/packages/fa/60/aa3b8678f3f009f675b99174fa2754302a7fbfe749162e8043d111de2d88/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", size = 385188, upload-time = "2025-11-16T14:49:13.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/5546c1c8aa89c18d40c1fcffdcc957ba730dee53fb7c3ca3a46f114761d2/rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", size = 398587, upload-time = "2025-11-16T14:49:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e0/ad6eeaf47e236eba052fa34c4073078b9e092bd44da6bbb35aaae9580669/rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", size = 416641, upload-time = "2025-11-16T14:49:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/0acedfd50ad9cdd3879c615a6dc8c5f1ce78d2fdf8b87727468bb5bb4077/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", size = 566683, upload-time = "2025-11-16T14:49:18.342Z" }, + { url = "https://files.pythonhosted.org/packages/62/53/8c64e0f340a9e801459fc6456821abc15b3582cb5dc3932d48705a9d9ac7/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", size = 592730, upload-time = "2025-11-16T14:49:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/85/ef/3109b6584f8c4b0d2490747c916df833c127ecfa82be04d9a40a376f2090/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", size = 557361, upload-time = "2025-11-16T14:49:21.574Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3b/61586475e82d57f01da2c16edb9115a618afe00ce86fe1b58936880b15af/rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", size = 211227, upload-time = "2025-11-16T14:49:23.03Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, + { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, + { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, + { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ac/b97e80bf107159e5b9ba9c91df1ab95f69e5e41b435f27bdd737f0d583ac/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:acd82a9e39082dc5f4492d15a6b6c8599aa21db5c35aaf7d6889aea16502c07d", size = 373963, upload-time = "2025-11-16T14:50:16.205Z" }, + { url = "https://files.pythonhosted.org/packages/40/5a/55e72962d5d29bd912f40c594e68880d3c7a52774b0f75542775f9250712/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:715b67eac317bf1c7657508170a3e011a1ea6ccb1c9d5f296e20ba14196be6b3", size = 364644, upload-time = "2025-11-16T14:50:18.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/2a/6b6524d0191b7fc1351c3c0840baac42250515afb48ae40c7ed15499a6a2/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3b1b87a237cb2dba4db18bcfaaa44ba4cd5936b91121b62292ff21df577fc43", size = 393847, upload-time = "2025-11-16T14:50:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/c5692a7df577b3c0c7faed7ac01ee3c608b81750fc5d89f84529229b6873/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c3c3e8101bb06e337c88eb0c0ede3187131f19d97d43ea0e1c5407ea74c0cbf", size = 407281, upload-time = "2025-11-16T14:50:21.64Z" }, + { url = "https://files.pythonhosted.org/packages/f0/57/0546c6f84031b7ea08b76646a8e33e45607cc6bd879ff1917dc077bb881e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8e54d6e61f3ecd3abe032065ce83ea63417a24f437e4a3d73d2f85ce7b7cfe", size = 529213, upload-time = "2025-11-16T14:50:23.219Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c1/01dd5f444233605555bc11fe5fed6a5c18f379f02013870c176c8e630a23/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fbd4e9aebf110473a420dea85a238b254cf8a15acb04b22a5a6b5ce8925b760", size = 413808, upload-time = "2025-11-16T14:50:25.262Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0a/60f98b06156ea2a7af849fb148e00fbcfdb540909a5174a5ed10c93745c7/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fdf53d36e6c72819993e35d1ebeeb8e8fc688d0c6c2b391b55e335b3afba5a", size = 394600, upload-time = "2025-11-16T14:50:26.956Z" }, + { url = "https://files.pythonhosted.org/packages/37/f1/dc9312fc9bec040ece08396429f2bd9e0977924ba7a11c5ad7056428465e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:ea7173df5d86f625f8dde6d5929629ad811ed8decda3b60ae603903839ac9ac0", size = 408634, upload-time = "2025-11-16T14:50:28.989Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/65024c9fd40c89bb7d604cf73beda4cbdbcebe92d8765345dd65855b6449/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:76054d540061eda273274f3d13a21a4abdde90e13eaefdc205db37c05230efce", size = 426064, upload-time = "2025-11-16T14:50:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e0/cf95478881fc88ca2fdbf56381d7df36567cccc39a05394beac72182cd62/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:9f84c549746a5be3bc7415830747a3a0312573afc9f95785eb35228bb17742ec", size = 575871, upload-time = "2025-11-16T14:50:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/df88097e64339a0218b57bd5f9ca49898e4c394db756c67fccc64add850a/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0ea962671af5cb9a260489e311fa22b2e97103e3f9f0caaea6f81390af96a9ed", size = 601702, upload-time = "2025-11-16T14:50:36.051Z" }, + { url = "https://files.pythonhosted.org/packages/87/f4/09ffb3ebd0cbb9e2c7c9b84d252557ecf434cd71584ee1e32f66013824df/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f7728653900035fb7b8d06e1e5900545d8088efc9d5d4545782da7df03ec803f", size = 564054, upload-time = "2025-11-16T14:50:37.733Z" }, ] [[package]] @@ -1949,9 +2482,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + [[package]] name = "sphinx" -version = "8.2.3" +version = "8.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alabaster" }, @@ -1963,7 +2505,6 @@ dependencies = [ { name = "packaging" }, { name = "pygments" }, { name = "requests" }, - { name = "roman-numerals-py" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, @@ -1972,21 +2513,21 @@ dependencies = [ { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, ] [[package]] name = "sphinx-autodoc-typehints" -version = "3.2.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/68/a388a9b8f066cd865d9daa65af589d097efbfab9a8c302d2cb2daa43b52e/sphinx_autodoc_typehints-3.2.0.tar.gz", hash = "sha256:107ac98bc8b4837202c88c0736d59d6da44076e65a0d7d7d543a78631f662a9b", size = 36724, upload-time = "2025-04-25T16:53:25.872Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/f0/43c6a5ff3e7b08a8c3b32f81b859f1b518ccc31e45f22e2b41ced38be7b9/sphinx_autodoc_typehints-3.0.1.tar.gz", hash = "sha256:b9b40dd15dee54f6f810c924f863f9cf1c54f9f3265c495140ea01be7f44fa55", size = 36282, upload-time = "2025-01-16T18:25:30.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/c7/8aab362e86cbf887e58be749a78d20ad743e1eb2c73c2b13d4761f39a104/sphinx_autodoc_typehints-3.2.0-py3-none-any.whl", hash = "sha256:884b39be23b1d884dcc825d4680c9c6357a476936e3b381a67ae80091984eb49", size = 20563, upload-time = "2025-04-25T16:53:24.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/dc/dc46c5c7c566b7ec5e8f860f9c89533bf03c0e6aadc96fb9b337867e4460/sphinx_autodoc_typehints-3.0.1-py3-none-any.whl", hash = "sha256:4b64b676a14b5b79cefb6628a6dc8070e320d4963e8ff640a2f3e9390ae9045a", size = 20245, upload-time = "2025-01-16T18:25:27.394Z" }, ] [[package]] @@ -2013,20 +2554,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, ] -[[package]] -name = "sphinx-rtd-theme" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "sphinx" }, - { name = "sphinxcontrib-jquery" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463, upload-time = "2024-11-13T11:06:04.545Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561, upload-time = "2024-11-13T11:06:02.094Z" }, -] - [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" @@ -2054,18 +2581,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, ] -[[package]] -name = "sphinxcontrib-jquery" -version = "4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, -] - [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" @@ -2076,16 +2591,16 @@ wheels = [ ] [[package]] -name = "sphinxcontrib-napoleon" -version = "0.7" +name = "sphinxcontrib-mermaid" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pockets" }, - { name = "six" }, + { name = "pyyaml" }, + { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/eb/ad89500f4cee83187596e07f43ad561f293e8e6e96996005c3319653b89f/sphinxcontrib-napoleon-0.7.tar.gz", hash = "sha256:407382beed396e9f2d7f3043fad6afda95719204a1e1a231ac865f40abcbfcf8", size = 21232, upload-time = "2018-09-23T14:16:47.272Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/83/11fe1f2968c05fae725e473e8b3be08cbe5f51b83ddaf3309ab4c841082a/sphinxcontrib_mermaid-1.2.2.tar.gz", hash = "sha256:35423c13e565abb839b13f955f9722f0769e77e5d607ca07877ce93e1636c196", size = 18851, upload-time = "2025-11-24T01:05:48.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/f2/6b7627dfe7b4e418e295e254bb15c3a6455f11f8c0ad0d43113f678049c3/sphinxcontrib_napoleon-0.7-py2.py3-none-any.whl", hash = "sha256:711e41a3974bdf110a484aec4c1a556799eb0b3f3b897521a018ad7e2db13fef", size = 17151, upload-time = "2018-09-23T14:16:45.548Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/f24437b92c3a34eadf93987d8472def6532200621df223e1b818c4318d63/sphinxcontrib_mermaid-1.2.2-py3-none-any.whl", hash = "sha256:51655f592300fc70e73b3ef2007cfc44fac11da5ff1f15c4725c83bf4a5b517c", size = 13416, upload-time = "2025-11-24T01:05:47.252Z" }, ] [[package]] @@ -2141,6 +2656,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, +] + [[package]] name = "tokenizers" version = "0.22.1" @@ -2267,13 +2794,17 @@ dev = [ { name = "unidecode" }, ] docs = [ + { name = "ipython" }, + { name = "linkify-it-py" }, { name = "myst-parser" }, + { name = "nbsphinx" }, + { name = "pandoc" }, + { name = "pydata-sphinx-theme" }, { name = "sphinx" }, { name = "sphinx-autodoc-typehints" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, - { name = "sphinx-rtd-theme" }, - { name = "sphinxcontrib-napoleon" }, + { name = "sphinxcontrib-mermaid" }, ] [package.metadata] @@ -2305,13 +2836,36 @@ dev = [ { name = "unidecode" }, ] docs = [ - { name = "myst-parser", specifier = ">=0.18.0" }, - { name = "sphinx", specifier = ">=5.0.0" }, - { name = "sphinx-autodoc-typehints", specifier = ">=1.19.0" }, + { name = "ipython", specifier = ">=8.0.0" }, + { name = "linkify-it-py", specifier = ">=2.0.0" }, + { name = "myst-parser", specifier = ">=4.0.0" }, + { name = "nbsphinx", specifier = ">=0.9.0" }, + { name = "pandoc", specifier = ">=2.0.0" }, + { name = "pydata-sphinx-theme", specifier = ">=0.16.0" }, + { name = "sphinx", specifier = ">=8.1.0" }, + { name = "sphinx-autodoc-typehints", specifier = ">=2.0.0" }, { name = "sphinx-copybutton", specifier = ">=0.5.0" }, - { name = "sphinx-design", specifier = ">=0.3.0" }, - { name = "sphinx-rtd-theme", specifier = ">=1.2.0" }, - { name = "sphinxcontrib-napoleon", specifier = ">=0.7" }, + { name = "sphinx-design", specifier = ">=0.6.0" }, + { name = "sphinxcontrib-mermaid", specifier = ">=0.9.0" }, +] + +[[package]] +name = "tornado" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, + { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, + { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, ] [[package]] @@ -2388,6 +2942,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + [[package]] name = "unidecode" version = "1.4.0" @@ -2429,6 +2992,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + [[package]] name = "widgetsnbextension" version = "4.0.15" From 4a4d617f353eaee73e0dcc94fc976b032a92cdb7 Mon Sep 17 00:00:00 2001 From: Meilame Tayebjee <114609737+meilame-tayebjee@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:29:21 +0100 Subject: [PATCH 2/5] fix(docs): wrong parameter name --- docs/source/api/components.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/api/components.rst b/docs/source/api/components.rst index d42a90f..5ac2f9f 100644 --- a/docs/source/api/components.rst +++ b/docs/source/api/components.rst @@ -147,7 +147,7 @@ Example: nn.Linear(64, 5) ) - head = ClassificationHead(linear=custom_head_module) + head = ClassificationHead(net=custom_head_module) Attention Mechanism ------------------- @@ -270,3 +270,4 @@ See Also * :doc:`model` - How components are used in models * :doc:`../architecture/overview` - Architecture explanation * :doc:`configs` - ModelConfig for component configuration + From 5badbb352f8e70dc44450beee4a9fabf2638a5c5 Mon Sep 17 00:00:00 2001 From: Meilame Tayebjee <114609737+meilame-tayebjee@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:34:25 +0100 Subject: [PATCH 3/5] format --- docs/source/architecture/overview.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/architecture/overview.md b/docs/source/architecture/overview.md index d3e47a1..cb72370 100644 --- a/docs/source/architecture/overview.md +++ b/docs/source/architecture/overview.md @@ -4,7 +4,7 @@ torchTextClassifiers is a **modular, component-based framework** for text classi ## The Pipeline -At its core, torch Text Classifiers processes data through a simple pipeline: +At its core, torchTextClassifiers processes data through a simple pipeline: ```{mermaid} flowchart LR @@ -617,3 +617,4 @@ torchTextClassifiers provides a **component-based pipeline** for text classifica - **Examples**: Explore complete examples in the repository Ready to build your classifier? Start with {doc}`../getting_started/quickstart`! + From 55635b0fa12a15333d3fdd9244b09d71632dda87 Mon Sep 17 00:00:00 2001 From: Meilame Tayebjee <114609737+meilame-tayebjee@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:57:47 +0100 Subject: [PATCH 4/5] fix(docs - multilabel tuto): wording Updated the multilabel classification tutorial to enhance clarity and structure, including examples and best practices. --- .../tutorials/multilabel_classification.md | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/docs/source/tutorials/multilabel_classification.md b/docs/source/tutorials/multilabel_classification.md index 4c0a650..b9a876c 100644 --- a/docs/source/tutorials/multilabel_classification.md +++ b/docs/source/tutorials/multilabel_classification.md @@ -58,7 +58,7 @@ labels = [ ## Two Approaches to Multilabel -### Approach 1: Ragged Lists (Recommended) +### Approach 1: Ragged Lists Each sample has a **list of label indices**: @@ -72,14 +72,14 @@ labels = [ **Pros:** - Natural representation -- Saves memory - Easy to construct **Cons:** - Can't directly convert to numpy array - Variable-length lists +- Forward pass a bit slower (we convert to one-hot encodings behind the scene, on the fly for each batch) -### Approach 2: One-Hot Encoding +### Approach 2: One-Hot Encoding (Recommended) Each sample has a **binary vector** (1 = label present, 0 = absent): @@ -96,8 +96,7 @@ labels = [ - Can store probabilities (not just 0/1) **Cons:** -- Memory-intensive for many labels -- Sparse representation +- _Might_ require a bit more work on your end to have this format ## Complete Example: Ragged Lists @@ -247,14 +246,12 @@ predictions = result["prediction"] ### 1. Choose Your Approach **Use Ragged Lists if:** -- You have variable numbers of labels per sample -- Memory is a concern -- Data is naturally in list format +- Data is naturally in list format... +- ... and it wouldbe too costly to one-hot encode them **Use One-Hot if:** -- You need fixed-size arrays +- You want more efficiency - You want to store probabilities -- You're integrating with systems expecting one-hot ### 2. Prepare Labels @@ -263,9 +260,6 @@ predictions = result["prediction"] ```python # List of lists (variable length) labels = [[0, 1], [1, 2, 3], [0]] - -# Convert to numpy array with dtype=object -y = np.array(labels, dtype=object) ``` #### One-Hot Encoding @@ -288,7 +282,7 @@ one_hot_labels = mlb.fit_transform(ragged_labels) ### 3. Configure Loss Function -**Always use `BCEWithLogitsLoss` for multilabel:** +**We recommend to use `BCEWithLogitsLoss` for multilabel:** ```python import torch @@ -640,3 +634,4 @@ Ready to combine everything? Try adding categorical features to multilabel class - **Mixed features**: Combine multilabel with categorical features - **Explainability**: Understand which words trigger which labels - **API Reference**: See {doc}`../api/index` for detailed documentation + From d2ae78fee27a169ca85af96118d63dc02b6a74a6 Mon Sep 17 00:00:00 2001 From: micedre Date: Tue, 25 Nov 2025 20:56:48 +0000 Subject: [PATCH 5/5] docs: Replace mermaid with png fpr architecture diagram --- .../diagrams/ttc_architecture.png | Bin 0 -> 69318 bytes docs/source/architecture/overview.md | 29 +++++++----------- docs/source/conf.py | 5 ++- pyproject.toml | 2 +- uv.lock | 25 ++++++++------- 5 files changed, 28 insertions(+), 33 deletions(-) create mode 100644 docs/source/architecture/diagrams/ttc_architecture.png diff --git a/docs/source/architecture/diagrams/ttc_architecture.png b/docs/source/architecture/diagrams/ttc_architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..ff4da8519a8da7d5d6191a4098237cd7dcfc9ff3 GIT binary patch literal 69318 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 DkiZ6% literal 0 HcmV?d00001 diff --git a/docs/source/architecture/overview.md b/docs/source/architecture/overview.md index cb72370..ae1538b 100644 --- a/docs/source/architecture/overview.md +++ b/docs/source/architecture/overview.md @@ -6,20 +6,8 @@ torchTextClassifiers is a **modular, component-based framework** for text classi At its core, torchTextClassifiers processes data through a simple pipeline: -```{mermaid} -flowchart LR - TextInput["Text Input"] --> Tokenizer - Tokenizer --> TextEmbedder["Text Embedder"] - - CatInput["Categorical Features
(Optional)"] --> CatEmbedder["Categorical Embedder"] - - TextEmbedder --> ClassHead["Classification Head"] - CatEmbedder --> ClassHead - - ClassHead --> Predictions - - style CatInput stroke-dasharray: 5 5 - style CatEmbedder stroke-dasharray: 5 5 +```{thumbnail} diagrams/ttc_architecture.png +:alt: Package Architecture ``` **Data Flow:** @@ -195,7 +183,9 @@ The `forward_type` controls how categorical embeddings are combined: Average all categorical embeddings, then concatenate with text: -![Average and Concatenate](diagrams/avg_concat.png) +```{thumbnail} diagrams/avg_concat.png +:alt: Average and Concatenate +``` ```python forward_type=CategoricalForwardType.AVERAGE_AND_CONCAT @@ -209,7 +199,9 @@ forward_type=CategoricalForwardType.AVERAGE_AND_CONCAT Concatenate each categorical embedding separately: -![Full Concatenation](diagrams/full_concat.png) +```{thumbnail} diagrams/full_concat.png +:alt: Full Concatenation +``` ```python forward_type=CategoricalForwardType.CONCATENATE_ALL @@ -283,8 +275,9 @@ head = ClassificationHead(linear=custom_head) ## Complete Architecture -![Complete Architecture](diagrams/NN.drawio.png) -*Complete model architecture showing all components* +```{thumbnail} diagrams/NN.drawio.png +:alt: +``` ### Full Model Assembly diff --git a/docs/source/conf.py b/docs/source/conf.py index 753979f..569cb02 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -30,9 +30,11 @@ 'myst_parser', # Parse Markdown files 'sphinx_design', # Modern UI components (cards, grids, etc.) 'nbsphinx', # Render Jupyter notebooks - 'sphinxcontrib.mermaid', # Render Mermaid diagrams + 'sphinxcontrib.images' # Allow zooming on images ] + + templates_path = ['_templates'] exclude_patterns = [] @@ -42,6 +44,7 @@ '.md': 'markdown', } + # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output diff --git a/pyproject.toml b/pyproject.toml index c64127c..603e41e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ docs = [ "ipython>=8.0.0", "pandoc>=2.0.0", "linkify-it-py>=2.0.0", - "sphinxcontrib-mermaid>=0.9.0", + "sphinxcontrib-images>=1.0.1" ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 39ef300..47568ac 100644 --- a/uv.lock +++ b/uv.lock @@ -2582,25 +2582,24 @@ wheels = [ ] [[package]] -name = "sphinxcontrib-jsmath" +name = "sphinxcontrib-images" version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +dependencies = [ + { name = "requests" }, + { name = "sphinx" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/08/c5/402cce1cc18caff306effa09e77fdb3872289edc956a5c1bf53c03799b3b/sphinxcontrib_images-1.0.1-py3-none-any.whl", hash = "sha256:3cc9738dc15bacb3ab153b411a1b50dbfaa2535b49853ef3eae4d22adbbffa26", size = 119672, upload-time = "2025-06-07T22:42:48.954Z" }, ] [[package]] -name = "sphinxcontrib-mermaid" -version = "1.2.2" +name = "sphinxcontrib-jsmath" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/83/11fe1f2968c05fae725e473e8b3be08cbe5f51b83ddaf3309ab4c841082a/sphinxcontrib_mermaid-1.2.2.tar.gz", hash = "sha256:35423c13e565abb839b13f955f9722f0769e77e5d607ca07877ce93e1636c196", size = 18851, upload-time = "2025-11-24T01:05:48.702Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/74/f24437b92c3a34eadf93987d8472def6532200621df223e1b818c4318d63/sphinxcontrib_mermaid-1.2.2-py3-none-any.whl", hash = "sha256:51655f592300fc70e73b3ef2007cfc44fac11da5ff1f15c4725c83bf4a5b517c", size = 13416, upload-time = "2025-11-24T01:05:47.252Z" }, + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] [[package]] @@ -2804,7 +2803,7 @@ docs = [ { name = "sphinx-autodoc-typehints" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, - { name = "sphinxcontrib-mermaid" }, + { name = "sphinxcontrib-images" }, ] [package.metadata] @@ -2846,7 +2845,7 @@ docs = [ { name = "sphinx-autodoc-typehints", specifier = ">=2.0.0" }, { name = "sphinx-copybutton", specifier = ">=0.5.0" }, { name = "sphinx-design", specifier = ">=0.6.0" }, - { name = "sphinxcontrib-mermaid", specifier = ">=0.9.0" }, + { name = "sphinxcontrib-images", specifier = ">=1.0.1" }, ] [[package]]