torchsystem 1.2.0__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,414 @@
1
+ Metadata-Version: 2.3
2
+ Name: torchsystem
3
+ Version: 1.2.2
4
+ Summary: A framework for creating message-driven IA systems with PyTorch
5
+ License: MIT
6
+ Author: mr-mapache
7
+ Author-email: eric.m.cardozo@mi.unc.edu.ar
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Dist: mlregistry (>=1.2.1,<2.0.0)
14
+ Description-Content-Type: text/markdown
15
+
16
+ # TorchSystem.
17
+
18
+ This framework will help you to create powerful and scalable systems using the PyTorch library. It is designed under the principles of domain driven design (DDD) and includes built-in message patterns and a robust dependency injection system. It enables the creation of stateless, modular service layers and robust domain models. This design facilitates better separation of concerns, testability, and scalability, making it ideal for complex IA training systems. You can find the full documentation here: [mr-mapache.github.io/torch-system/](https://mr-mapache.github.io/torch-system/)
19
+
20
+ ## Table of contents:
21
+
22
+ - [Introduction](#introduction)
23
+ - [Features](#features)
24
+ - [Instalation](#instalation)
25
+ - [Example](#example)
26
+ - [License](#license)
27
+
28
+ ## Introduction
29
+
30
+ In domain-driven design, an aggregate is a cluster of associated objects that we treat as a unit for the purpose of data changes. It acts as a boundary around its constituent objects, encapsulating their behavior and ensuring that all changes to its state occur through well-defined entry points.
31
+
32
+ In the context of deep learning, a model not only consists of a neural network but also a set of associated objects that are necessary for the tasks it performs, such as loss functions, tokenizers, etc. This defines an aggregate.
33
+
34
+ While aggregates are in charge of data, in order to perform actions, we need to define services. Services are stateless operations that fulfill domain-specific tasks. For example, when training a neural network, the model doesn't own the data on which it is trained or how the training is performed. The training process is a stateless operation that resides outside the model and should be defined as a service.
35
+
36
+ Services may produce data, such as events, metrics, or logs, that are not their responsibility to handle. This introduces the need for a messaging system that allows services to communicate with each other.
37
+
38
+ With all this in mind, the need for a well-defined framework that defines aggregates and handles service interactions becomes evident. While it is up to the developer to define his domain, this framework provides a set of tools to facilitate it's implementation.
39
+
40
+ ## Instalation
41
+
42
+ To install the framework, you can use pip:
43
+
44
+ ```bash
45
+ pip install torchsystem
46
+ ```
47
+
48
+ ## Features
49
+
50
+ - **Aggregates**: Define the structure of your domain by grouping related entities and enforcing consistency within their boundaries. They encapsulate both data and behavior, ensuring that all modifications occur through controlled operations.
51
+
52
+ - **Domain Events**: Aggregates can produce and consume domain events, which signal meaningful changes in the system or trigger actions elsewhere. Exceptions are supported to be treated as domain events, allowing them to be enqueued and handled or rised as needed. This makes it trivial to implement features like early stopping (Just enqueue an exception and raise it when needed).
53
+
54
+ - **Dependency Injection**: The framework provides a robust dependency injection system that allows you to define and inject dependencies. This enables you to define your logic in terms of interfaces and inject implementations later.
55
+
56
+ - **Registry**: The registry module allows you to treat your models as entities by providing a way to calculate locally unique hashes for them that can act as their identifier. This module also provides several other utilities to help you handle the data from your domain.
57
+
58
+ - **Compilers**: Building aggregates can be a complex process. In the context of deep learning, aggregates not only need to be built but also compiled, making compilation an integral part of the construction process. This framework provides a Compiler class to help define and manage the compilation process for your aggregates
59
+
60
+ - **Services**: Define stateless operations that fulfill domain-specific tasks using ubiquitous language.
61
+
62
+ - **Producers/Consumers**: Events produced by services can be delivered by producers to several consumers. This allows you to decouple services and define complex interactions between them.
63
+
64
+ - **Publisher/Subscriber**: Data also can be delivered with the publisher/subscriber pattern. Publishers can send data to subscribers using a topic-based system.
65
+
66
+
67
+ ## Example
68
+
69
+ Let's build a simple training system using the framework. First, we can define our domain with interfaces:
70
+
71
+ ```python
72
+ # src/domain.py
73
+ from typing import Any
74
+ from typing import Protocol
75
+
76
+ from torch import Tensor
77
+ from torch.nn import Module
78
+ from torchsystem import Events
79
+
80
+ class Model(Protocol):
81
+ id: Any
82
+ phase: str
83
+ epoch: int
84
+ events: Events # You will find out why we need this later
85
+ nn: Module
86
+ criterion: Module
87
+ optimizer: Module
88
+
89
+ def fit(self, *args, **kwargs) -> Any:...
90
+
91
+ def evaluate(self, *args, **kwargs) -> Any:...
92
+
93
+ class Metric(Protocol):
94
+ name: str
95
+ value: Any
96
+
97
+ class Metrics(Protocol):
98
+
99
+ def update(self, *args, **kwargs) -> None:...
100
+
101
+ def compute(self) -> Sequence[Metric]:...
102
+
103
+ class Loader(Protocol):
104
+
105
+ def __iter__(self) -> Iterator[tuple[Tensor, Tensor]]:...
106
+ ```
107
+
108
+ Notice that we didn't define any implementation. Of course you can just implement it right away, but let's define a training service first. Service handlers and events produced in the services should be modeled using an ubiquitous language. Under this idea, the framework provides a way to invoke services or produce events using their names. This is completly optional, like everything in the library, and you can invoke services handlers and dispatch events directly.
109
+
110
+ ```python
111
+ # src/services/training.py
112
+ from typing import Sequence
113
+ from dataclasses import dataclass
114
+
115
+ from torch import inference_mode
116
+ from torchsystem import Depends
117
+ from torchsystem.services import Service
118
+ from torchsystem.services import Producer
119
+
120
+ from src.domain import Model
121
+ from src.domain import Loader
122
+ from src.domain import Metric, Metrics
123
+
124
+ service = Service()
125
+ producer = Producer()
126
+
127
+ @service.handler
128
+ def iterate(model: Model, loaders: Sequence[tuple[str, Loader]], metrics: Metrics):
129
+ for phase, loader in loaders:
130
+ train(model, loader, metrics) if phase == 'train' else validate(model, loader, metrics)
131
+ service.handle('train', model, loader, metrics) if phase == 'train' else service.handle('validate', model, loader, metrics)
132
+ model.epoch += 1
133
+ producer.produce('iterated', model, loaders)
134
+
135
+ def device() -> str:
136
+ raise NotImplementedError("We don't know the device yet!!!, will be injected later")
137
+
138
+ @service.handler
139
+ def train(model: Model, loader: Loader, metrics: Metrics, device: str = Depends(device)):
140
+ model.phase = 'train'
141
+ for batch, (input, target) in enumerate(loader, start=1):
142
+ input, target = input.to(device), target.to(device)
143
+ prediction, loss = model.fit(input, target)
144
+ metrics.update(batch, loss, prediction, target)
145
+ sequence = metrics.compute()
146
+ producer.produce('trained', model, loader, sequence)
147
+
148
+ @service.handler
149
+ def validate(model: Model, loader: Loader, metrics: Metrics, device: str = Depends(device)):
150
+ model.phase = 'evaluation'
151
+ with inference_mode():
152
+ for batch, (input, target) in enumerate(loader, start=1):
153
+ input, target = input.to(device), target.to(device)
154
+ prediction, loss = model.evaluate(input, target)
155
+ metrics.update(batch, loss, prediction, target)
156
+ sequence = metrics.compute()
157
+ producer.produce('validated', model, loader, sequence)
158
+
159
+ @producer.event
160
+ class Iterated:
161
+ model: Model
162
+ loaders: Sequence[tuple[str, Loader]]
163
+
164
+ @producer.event
165
+ class Trained:
166
+ model: Model
167
+ loader: Loader
168
+ metrics: Sequence[Metric]
169
+
170
+ @producer.event
171
+ class Validated:
172
+ model: Model
173
+ loader: Loader
174
+ metrics: Sequence[Metric]
175
+ ```
176
+
177
+ And that's it! A simple training system. Notice that it is completely decoupled from the implementation of the domain. It's only task is to orchestrate the training process and produce events from it. It doesn't provide any storage logic or data manipulation, only stateless training logic. Now you can now build a whole data storage system, logging or any other service you need around this simple service, thanks to the event system.
178
+
179
+ Let's create a simple tensorboard consumer for this service:
180
+
181
+ ```python
182
+ # src/services/tensorboard.py
183
+ from torchsystem import Depends
184
+ from torchsystem.services import Consumer
185
+ from torch.utils.tensorboard.writer import SummaryWriter
186
+ from trainsys.services.training import (
187
+ Trained,
188
+ Validated
189
+ )
190
+
191
+ consumer = Consumer()
192
+
193
+ def writer() -> SummaryWriter:
194
+ raise NotImplementedError("We don't want to handle the writer here!!!, will be injected later in the application layer")
195
+
196
+
197
+ @consumer.handler
198
+ def deliver_metrics(event: Trained | Validated, writer: SummaryWriter = Depends(writer)):
199
+ for metric in event.metrics:
200
+ writer.add_scalar(f'{event.model.id}/{metric.name}/{event.model.phase}', metric.value, event.model.epoch)
201
+
202
+ # When you pass an Union of types as annotation, the consumer will register the handler for all types in the Union
203
+ # automatically. This is a good way to handle events that share the same logic.
204
+ ```
205
+
206
+ Since several consumers can consume from the same producer, you can plug any service you want to the training system. The service don't need to know who is consuming the events it produces. This is known a dependency inversion principle. You now build a whole system around this simple training service. All kind of logic can be implemented from here, from weights storage to early stopping. Let's create an early stopping service:
207
+
208
+ ```python
209
+ # src/services/earlystopping.py
210
+ from torchsystem.services import Subscriber
211
+ from torchsystem.services import Consumer
212
+
213
+ from trainsys.services.training import Metric
214
+ from trainsys.services.training import Trained, Validated
215
+
216
+ subscriber = Subscriber()
217
+ consumer = Consumer()
218
+
219
+ @consumer.handler
220
+ def deliver_metrics(event: Trained | Validated):
221
+ for metric in event.metrics:
222
+ try:
223
+ subscriber.receive(metric, metric.name)
224
+ except StopIteration:
225
+ event.model.events.enqueue(StopIteration) # Exceptions are also supported as domain events
226
+ # If you prefer you can create a domain event for this
227
+ # and enqueue it here.
228
+ @subscriber.subscribe('loss')
229
+ def on_low_loss(metric: Metric):
230
+ if metric.value < 0.001:
231
+ raise StopIteration # Built-in exception from Python
232
+
233
+ @subscriber.subscribe('accuracy')
234
+ def on_high_accuracy(metric: Metric):
235
+ if metric.value > 0.995:
236
+ raise StopIteration # Exceptions are a good way to propagate information backwards in the system.
237
+ ```
238
+
239
+ This is a simple early stopping service. It listens to the metrics produced by the training service and raises a StopIteration exception when the loss is low enough or the accuracy is high enough. This exception is enqueued in the model events and can be raised again when needed, for example within the `onepoch` hook in the aggregate. A `Publisher` could also be used to send the messages to the subscribers but it wasn't necessary in this case.
240
+
241
+ Now we are going to implement a simple classifier aggregate in order to train a neural network for image classification tasks.
242
+
243
+ ```python
244
+ from torch import argmax
245
+ from torch import Tensor
246
+ from torch.nn import Module
247
+ from torch.nn import Flatten
248
+ from torch.optim import Optimizer
249
+ from torchsystem.domain import Event
250
+ from torchsystem.domain import Aggregate
251
+ from torchsystem.registry import gethash
252
+
253
+ class Classifier(Aggregate):
254
+ def __init__(self, nn: Module, criterion: Module, optimizer: Optimizer):
255
+ super().__init__()
256
+ self.epoch = 0
257
+ self.nn = nn
258
+ self.criterion = criterion
259
+ self.optimizer = optimizer
260
+ self.flatten = Flatten()
261
+
262
+ @property
263
+ def id(self) -> str:
264
+ return gethash(self.nn) # This will return an identifier for the root
265
+
266
+ def forward(self, input: Tensor) -> tuple[Tensor, Tensor]:
267
+ input = self.flatten(input)
268
+ return self.nn(input)
269
+
270
+ def loss(self, output: Tensor, target: Tensor) -> Tensor:
271
+ return self.criterion(output, target)
272
+
273
+ def fit(self, input: Tensor, target: Tensor) -> tuple[Tensor, Tensor]:
274
+ self.optimizer.zero_grad()
275
+ output = self(input)
276
+ loss = self.loss(output, target)
277
+ loss.backward()
278
+ self.optimizer.step()
279
+ return argmax(output, dim=1), loss # returns classification predictions and their loss
280
+
281
+ def evaluate(self, input: Tensor, target: Tensor) -> tuple[Tensor, Tensor]:
282
+ output = self(input)
283
+ return argmax(output, dim=1), self.loss(output, target)
284
+
285
+ def onepoch(self):
286
+ # Hook that will be called after the epoch attribute is updated. The aggregate handle this
287
+ # automatically for epochs and phase.
288
+ self.events.commit() # This will raise the StopIteration exception when the epoch is over
289
+ ```
290
+
291
+ As you see, aggregates is just a simple facade to encapsulate things you already knew. Aggregates have also the capability to handle domain events or domain exceptions.
292
+
293
+ ```python
294
+ class SomeDomainEvent(Event):...
295
+ # A simple class can represent a domain event.
296
+
297
+ class DomainException(Exception):...
298
+ # Exceptions are also supported as domain events.
299
+ # If no handler is found, they will be raised when the event queue is commited.
300
+
301
+ class DomainEventWithData(Event):
302
+ # They also can carry data
303
+ def __init__(self, data: Any):
304
+ self.data = data
305
+
306
+ class DomainEventWithIgnoredData(Event):
307
+ def __init__(self, data: Any):
308
+ self.data = data
309
+
310
+ class Classifier(Aggregate):
311
+ def __init__(self, nn: Module, criterion: Module, optimizer: Optimizer):
312
+ super().__init__()
313
+ self.events.handlers[SomeDomainEvent] = lambda: print('SomeDomainEvent handled!')
314
+ self.events.handlers[DomainException] = lambda: print('DomainException handled!')
315
+ self.events.handlers[DomainEventWithData] = lambda event: print(f'DomainEventWithData handled with data: {event.data}')
316
+ self.events.handlers[DomainEventWithIgnoredData] = lambda: print(f'DomainEventWithIgnoredData handled')
317
+ # Usually you need to define the handlers outside the aggregate and pass them in the building process. But
318
+ # This is an example of how you can handle complex domain logic within the aggregate.
319
+ ...
320
+ ```
321
+
322
+ The `Classifier` aggregate we just created can be built and compiled in a simple way. However you will find yourself in situations where you need to pick a torch backend, create and clean multiprocessing resources, pickle modules, etc., and you will need a tool to build it an compile it. I will implement a compilation pipeline, just to show you how to use the compiler:
323
+
324
+ ```python
325
+ # src/services/compilation.py
326
+ from logging import getLogger
327
+ from torch.nn import Module
328
+ from torch.optim import Optimizer
329
+ from torchsystem import Depends
330
+ from torchsystem.compiler import compile
331
+ from torchsystem.compiler import Compiler
332
+ from trainsys.domain.models import Repository
333
+ from trainsys.ports.models import Models
334
+ from src.classifier import Classifier
335
+
336
+ logger = getLogger(__name__)
337
+ compiler = Compiler[Classifier]()
338
+
339
+ def device() -> str:...
340
+
341
+ def epoch() -> int:
342
+ return 10 # Just for the sake of the example
343
+
344
+ @compiler.step
345
+ def build_classifier(model: Module, criterion: Module, optimizer: Optimizer):
346
+ logger.info(f'Building Classifier')
347
+ return Classifier(model, criterion, optimizer)
348
+
349
+ @compiler.step
350
+ def move_to_device(classifier: Classifier, device = Depends(device)):
351
+ logger.info(f'Moving classifier to device: {device}')
352
+ return classifier.to(device)
353
+
354
+ @compiler.step
355
+ def compile_classifier(classifier: Classifier):
356
+ logger.info(f'Compiling classifier')
357
+ return compile(classifier)
358
+
359
+ @compiler.step
360
+ def get_current_epoch(classifier: Classifier, epoch: int = Depends(epoch)):
361
+ # Implement this with some database query or api call.
362
+ classifier.epoch = epoch
363
+ return classifier
364
+ ```
365
+
366
+ Finally, you can put all together in the application layer as follows:
367
+
368
+ ```python
369
+ # src/main.py
370
+
371
+ from torch import cuda
372
+ from torch.utils.tensorboard.writer import SummaryWriter
373
+
374
+ from src.service import (
375
+ training,
376
+ tensorboard,
377
+ earlystopping
378
+ )
379
+
380
+ model = MLP(...)
381
+ criterion = CrossEntropyLoss(...)
382
+ optimizer = Adam(...)
383
+ metrics = Metrics(...)
384
+ loaders = [
385
+ ('train', DataLoader(...)),
386
+ ('validation', DataLoader(...))
387
+ ]
388
+
389
+ def device():
390
+ return 'cuda' if cuda.is_available() else 'cpu'
391
+
392
+ def summary_writer():
393
+ writter = SummaryWriter(...)
394
+ yield writter
395
+ writter.close()
396
+
397
+ training.service.dependency_overrides[training.device] = device
398
+ compilation.compiler.dependency_overrides[compilation.device] = device
399
+ tensorboard.consumer.dependency_overrides[tensorboard.writer] = summary_writer
400
+
401
+ ...
402
+
403
+ classifier = compilation.compiler.compile(model, criterion, optimizer)
404
+
405
+ training.service.handle('iterate', classifier, loaders, metrics)
406
+
407
+ ...
408
+ ```
409
+
410
+ This is a simple example of how to build a training system using the framework. Since services can be called by their name, you can easily write a REST API with CQS or a CLI interfaces for your training system. The possibilities are endless.
411
+
412
+ ## License
413
+
414
+ This project is licensed under the MIT License - Use it as you wish in your projects.
@@ -0,0 +1,399 @@
1
+ # TorchSystem.
2
+
3
+ This framework will help you to create powerful and scalable systems using the PyTorch library. It is designed under the principles of domain driven design (DDD) and includes built-in message patterns and a robust dependency injection system. It enables the creation of stateless, modular service layers and robust domain models. This design facilitates better separation of concerns, testability, and scalability, making it ideal for complex IA training systems. You can find the full documentation here: [mr-mapache.github.io/torch-system/](https://mr-mapache.github.io/torch-system/)
4
+
5
+ ## Table of contents:
6
+
7
+ - [Introduction](#introduction)
8
+ - [Features](#features)
9
+ - [Instalation](#instalation)
10
+ - [Example](#example)
11
+ - [License](#license)
12
+
13
+ ## Introduction
14
+
15
+ In domain-driven design, an aggregate is a cluster of associated objects that we treat as a unit for the purpose of data changes. It acts as a boundary around its constituent objects, encapsulating their behavior and ensuring that all changes to its state occur through well-defined entry points.
16
+
17
+ In the context of deep learning, a model not only consists of a neural network but also a set of associated objects that are necessary for the tasks it performs, such as loss functions, tokenizers, etc. This defines an aggregate.
18
+
19
+ While aggregates are in charge of data, in order to perform actions, we need to define services. Services are stateless operations that fulfill domain-specific tasks. For example, when training a neural network, the model doesn't own the data on which it is trained or how the training is performed. The training process is a stateless operation that resides outside the model and should be defined as a service.
20
+
21
+ Services may produce data, such as events, metrics, or logs, that are not their responsibility to handle. This introduces the need for a messaging system that allows services to communicate with each other.
22
+
23
+ With all this in mind, the need for a well-defined framework that defines aggregates and handles service interactions becomes evident. While it is up to the developer to define his domain, this framework provides a set of tools to facilitate it's implementation.
24
+
25
+ ## Instalation
26
+
27
+ To install the framework, you can use pip:
28
+
29
+ ```bash
30
+ pip install torchsystem
31
+ ```
32
+
33
+ ## Features
34
+
35
+ - **Aggregates**: Define the structure of your domain by grouping related entities and enforcing consistency within their boundaries. They encapsulate both data and behavior, ensuring that all modifications occur through controlled operations.
36
+
37
+ - **Domain Events**: Aggregates can produce and consume domain events, which signal meaningful changes in the system or trigger actions elsewhere. Exceptions are supported to be treated as domain events, allowing them to be enqueued and handled or rised as needed. This makes it trivial to implement features like early stopping (Just enqueue an exception and raise it when needed).
38
+
39
+ - **Dependency Injection**: The framework provides a robust dependency injection system that allows you to define and inject dependencies. This enables you to define your logic in terms of interfaces and inject implementations later.
40
+
41
+ - **Registry**: The registry module allows you to treat your models as entities by providing a way to calculate locally unique hashes for them that can act as their identifier. This module also provides several other utilities to help you handle the data from your domain.
42
+
43
+ - **Compilers**: Building aggregates can be a complex process. In the context of deep learning, aggregates not only need to be built but also compiled, making compilation an integral part of the construction process. This framework provides a Compiler class to help define and manage the compilation process for your aggregates
44
+
45
+ - **Services**: Define stateless operations that fulfill domain-specific tasks using ubiquitous language.
46
+
47
+ - **Producers/Consumers**: Events produced by services can be delivered by producers to several consumers. This allows you to decouple services and define complex interactions between them.
48
+
49
+ - **Publisher/Subscriber**: Data also can be delivered with the publisher/subscriber pattern. Publishers can send data to subscribers using a topic-based system.
50
+
51
+
52
+ ## Example
53
+
54
+ Let's build a simple training system using the framework. First, we can define our domain with interfaces:
55
+
56
+ ```python
57
+ # src/domain.py
58
+ from typing import Any
59
+ from typing import Protocol
60
+
61
+ from torch import Tensor
62
+ from torch.nn import Module
63
+ from torchsystem import Events
64
+
65
+ class Model(Protocol):
66
+ id: Any
67
+ phase: str
68
+ epoch: int
69
+ events: Events # You will find out why we need this later
70
+ nn: Module
71
+ criterion: Module
72
+ optimizer: Module
73
+
74
+ def fit(self, *args, **kwargs) -> Any:...
75
+
76
+ def evaluate(self, *args, **kwargs) -> Any:...
77
+
78
+ class Metric(Protocol):
79
+ name: str
80
+ value: Any
81
+
82
+ class Metrics(Protocol):
83
+
84
+ def update(self, *args, **kwargs) -> None:...
85
+
86
+ def compute(self) -> Sequence[Metric]:...
87
+
88
+ class Loader(Protocol):
89
+
90
+ def __iter__(self) -> Iterator[tuple[Tensor, Tensor]]:...
91
+ ```
92
+
93
+ Notice that we didn't define any implementation. Of course you can just implement it right away, but let's define a training service first. Service handlers and events produced in the services should be modeled using an ubiquitous language. Under this idea, the framework provides a way to invoke services or produce events using their names. This is completly optional, like everything in the library, and you can invoke services handlers and dispatch events directly.
94
+
95
+ ```python
96
+ # src/services/training.py
97
+ from typing import Sequence
98
+ from dataclasses import dataclass
99
+
100
+ from torch import inference_mode
101
+ from torchsystem import Depends
102
+ from torchsystem.services import Service
103
+ from torchsystem.services import Producer
104
+
105
+ from src.domain import Model
106
+ from src.domain import Loader
107
+ from src.domain import Metric, Metrics
108
+
109
+ service = Service()
110
+ producer = Producer()
111
+
112
+ @service.handler
113
+ def iterate(model: Model, loaders: Sequence[tuple[str, Loader]], metrics: Metrics):
114
+ for phase, loader in loaders:
115
+ train(model, loader, metrics) if phase == 'train' else validate(model, loader, metrics)
116
+ service.handle('train', model, loader, metrics) if phase == 'train' else service.handle('validate', model, loader, metrics)
117
+ model.epoch += 1
118
+ producer.produce('iterated', model, loaders)
119
+
120
+ def device() -> str:
121
+ raise NotImplementedError("We don't know the device yet!!!, will be injected later")
122
+
123
+ @service.handler
124
+ def train(model: Model, loader: Loader, metrics: Metrics, device: str = Depends(device)):
125
+ model.phase = 'train'
126
+ for batch, (input, target) in enumerate(loader, start=1):
127
+ input, target = input.to(device), target.to(device)
128
+ prediction, loss = model.fit(input, target)
129
+ metrics.update(batch, loss, prediction, target)
130
+ sequence = metrics.compute()
131
+ producer.produce('trained', model, loader, sequence)
132
+
133
+ @service.handler
134
+ def validate(model: Model, loader: Loader, metrics: Metrics, device: str = Depends(device)):
135
+ model.phase = 'evaluation'
136
+ with inference_mode():
137
+ for batch, (input, target) in enumerate(loader, start=1):
138
+ input, target = input.to(device), target.to(device)
139
+ prediction, loss = model.evaluate(input, target)
140
+ metrics.update(batch, loss, prediction, target)
141
+ sequence = metrics.compute()
142
+ producer.produce('validated', model, loader, sequence)
143
+
144
+ @producer.event
145
+ class Iterated:
146
+ model: Model
147
+ loaders: Sequence[tuple[str, Loader]]
148
+
149
+ @producer.event
150
+ class Trained:
151
+ model: Model
152
+ loader: Loader
153
+ metrics: Sequence[Metric]
154
+
155
+ @producer.event
156
+ class Validated:
157
+ model: Model
158
+ loader: Loader
159
+ metrics: Sequence[Metric]
160
+ ```
161
+
162
+ And that's it! A simple training system. Notice that it is completely decoupled from the implementation of the domain. It's only task is to orchestrate the training process and produce events from it. It doesn't provide any storage logic or data manipulation, only stateless training logic. Now you can now build a whole data storage system, logging or any other service you need around this simple service, thanks to the event system.
163
+
164
+ Let's create a simple tensorboard consumer for this service:
165
+
166
+ ```python
167
+ # src/services/tensorboard.py
168
+ from torchsystem import Depends
169
+ from torchsystem.services import Consumer
170
+ from torch.utils.tensorboard.writer import SummaryWriter
171
+ from trainsys.services.training import (
172
+ Trained,
173
+ Validated
174
+ )
175
+
176
+ consumer = Consumer()
177
+
178
+ def writer() -> SummaryWriter:
179
+ raise NotImplementedError("We don't want to handle the writer here!!!, will be injected later in the application layer")
180
+
181
+
182
+ @consumer.handler
183
+ def deliver_metrics(event: Trained | Validated, writer: SummaryWriter = Depends(writer)):
184
+ for metric in event.metrics:
185
+ writer.add_scalar(f'{event.model.id}/{metric.name}/{event.model.phase}', metric.value, event.model.epoch)
186
+
187
+ # When you pass an Union of types as annotation, the consumer will register the handler for all types in the Union
188
+ # automatically. This is a good way to handle events that share the same logic.
189
+ ```
190
+
191
+ Since several consumers can consume from the same producer, you can plug any service you want to the training system. The service don't need to know who is consuming the events it produces. This is known a dependency inversion principle. You now build a whole system around this simple training service. All kind of logic can be implemented from here, from weights storage to early stopping. Let's create an early stopping service:
192
+
193
+ ```python
194
+ # src/services/earlystopping.py
195
+ from torchsystem.services import Subscriber
196
+ from torchsystem.services import Consumer
197
+
198
+ from trainsys.services.training import Metric
199
+ from trainsys.services.training import Trained, Validated
200
+
201
+ subscriber = Subscriber()
202
+ consumer = Consumer()
203
+
204
+ @consumer.handler
205
+ def deliver_metrics(event: Trained | Validated):
206
+ for metric in event.metrics:
207
+ try:
208
+ subscriber.receive(metric, metric.name)
209
+ except StopIteration:
210
+ event.model.events.enqueue(StopIteration) # Exceptions are also supported as domain events
211
+ # If you prefer you can create a domain event for this
212
+ # and enqueue it here.
213
+ @subscriber.subscribe('loss')
214
+ def on_low_loss(metric: Metric):
215
+ if metric.value < 0.001:
216
+ raise StopIteration # Built-in exception from Python
217
+
218
+ @subscriber.subscribe('accuracy')
219
+ def on_high_accuracy(metric: Metric):
220
+ if metric.value > 0.995:
221
+ raise StopIteration # Exceptions are a good way to propagate information backwards in the system.
222
+ ```
223
+
224
+ This is a simple early stopping service. It listens to the metrics produced by the training service and raises a StopIteration exception when the loss is low enough or the accuracy is high enough. This exception is enqueued in the model events and can be raised again when needed, for example within the `onepoch` hook in the aggregate. A `Publisher` could also be used to send the messages to the subscribers but it wasn't necessary in this case.
225
+
226
+ Now we are going to implement a simple classifier aggregate in order to train a neural network for image classification tasks.
227
+
228
+ ```python
229
+ from torch import argmax
230
+ from torch import Tensor
231
+ from torch.nn import Module
232
+ from torch.nn import Flatten
233
+ from torch.optim import Optimizer
234
+ from torchsystem.domain import Event
235
+ from torchsystem.domain import Aggregate
236
+ from torchsystem.registry import gethash
237
+
238
+ class Classifier(Aggregate):
239
+ def __init__(self, nn: Module, criterion: Module, optimizer: Optimizer):
240
+ super().__init__()
241
+ self.epoch = 0
242
+ self.nn = nn
243
+ self.criterion = criterion
244
+ self.optimizer = optimizer
245
+ self.flatten = Flatten()
246
+
247
+ @property
248
+ def id(self) -> str:
249
+ return gethash(self.nn) # This will return an identifier for the root
250
+
251
+ def forward(self, input: Tensor) -> tuple[Tensor, Tensor]:
252
+ input = self.flatten(input)
253
+ return self.nn(input)
254
+
255
+ def loss(self, output: Tensor, target: Tensor) -> Tensor:
256
+ return self.criterion(output, target)
257
+
258
+ def fit(self, input: Tensor, target: Tensor) -> tuple[Tensor, Tensor]:
259
+ self.optimizer.zero_grad()
260
+ output = self(input)
261
+ loss = self.loss(output, target)
262
+ loss.backward()
263
+ self.optimizer.step()
264
+ return argmax(output, dim=1), loss # returns classification predictions and their loss
265
+
266
+ def evaluate(self, input: Tensor, target: Tensor) -> tuple[Tensor, Tensor]:
267
+ output = self(input)
268
+ return argmax(output, dim=1), self.loss(output, target)
269
+
270
+ def onepoch(self):
271
+ # Hook that will be called after the epoch attribute is updated. The aggregate handle this
272
+ # automatically for epochs and phase.
273
+ self.events.commit() # This will raise the StopIteration exception when the epoch is over
274
+ ```
275
+
276
+ As you see, aggregates is just a simple facade to encapsulate things you already knew. Aggregates have also the capability to handle domain events or domain exceptions.
277
+
278
+ ```python
279
+ class SomeDomainEvent(Event):...
280
+ # A simple class can represent a domain event.
281
+
282
+ class DomainException(Exception):...
283
+ # Exceptions are also supported as domain events.
284
+ # If no handler is found, they will be raised when the event queue is commited.
285
+
286
+ class DomainEventWithData(Event):
287
+ # They also can carry data
288
+ def __init__(self, data: Any):
289
+ self.data = data
290
+
291
+ class DomainEventWithIgnoredData(Event):
292
+ def __init__(self, data: Any):
293
+ self.data = data
294
+
295
+ class Classifier(Aggregate):
296
+ def __init__(self, nn: Module, criterion: Module, optimizer: Optimizer):
297
+ super().__init__()
298
+ self.events.handlers[SomeDomainEvent] = lambda: print('SomeDomainEvent handled!')
299
+ self.events.handlers[DomainException] = lambda: print('DomainException handled!')
300
+ self.events.handlers[DomainEventWithData] = lambda event: print(f'DomainEventWithData handled with data: {event.data}')
301
+ self.events.handlers[DomainEventWithIgnoredData] = lambda: print(f'DomainEventWithIgnoredData handled')
302
+ # Usually you need to define the handlers outside the aggregate and pass them in the building process. But
303
+ # This is an example of how you can handle complex domain logic within the aggregate.
304
+ ...
305
+ ```
306
+
307
+ The `Classifier` aggregate we just created can be built and compiled in a simple way. However you will find yourself in situations where you need to pick a torch backend, create and clean multiprocessing resources, pickle modules, etc., and you will need a tool to build it an compile it. I will implement a compilation pipeline, just to show you how to use the compiler:
308
+
309
+ ```python
310
+ # src/services/compilation.py
311
+ from logging import getLogger
312
+ from torch.nn import Module
313
+ from torch.optim import Optimizer
314
+ from torchsystem import Depends
315
+ from torchsystem.compiler import compile
316
+ from torchsystem.compiler import Compiler
317
+ from trainsys.domain.models import Repository
318
+ from trainsys.ports.models import Models
319
+ from src.classifier import Classifier
320
+
321
+ logger = getLogger(__name__)
322
+ compiler = Compiler[Classifier]()
323
+
324
+ def device() -> str:...
325
+
326
+ def epoch() -> int:
327
+ return 10 # Just for the sake of the example
328
+
329
+ @compiler.step
330
+ def build_classifier(model: Module, criterion: Module, optimizer: Optimizer):
331
+ logger.info(f'Building Classifier')
332
+ return Classifier(model, criterion, optimizer)
333
+
334
+ @compiler.step
335
+ def move_to_device(classifier: Classifier, device = Depends(device)):
336
+ logger.info(f'Moving classifier to device: {device}')
337
+ return classifier.to(device)
338
+
339
+ @compiler.step
340
+ def compile_classifier(classifier: Classifier):
341
+ logger.info(f'Compiling classifier')
342
+ return compile(classifier)
343
+
344
+ @compiler.step
345
+ def get_current_epoch(classifier: Classifier, epoch: int = Depends(epoch)):
346
+ # Implement this with some database query or api call.
347
+ classifier.epoch = epoch
348
+ return classifier
349
+ ```
350
+
351
+ Finally, you can put all together in the application layer as follows:
352
+
353
+ ```python
354
+ # src/main.py
355
+
356
+ from torch import cuda
357
+ from torch.utils.tensorboard.writer import SummaryWriter
358
+
359
+ from src.service import (
360
+ training,
361
+ tensorboard,
362
+ earlystopping
363
+ )
364
+
365
+ model = MLP(...)
366
+ criterion = CrossEntropyLoss(...)
367
+ optimizer = Adam(...)
368
+ metrics = Metrics(...)
369
+ loaders = [
370
+ ('train', DataLoader(...)),
371
+ ('validation', DataLoader(...))
372
+ ]
373
+
374
+ def device():
375
+ return 'cuda' if cuda.is_available() else 'cpu'
376
+
377
+ def summary_writer():
378
+ writter = SummaryWriter(...)
379
+ yield writter
380
+ writter.close()
381
+
382
+ training.service.dependency_overrides[training.device] = device
383
+ compilation.compiler.dependency_overrides[compilation.device] = device
384
+ tensorboard.consumer.dependency_overrides[tensorboard.writer] = summary_writer
385
+
386
+ ...
387
+
388
+ classifier = compilation.compiler.compile(model, criterion, optimizer)
389
+
390
+ training.service.handle('iterate', classifier, loaders, metrics)
391
+
392
+ ...
393
+ ```
394
+
395
+ This is a simple example of how to build a training system using the framework. Since services can be called by their name, you can easily write a REST API with CQS or a CLI interfaces for your training system. The possibilities are endless.
396
+
397
+ ## License
398
+
399
+ This project is licensed under the MIT License - Use it as you wish in your projects.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "torchsystem"
3
- version = "1.2.0"
3
+ version = "1.2.2"
4
4
  description = "A framework for creating message-driven IA systems with PyTorch"
5
5
  authors = ["mr-mapache <eric.m.cardozo@mi.unc.edu.ar>"]
6
6
  license = "MIT"
@@ -8,15 +8,14 @@ from torchsystem.depends import Provider
8
8
 
9
9
  class Compiler[T]:
10
10
  """
11
- AGGREGATES usually have a complex initialization and are built from multiple components. Sometimes
12
- database queries, API calls, or other external resources are required to build it. Each one with it's
13
- own lifecycle tath may require some initialization method or cleanup after they are no longer needed.
11
+ Sometimes AGGREGATES may require complex initialization and be built from multiple sources, like database
12
+ queries, API calls, or other external resources are required to build it. Each one with it's own lifecycle
13
+ that may require some initialization method or cleanup after they are no longer needed.
14
14
 
15
15
  In the context of neural networks, AGGREGATES not only should be built but also compiled. Compilation
16
16
  is the process of converting a high-level neural network model into a low-level representation that can
17
17
  be executed on a specific hardware platform, and can be seen as an integral part of the process of building
18
- the AGGREGATE. This process get even more complex when performing distributed training and
19
- a dedicated pipeline is required to build and compile AGGREGATES.
18
+ the AGGREGATE. This process get even more complex when performing distributed training.
20
19
 
21
20
  A `Compiler` is a class that compiles a pipeline of functions to be executed in sequence in order
22
21
  to build an a low-level representation of the AGGREGATE. Since some compilation steps sometimes
@@ -2,7 +2,9 @@ from re import sub
2
2
  from typing import Callable
3
3
  from typing import Any
4
4
  from typing import Union
5
+ from typing import overload
5
6
  from inspect import signature
7
+ from dataclasses import dataclass
6
8
 
7
9
  from torchsystem.depends import inject, Provider
8
10
  from torchsystem.depends import Depends as Depends
@@ -18,6 +20,10 @@ class Consumer:
18
20
  for deciding which handlers to invoke based on the type of the message it consumes. The message type should
19
21
  describe the ocurrence in past tense as DDD suggests when modeling events.
20
22
 
23
+ The EVENTS should be modeled with UBIQUITOUS LANGUAGE. This means that the names of the events should reflect
24
+ the domain ocurrences that the consumer is responsible for. Keep this in mind when naming the events that will
25
+ be consumed by the consumer. The consumer maps it's handlers to keys generated by the EVENT's name.
26
+
21
27
  Methods:
22
28
  register:
23
29
  Registers a message type and its corresponding handler function.
@@ -61,8 +67,8 @@ class Consumer:
61
67
  self,
62
68
  name: str = None,
63
69
  *,
64
- generator: Callable[[str], str] = lambda name: sub(r'(?<!^)(?=[A-Z])', '-', name).lower(),
65
- provider: Provider = None
70
+ provider: Provider = None,
71
+ generator: Callable[[str], str] = lambda name: sub(r'(?<!^)(?=[A-Z])', '-', name).lower()
66
72
  ):
67
73
  self.name = name
68
74
  self.handlers = dict[str, list[Callable[[Any], None]]]()
@@ -161,15 +167,13 @@ class Producer:
161
167
  from torchsystem.services import Consumer
162
168
  from torchsystem.services import Producer
163
169
 
164
- class Event:...
165
-
166
170
  @dataclass
167
- class ModelTrained(Event):
171
+ class ModelTrained:
168
172
  model: Callable
169
173
  metrics: Sequence
170
174
 
171
175
  @dataclass
172
- class ModelEvaluated(Event):
176
+ class ModelEvaluated:
173
177
  model: Callable
174
178
  metrics: Sequence
175
179
 
@@ -178,10 +182,25 @@ class Producer:
178
182
  producer = Producer()
179
183
  producer.register(consumer)
180
184
  producer.dispatch(ModelTrained(model, [{'name': 'loss', 'value': 0.1}, {'name': 'accuracy', 'value': 0.9}]))
185
+
186
+ # Events can also be produced and dispatched by the produce method.
187
+
188
+ @producer.event
189
+ class ModelIterated:
190
+ model: Callable
191
+ metrics: Sequence
192
+
193
+ producer.produce('model-iterated', model, [{'name': 'loss', 'value': 0.1}, {'name': 'accuracy', 'value': 0.9}])
181
194
  ```
182
195
  """
183
- def __init__(self):
196
+ def __init__(
197
+ self,
198
+ *,
199
+ generator: Callable[[str], str] = lambda name: sub(r'(?<!^)(?=[A-Z])', '-', name).lower()
200
+ ):
184
201
  self.consumers = list[Consumer]()
202
+ self.types = dict[str, str]()
203
+ self.generator = generator
185
204
 
186
205
  def register(self, *consumers: Consumer):
187
206
  """
@@ -200,4 +219,44 @@ class Producer:
200
219
  message (Any): The event to dispatch.
201
220
  """
202
221
  for consumer in self.consumers:
203
- consumer.consume(message)
222
+ consumer.consume(message)
223
+
224
+ def event(self, cls: type):
225
+ """
226
+ Decorator for registering an event type. The event type is registered with the name of the class as the key.
227
+
228
+ Args:
229
+ cls (type): The event class to be registered.
230
+
231
+ Returns:
232
+ type: The event class.
233
+ """
234
+ key = self.generator(cls.__name__)
235
+ self.types[key] = dataclass(cls)
236
+ return dataclass(cls)
237
+
238
+ @overload
239
+ def produce(self, event_type: str, *args, **kwargs):
240
+ ...
241
+
242
+ @overload
243
+ def produce(self, event_type: type, *args, **kwargs):
244
+ ...
245
+
246
+ def produce(self, event_type: str | type, *args, **kwargs):
247
+ """
248
+ Produces an event by instantiating the event class and dispatching it to all registered consumers.
249
+
250
+ Args:
251
+ event_type (str | type): The event type to produce.
252
+ *args: The event class arguments.
253
+ **kwargs: The event class keyword arguments.
254
+ """
255
+ if isinstance(event_type, str):
256
+ event_type = self.types.get(event_type, None)
257
+ if event_type is None:
258
+ raise ValueError(f'Event of type {event_type} is not registered.')
259
+ self.dispatch(event_type(*args, **kwargs))
260
+
261
+ elif isinstance(event_type, type):
262
+ self.dispatch(event_type(*args, **kwargs))
@@ -13,6 +13,17 @@ class Service:
13
13
  The `Service` serves as an entry point for the service layer and provides a simple way to
14
14
  build stateless logic for executing domain operations.
15
15
 
16
+ A SERVICE should be modeled with UBIQUITOUS LANGUAGE. This means that the names of handler
17
+ functions should reflect the domain operations that the service is responsible for. Keep
18
+ this in mind when naming the functions that will be registered as handlers, since the `Service`
19
+ class provides a method to call the handlers by their registered name. This is useful for example
20
+ when building REST APIs with Command Query Segregation (CQS) and you want to invoke a handler based
21
+ on the action they perfom (aka. The handler's name).
22
+
23
+ A naming generator can be provided to the `Service` constructor in order to customize the function names
24
+ to the ubiquitous language of the domain. The default generator transforms the function name from snake_case
25
+ to kebab-case.
26
+
16
27
  Methods:
17
28
  register:
18
29
  Registers a handler for a specific command or query type. Handles nested or generic annotations.
@@ -46,8 +57,8 @@ class Service:
46
57
  self,
47
58
  name: str = None,
48
59
  *,
49
- generator: Callable[[str], str] = lambda name: sub(r'_', '-', name),
50
- provider: Provider = None
60
+ provider: Provider = None,
61
+ generator: Callable[[str], str] = lambda name: sub(r'_', '-', name)
51
62
  ):
52
63
  self.name = name
53
64
  self.handlers = dict[str, Callable[..., Any]]()
@@ -57,7 +68,7 @@ class Service:
57
68
  @property
58
69
  def dependency_overrides(self) -> dict:
59
70
  """
60
- Returns the dependency overrides for the service. This is useful for late binding,
71
+ An entry point for overriding the dependencies for the service. This is useful for late binding,
61
72
  testing and changing the behavior of the service in runtime.
62
73
 
63
74
  Returns:
@@ -110,16 +121,16 @@ class Service:
110
121
  service = Service()
111
122
 
112
123
  @service.handler
113
- def train(model: Model, data: DataLoader, device: str = Depends(device)):
124
+ def train_model(model: Model, data: DataLoader, device: str = Depends(device)):
114
125
  # Your training logic here
115
126
  ...
116
127
 
117
128
  model = Model()
118
129
  data = Data()
119
130
 
120
- service.handle('train', model, data) # train(model, data) will also work
121
- # but this is usefull when building REST APIs
122
- # with Command Query Segregation (CQS)
131
+ service.handle('train-model', model, data) # train(model, data) will also work
132
+ # but this is usefull when building REST APIs
133
+ # with Command Query Segregation (CQS)
123
134
  ```
124
135
  """
125
136
  handler = self.handlers.get(action, None)
@@ -1,19 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: torchsystem
3
- Version: 1.2.0
4
- Summary: A framework for creating message-driven IA systems with PyTorch
5
- License: MIT
6
- Author: mr-mapache
7
- Author-email: eric.m.cardozo@mi.unc.edu.ar
8
- Requires-Python: >=3.12,<4.0
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.12
12
- Classifier: Programming Language :: Python :: 3.13
13
- Requires-Dist: mlregistry (>=1.2.1,<2.0.0)
14
- Description-Content-Type: text/markdown
15
-
16
- # TorchSystem
17
- An IA training system based on event driven programming, unit of work pattern, pubsub and domain driven desing.
18
-
19
- Rebuilding!!! stay tuned.
@@ -1,4 +0,0 @@
1
- # TorchSystem
2
- An IA training system based on event driven programming, unit of work pattern, pubsub and domain driven desing.
3
-
4
- Rebuilding!!! stay tuned.
File without changes