Skip to content

Commit c7278b4

Browse files
feat(ocl): implement an event system for evaluation
1 parent 2ef7fe7 commit c7278b4

21 files changed

Lines changed: 1103 additions & 606 deletions

docs/contributing/faq.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ A learner should implement the appropriate interface:
3232
* {py:class}`capymoa.base.AnomalyDetector` for anomaly detectors.
3333
* {py:class}`capymoa.base.ClassifierSSL` for semi-supervised classifiers.
3434
* {py:class}`capymoa.base.Classifier` for online continual learning classifiers.
35-
Optionally also inheriting from {py:class}`capymoa.ocl.base.TrainTaskAware` or
36-
{py:class}`capymoa.ocl.base.TestTaskAware` to support learners that are aware of
37-
task identities or task boundaries.
35+
Optionally also inheriting from {py:class}`capymoa.base.events.Handler` to
36+
subscribe to task-boundary events (for example
37+
`capymoa.ocl.evaluation.events.TrainTaskBegin` and
38+
`capymoa.ocl.evaluation.events.TestTaskBegin`).
3839

3940
If your method is a wrapper around a MOA learner, you should use the appropriate
4041
base class:

docs/tutorials.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ These tutorials will show you how to get started with the CapyMOA library.
3232
notebooks/save_and_load_model.ipynb
3333
notebooks/clustering.ipynb
3434
notebooks/feature_importance.ipynb
35+
notebooks/ocl_event_system.ipynb
3536

3637
Talks
3738
=====

notebooks/ocl_event_system.ipynb

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"id": "c337f69e",
6+
"metadata": {},
7+
"source": [
8+
"# Online Continual Learning and Event Handlers\n",
9+
"\n",
10+
"The OCL model is experimenting with an event-based system to facilitate communication\n",
11+
"between different objects. This allows objects to hook into stages of training, evaluation, and inference. A common use is to implement custom metrics or loggers."
12+
]
13+
},
14+
{
15+
"cell_type": "markdown",
16+
"id": "3f7ec1bf",
17+
"metadata": {},
18+
"source": [
19+
"This block shows how to use the event system in isolation:"
20+
]
21+
},
22+
{
23+
"cell_type": "code",
24+
"execution_count": 1,
25+
"id": "c26d92f5",
26+
"metadata": {},
27+
"outputs": [
28+
{
29+
"name": "stdout",
30+
"output_type": "stream",
31+
"text": [
32+
"<class '__main__.MyEvent'>\n"
33+
]
34+
}
35+
],
36+
"source": [
37+
"from capymoa.base.events import Dispatcher, Event\n",
38+
"from dataclasses import dataclass\n",
39+
"\n",
40+
"\n",
41+
"@dataclass\n",
42+
"class MyEvent(Event):\n",
43+
" value: int\n",
44+
"\n",
45+
"\n",
46+
"dispatcher = Dispatcher()\n",
47+
"\n",
48+
"\n",
49+
"def print_event_type(event):\n",
50+
" print(type(event))\n",
51+
"\n",
52+
"\n",
53+
"dispatcher.subscribe(MyEvent, print_event_type)\n",
54+
"dispatcher.notify(MyEvent(42))"
55+
]
56+
},
57+
{
58+
"cell_type": "markdown",
59+
"id": "238f776f",
60+
"metadata": {},
61+
"source": [
62+
"This block shows how to use the event system within the OCL training loop. You can\n",
63+
"subscribe to the `None` event to get notified about every event emitted."
64+
]
65+
},
66+
{
67+
"cell_type": "code",
68+
"execution_count": 2,
69+
"id": "8db5dee1",
70+
"metadata": {},
71+
"outputs": [
72+
{
73+
"name": "stdout",
74+
"output_type": "stream",
75+
"text": [
76+
"<class 'capymoa.ocl.evaluation.events.TrainBegin'>\n",
77+
"<class 'capymoa.ocl.evaluation.events.TrainTaskBegin'>\n",
78+
"<class 'capymoa.ocl.evaluation.events.TrainBatchPredict'>\n",
79+
"<class 'capymoa.ocl.evaluation.events.TrainBatchPredict'>\n",
80+
"<class 'capymoa.ocl.evaluation.events.TrainBatchPredict'>\n",
81+
"<class 'capymoa.ocl.evaluation.events.TrainBatchPredict'>\n",
82+
"<class 'capymoa.ocl.evaluation.events.TestBegin'>\n",
83+
"<class 'capymoa.ocl.evaluation.events.TestTaskBegin'>\n",
84+
"<class 'capymoa.ocl.evaluation.events.EvalBatchPredict'>\n",
85+
"<class 'capymoa.ocl.evaluation.events.TestTaskEnd'>\n",
86+
"<class 'capymoa.ocl.evaluation.events.TestEnd'>\n",
87+
"<class 'capymoa.ocl.evaluation.events.TrainTaskEnd'>\n",
88+
"<class 'capymoa.ocl.evaluation.events.TrainEnd'>\n"
89+
]
90+
}
91+
],
92+
"source": [
93+
"from capymoa.ocl.evaluation import ocl_train_eval_loop\n",
94+
"from capymoa.ocl.datasets import TinySplitMNIST\n",
95+
"from capymoa.classifier import NoChange\n",
96+
"\n",
97+
"dispatcher = Dispatcher()\n",
98+
"# Subscribe to all events by using None as the event type\n",
99+
"dispatcher.subscribe(None, print_event_type)\n",
100+
"\n",
101+
"scenario = TinySplitMNIST()\n",
102+
"_ = ocl_train_eval_loop(\n",
103+
" NoChange(scenario.schema),\n",
104+
" train_streams=scenario.train_loaders(64)[:1],\n",
105+
" test_streams=scenario.test_loaders(64)[:1],\n",
106+
" dispatcher=dispatcher,\n",
107+
")"
108+
]
109+
},
110+
{
111+
"cell_type": "markdown",
112+
"id": "a8d1e798",
113+
"metadata": {},
114+
"source": [
115+
"We can use the ``TestTaskBegin`` and ``TrainTaskBegin`` events to implement a\n",
116+
"task-incremental learner. To standardising subscribing to a dispatcher our method\n",
117+
"implement's `attach_with` from the `Handler` class.\n"
118+
]
119+
},
120+
{
121+
"cell_type": "code",
122+
"execution_count": 4,
123+
"id": "10f054c2",
124+
"metadata": {},
125+
"outputs": [
126+
{
127+
"name": "stdout",
128+
"output_type": "stream",
129+
"text": [
130+
"Train task 0 has begun.\n",
131+
"Train task 1 has begun.\n",
132+
"Train task 2 has begun.\n",
133+
"Train task 3 has begun.\n",
134+
"Train task 4 has begun.\n",
135+
"Accuracy 63.57\n"
136+
]
137+
}
138+
],
139+
"source": [
140+
"from capymoa.base import BatchClassifier\n",
141+
"from capymoa.base.events import Dispatcher, Handler\n",
142+
"from capymoa.classifier import Finetune\n",
143+
"from capymoa.ocl.evaluation.events import TrainTaskBegin, TestTaskBegin\n",
144+
"from capymoa.ann import Perceptron\n",
145+
"from torch import Tensor\n",
146+
"\n",
147+
"\n",
148+
"class PerceptronTI(BatchClassifier, Handler):\n",
149+
" def __init__(self, schema, n_tasks: int) -> None:\n",
150+
" super().__init__(schema)\n",
151+
" self._classifiers = [Finetune(schema, Perceptron) for _ in range(n_tasks)]\n",
152+
" self._train_task = 0\n",
153+
" self._test_task = 0\n",
154+
"\n",
155+
" def batch_train(self, x: Tensor, y: Tensor) -> None:\n",
156+
" self._classifiers[self._train_task].batch_train(x, y)\n",
157+
"\n",
158+
" def batch_predict_proba(self, x: Tensor) -> Tensor:\n",
159+
" return self._classifiers[self._test_task].batch_predict_proba(x)\n",
160+
"\n",
161+
" def _on_train_task_begin(self, event: TrainTaskBegin) -> None:\n",
162+
" print(f\"Train task {event.train_task} has begun.\")\n",
163+
" self._train_task = event.train_task\n",
164+
"\n",
165+
" def _on_test_task_begin(self, event: TestTaskBegin) -> None:\n",
166+
" self._test_task = event.test_task\n",
167+
"\n",
168+
" def attach_with(self, dispatcher: Dispatcher) -> Handler:\n",
169+
" dispatcher.subscribe(TrainTaskBegin, self._on_train_task_begin)\n",
170+
" dispatcher.subscribe(TestTaskBegin, self._on_test_task_begin)\n",
171+
" return super().attach_with(dispatcher)\n",
172+
"\n",
173+
"\n",
174+
"learner = PerceptronTI(scenario.schema, n_tasks=5)\n",
175+
"results = ocl_train_eval_loop(\n",
176+
" learner, # If the learner is a Handler, it will be automatically subscribed.\n",
177+
" train_streams=scenario.train_loaders(32),\n",
178+
" test_streams=scenario.test_loaders(32),\n",
179+
")\n",
180+
"print(f\"Accuracy {results.accuracy_seen_avg * 100:.2f}\")"
181+
]
182+
},
183+
{
184+
"cell_type": "markdown",
185+
"id": "35fc7fd7",
186+
"metadata": {},
187+
"source": [
188+
"If you use a custom train-test loop with a ``Classifier`` that is also a ``Handler`` you\n",
189+
"will need to manually notify the classifier as required."
190+
]
191+
}
192+
],
193+
"metadata": {
194+
"kernelspec": {
195+
"display_name": "capymoa",
196+
"language": "python",
197+
"name": "python3"
198+
},
199+
"language_info": {
200+
"codemirror_mode": {
201+
"name": "ipython",
202+
"version": 3
203+
},
204+
"file_extension": ".py",
205+
"mimetype": "text/x-python",
206+
"name": "python",
207+
"nbconvert_exporter": "python",
208+
"pygments_lexer": "ipython3",
209+
"version": "3.12.0"
210+
}
211+
},
212+
"nbformat": 4,
213+
"nbformat_minor": 5
214+
}

src/capymoa/base/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
MOAClassifierSSL,
2222
)
2323
from ._batch import Batch
24+
from . import events
2425

2526
__all__ = [
2627
"_extract_moa_drift_detector_CLI",
@@ -43,4 +44,5 @@
4344
"MOAClusterer",
4445
"MOAPredictionIntervalLearner",
4546
"PredictionIntervalLearner",
47+
"events",
4648
]

src/capymoa/base/events.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from typing import Callable, Type, Dict, cast
2+
from abc import ABC, abstractmethod
3+
4+
5+
class Event:
6+
"""Base class for events emitted by a :py:class:`Dispatcher`."""
7+
8+
9+
class Dispatcher:
10+
"""Publish events to subscribed callbacks.
11+
12+
Create a source, subscribe one or more handlers for an event type, and
13+
dispatch event instances with :py:meth:`notify`.
14+
15+
>>> source = Dispatcher()
16+
>>> def handler(event: Event): print(f"Received event: {event}")
17+
>>> source.subscribe(Event, handler)
18+
>>> source.notify(Event())
19+
Received event: <src.capymoa.base.events.Event...
20+
21+
Subscriptions are keyed by exact event class.
22+
"""
23+
24+
def __init__(self) -> None:
25+
"""Initialize an empty mapping of event types to callbacks."""
26+
self.subscribers: Dict[Type[Event] | None, list[Callable[[Event], None]]] = {}
27+
28+
def subscribe(
29+
self, event_type: Type[Event] | None, callable: Callable[[Event], None]
30+
) -> None:
31+
"""Register a callback for a specific event class."""
32+
callable = cast(Callable[[Event], None], callable)
33+
self.subscribers.setdefault(event_type, []).append(callable)
34+
35+
def unsubscribe(
36+
self, event_type: Type[Event] | None, callable: Callable[[Event], None]
37+
) -> None:
38+
"""Remove a previously registered callback for an event class."""
39+
callable = cast(Callable[[Event], None], callable)
40+
self.subscribers.get(event_type, []).remove(callable)
41+
42+
def notify(self, event: Event) -> None:
43+
"""Notify all subscribers of an event.
44+
45+
Only subscribers of the exact event type will be notified. Subscribers of parent
46+
event types will not be notified.
47+
"""
48+
for subscriber in self.subscribers.get(type(event), []):
49+
subscriber(event)
50+
for subscriber in self.subscribers.get(None, []):
51+
subscriber(event)
52+
53+
54+
class Handler(ABC):
55+
"""Abstract interface for components that consume events.
56+
57+
A sink encapsulates subscription logic and can attach itself to an
58+
:py:class:`Dispatcher`.
59+
60+
Subclass ``EventSink`` and implement :py:meth:`attach_with` to register
61+
the sink's handlers with a source.
62+
"""
63+
64+
@abstractmethod
65+
def attach_with(self, dispatcher: Dispatcher) -> "Handler":
66+
"""Attach this sink to an event source.
67+
68+
Implementations should call
69+
:py:meth:`capymoa.base.events.Dispatcher.subscribe` for each
70+
event type the sink needs to handle.
71+
72+
:param dispatcher: The source this sink should subscribe to.
73+
"""

src/capymoa/ocl/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,6 @@
5252
Backward Transfer: -0.08
5353
"""
5454

55-
from . import base, datasets, evaluation, util, strategy
55+
from . import datasets, evaluation, util, strategy
5656

57-
__all__ = ["evaluation", "datasets", "strategy", "base", "util"]
57+
__all__ = ["evaluation", "datasets", "strategy", "util"]

0 commit comments

Comments
 (0)