predcache 0.1.0__tar.gz → 0.2.0__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.
- {predcache-0.1.0/src/predcache.egg-info → predcache-0.2.0}/PKG-INFO +46 -1
- {predcache-0.1.0 → predcache-0.2.0}/README.md +41 -0
- {predcache-0.1.0 → predcache-0.2.0}/pyproject.toml +3 -2
- {predcache-0.1.0 → predcache-0.2.0}/src/predcache/__init__.py +3 -1
- {predcache-0.1.0 → predcache-0.2.0}/src/predcache/batched.py +24 -13
- {predcache-0.1.0 → predcache-0.2.0}/src/predcache/cache.py +18 -7
- predcache-0.2.0/src/predcache/observability.py +118 -0
- {predcache-0.1.0 → predcache-0.2.0}/src/predcache/windowing.py +17 -7
- {predcache-0.1.0 → predcache-0.2.0/src/predcache.egg-info}/PKG-INFO +46 -1
- {predcache-0.1.0 → predcache-0.2.0}/src/predcache.egg-info/SOURCES.txt +2 -0
- predcache-0.2.0/src/predcache.egg-info/requires.txt +14 -0
- predcache-0.2.0/tests/test_observability.py +141 -0
- predcache-0.1.0/src/predcache.egg-info/requires.txt +0 -9
- {predcache-0.1.0 → predcache-0.2.0}/LICENSE +0 -0
- {predcache-0.1.0 → predcache-0.2.0}/setup.cfg +0 -0
- {predcache-0.1.0 → predcache-0.2.0}/src/predcache.egg-info/dependency_links.txt +0 -0
- {predcache-0.1.0 → predcache-0.2.0}/src/predcache.egg-info/top_level.txt +0 -0
- {predcache-0.1.0 → predcache-0.2.0}/tests/test_predcache.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: predcache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Persistent timestamp-keyed prediction cache and batched inference runner for ML models
|
|
5
5
|
Author-email: Anton Uralskii <uralskyanton@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -28,9 +28,13 @@ Requires-Dist: numpy>=1.21
|
|
|
28
28
|
Provides-Extra: parquet
|
|
29
29
|
Requires-Dist: pandas>=1.5; extra == "parquet"
|
|
30
30
|
Requires-Dist: pyarrow>=10; extra == "parquet"
|
|
31
|
+
Provides-Extra: otel
|
|
32
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
|
|
31
33
|
Provides-Extra: test
|
|
32
34
|
Requires-Dist: pytest>=7; extra == "test"
|
|
33
35
|
Requires-Dist: pandas>=1.5; extra == "test"
|
|
36
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "test"
|
|
37
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
|
|
34
38
|
Dynamic: license-file
|
|
35
39
|
|
|
36
40
|
# predcache
|
|
@@ -104,9 +108,50 @@ out = cache.get_or_compute(
|
|
|
104
108
|
box; register custom backends with `register_backend(name, dump, load)`.
|
|
105
109
|
- **Batched inference runner** — framework-agnostic `(start, stop) -> scores`
|
|
106
110
|
contract, progress callbacks, tuned default batch size (500).
|
|
111
|
+
- **OpenTelemetry instrumentation** — optional spans + counters for cache hits,
|
|
112
|
+
computed predictions, batched inference and feature assembly (see below).
|
|
107
113
|
- **L-inf normalization** helper matching the source system's
|
|
108
114
|
`normalize_vector_absolute`.
|
|
109
115
|
|
|
116
|
+
## Observability (OpenTelemetry)
|
|
117
|
+
|
|
118
|
+
`predcache` emits spans and counters for every expensive operation. The
|
|
119
|
+
`opentelemetry-api` package is **optional**: without it predcache runs
|
|
120
|
+
identically with zero overhead; with it installed, signals flow to whatever
|
|
121
|
+
Tracer/Meter provider your application configured.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
pip install predcache[otel] # pull in opentelemetry-api
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from opentelemetry import trace
|
|
129
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
130
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
|
131
|
+
|
|
132
|
+
trace.set_tracer_provider(TracerProvider())
|
|
133
|
+
trace.get_tracer_provider().add_span_processor(
|
|
134
|
+
BatchSpanProcessor(ConsoleSpanExporter())
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
from predcache import PredictionCache
|
|
138
|
+
|
|
139
|
+
cache = PredictionCache("preds.pkl")
|
|
140
|
+
cache.get_or_compute(keys, compute_fn)
|
|
141
|
+
# -> span "predcache.get_or_compute" with predcache.requested / .computed /
|
|
142
|
+
# .hits / .elapsed_s attributes, + predictions.computed counter
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Signal | Name | Attributes |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| span | `predcache.get_or_compute` | `predcache.requested`, `.computed`, `.hits`, `.elapsed_s` |
|
|
148
|
+
| span | `predcache.inference` | `predcache.rows`, `.batch_size`, `.elapsed_s` |
|
|
149
|
+
| span | `predcache.features` | `predcache.windows`, `.window`, `.warmup`, `.elapsed_s` |
|
|
150
|
+
| counter | `predcache.predictions.computed` / `.hits` | — |
|
|
151
|
+
| counter | `predcache.inference.batches` / `.rows` | — |
|
|
152
|
+
|
|
153
|
+
Set `PREDCACHE_OTEL=0` to disable emission entirely (hot loops, test runs).
|
|
154
|
+
|
|
110
155
|
## Origin
|
|
111
156
|
|
|
112
157
|
This package is the generalized core of the prediction-serving layer of
|
|
@@ -69,9 +69,50 @@ out = cache.get_or_compute(
|
|
|
69
69
|
box; register custom backends with `register_backend(name, dump, load)`.
|
|
70
70
|
- **Batched inference runner** — framework-agnostic `(start, stop) -> scores`
|
|
71
71
|
contract, progress callbacks, tuned default batch size (500).
|
|
72
|
+
- **OpenTelemetry instrumentation** — optional spans + counters for cache hits,
|
|
73
|
+
computed predictions, batched inference and feature assembly (see below).
|
|
72
74
|
- **L-inf normalization** helper matching the source system's
|
|
73
75
|
`normalize_vector_absolute`.
|
|
74
76
|
|
|
77
|
+
## Observability (OpenTelemetry)
|
|
78
|
+
|
|
79
|
+
`predcache` emits spans and counters for every expensive operation. The
|
|
80
|
+
`opentelemetry-api` package is **optional**: without it predcache runs
|
|
81
|
+
identically with zero overhead; with it installed, signals flow to whatever
|
|
82
|
+
Tracer/Meter provider your application configured.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install predcache[otel] # pull in opentelemetry-api
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from opentelemetry import trace
|
|
90
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
91
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
|
92
|
+
|
|
93
|
+
trace.set_tracer_provider(TracerProvider())
|
|
94
|
+
trace.get_tracer_provider().add_span_processor(
|
|
95
|
+
BatchSpanProcessor(ConsoleSpanExporter())
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
from predcache import PredictionCache
|
|
99
|
+
|
|
100
|
+
cache = PredictionCache("preds.pkl")
|
|
101
|
+
cache.get_or_compute(keys, compute_fn)
|
|
102
|
+
# -> span "predcache.get_or_compute" with predcache.requested / .computed /
|
|
103
|
+
# .hits / .elapsed_s attributes, + predictions.computed counter
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Signal | Name | Attributes |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| span | `predcache.get_or_compute` | `predcache.requested`, `.computed`, `.hits`, `.elapsed_s` |
|
|
109
|
+
| span | `predcache.inference` | `predcache.rows`, `.batch_size`, `.elapsed_s` |
|
|
110
|
+
| span | `predcache.features` | `predcache.windows`, `.window`, `.warmup`, `.elapsed_s` |
|
|
111
|
+
| counter | `predcache.predictions.computed` / `.hits` | — |
|
|
112
|
+
| counter | `predcache.inference.batches` / `.rows` | — |
|
|
113
|
+
|
|
114
|
+
Set `PREDCACHE_OTEL=0` to disable emission entirely (hot loops, test runs).
|
|
115
|
+
|
|
75
116
|
## Origin
|
|
76
117
|
|
|
77
118
|
This package is the generalized core of the prediction-serving layer of
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "predcache"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Persistent timestamp-keyed prediction cache and batched inference runner for ML models"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -37,7 +37,8 @@ dependencies = ["numpy>=1.21"]
|
|
|
37
37
|
|
|
38
38
|
[project.optional-dependencies]
|
|
39
39
|
parquet = ["pandas>=1.5", "pyarrow>=10"]
|
|
40
|
-
|
|
40
|
+
otel = ["opentelemetry-api>=1.20"]
|
|
41
|
+
test = ["pytest>=7", "pandas>=1.5", "opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20"]
|
|
41
42
|
|
|
42
43
|
[project.urls]
|
|
43
44
|
Homepage = "https://github.com/Geomanti/predcache"
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"""predcache: persistent prediction caching and batched inference for ML models."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
4
|
|
|
5
5
|
from .cache import PredictionCache, CacheEntry
|
|
6
6
|
from .batched import BatchedInferenceRunner, InferenceFn
|
|
7
7
|
from .windowing import FeatureWindowAssembler
|
|
8
|
+
from .observability import otel_available
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
10
11
|
"PredictionCache",
|
|
11
12
|
"CacheEntry",
|
|
12
13
|
"BatchedInferenceRunner",
|
|
13
14
|
"FeatureWindowAssembler",
|
|
15
|
+
"otel_available",
|
|
14
16
|
]
|
|
@@ -14,6 +14,8 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence
|
|
|
14
14
|
|
|
15
15
|
import numpy as np
|
|
16
16
|
|
|
17
|
+
from .observability import _TimedSpan, add_count
|
|
18
|
+
|
|
17
19
|
__all__ = ["BatchedInferenceRunner", "InferenceFn"]
|
|
18
20
|
|
|
19
21
|
# A scorer takes (start, stop) and returns an array-like of predictions
|
|
@@ -79,19 +81,28 @@ class BatchedInferenceRunner:
|
|
|
79
81
|
out: List[Any] = []
|
|
80
82
|
t0 = time.perf_counter()
|
|
81
83
|
n_batches = 0
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
with _TimedSpan(
|
|
85
|
+
"predcache.inference",
|
|
86
|
+
{
|
|
87
|
+
"predcache.rows": self.n_samples,
|
|
88
|
+
"predcache.batch_size": self.batch_size,
|
|
89
|
+
},
|
|
90
|
+
):
|
|
91
|
+
for start, stop in self.batches():
|
|
92
|
+
chunk = self.inference_fn(start, stop)
|
|
93
|
+
out.extend(_row_iter(chunk))
|
|
94
|
+
n_batches += 1
|
|
95
|
+
add_count("predcache.inference.batches", 1)
|
|
96
|
+
add_count("predcache.inference.rows", stop - start)
|
|
97
|
+
if progress_cb and n_batches % self.progress_every == 0:
|
|
98
|
+
cb = {
|
|
99
|
+
"rows_done": stop,
|
|
100
|
+
"rows_total": self.n_samples,
|
|
101
|
+
"batches": n_batches,
|
|
102
|
+
"elapsed_s": round(time.perf_counter() - t0, 3),
|
|
103
|
+
}
|
|
104
|
+
self.progress_log.append(cb)
|
|
105
|
+
progress_cb(cb)
|
|
95
106
|
return out
|
|
96
107
|
|
|
97
108
|
|
|
@@ -24,6 +24,8 @@ import tempfile
|
|
|
24
24
|
from dataclasses import dataclass, field
|
|
25
25
|
from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Protocol
|
|
26
26
|
|
|
27
|
+
from .observability import _TimedSpan, add_count
|
|
28
|
+
|
|
27
29
|
__all__ = ["PredictionCache", "CacheEntry", "register_backend"]
|
|
28
30
|
|
|
29
31
|
|
|
@@ -200,13 +202,22 @@ class PredictionCache:
|
|
|
200
202
|
dict ``{key: value}``. Computed values are merged into the cache.
|
|
201
203
|
"""
|
|
202
204
|
missing = [k for k in keys if k not in self._entries]
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
205
|
+
with _TimedSpan(
|
|
206
|
+
"predcache.get_or_compute",
|
|
207
|
+
{
|
|
208
|
+
"predcache.requested": len(keys),
|
|
209
|
+
"predcache.computed": len(missing),
|
|
210
|
+
"predcache.hits": len(keys) - len(missing),
|
|
211
|
+
},
|
|
212
|
+
):
|
|
213
|
+
if missing:
|
|
214
|
+
computed = compute_fn(missing) or {}
|
|
215
|
+
for k, v in computed.items():
|
|
216
|
+
self._entries[k] = v
|
|
217
|
+
self._dirty = True
|
|
218
|
+
add_count("predcache.predictions.computed", len(computed))
|
|
219
|
+
if save:
|
|
220
|
+
self.save()
|
|
210
221
|
return {k: self._entries[k] for k in keys if k in self._entries}
|
|
211
222
|
|
|
212
223
|
def drop(self, keys: List[Hashable]) -> int:
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Optional OpenTelemetry instrumentation for predcache.
|
|
2
|
+
|
|
3
|
+
Design
|
|
4
|
+
------
|
|
5
|
+
* **Zero hard dependency** — when ``opentelemetry`` is not installed every
|
|
6
|
+
helper here degrades to a no-op context manager / counter sink, so the
|
|
7
|
+
core cache stays dependency-light.
|
|
8
|
+
* **Auto-enabled** — as soon as ``opentelemetry-api`` is importable, spans
|
|
9
|
+
and counters flow to whatever ``TracerProvider``/``MeterProvider`` the
|
|
10
|
+
host application configured (the OpenTelemetry API returns no-op
|
|
11
|
+
tracers/meters until a provider is set, so unconfigured hosts pay
|
|
12
|
+
near-zero cost).
|
|
13
|
+
* **Explicit off switch** — set ``PREDCACHE_OTEL=0`` to disable emission
|
|
14
|
+
entirely (useful in hot loops or test matrices without the package).
|
|
15
|
+
|
|
16
|
+
Emitted signals
|
|
17
|
+
---------------
|
|
18
|
+
Spans (as current spans, so they nest under the caller's trace):
|
|
19
|
+
|
|
20
|
+
* ``predcache.get_or_compute`` — attributes ``predcache.requested``,
|
|
21
|
+
``predcache.computed``, ``predcache.hits``
|
|
22
|
+
* ``predcache.inference`` — ``predcache.rows``, ``predcache.batches``,
|
|
23
|
+
``predcache.batch_size``, ``predcache.elapsed_s``
|
|
24
|
+
* ``predcache.features`` — ``predcache.windows``, ``predcache.window``,
|
|
25
|
+
``predcache.warmup``
|
|
26
|
+
|
|
27
|
+
Counters:
|
|
28
|
+
|
|
29
|
+
* ``predcache.predictions.computed`` / ``predcache.predictions.hits``
|
|
30
|
+
* ``predcache.inference.batches`` / ``predcache.inference.rows``
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import time
|
|
37
|
+
from contextlib import nullcontext
|
|
38
|
+
from typing import Any, Dict, Optional
|
|
39
|
+
|
|
40
|
+
__all__ = ["span", "add_count", "otel_available"]
|
|
41
|
+
|
|
42
|
+
try: # opentelemetry-api is optional
|
|
43
|
+
from opentelemetry import metrics, trace
|
|
44
|
+
|
|
45
|
+
_OTEL_AVAILABLE = True
|
|
46
|
+
except ImportError: # pragma: no cover - exercised in no-dependency installs
|
|
47
|
+
_OTEL_AVAILABLE = False
|
|
48
|
+
|
|
49
|
+
_TRACER = None
|
|
50
|
+
_METER = None
|
|
51
|
+
_COUNTERS: Dict[str, Any] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def otel_available() -> bool:
|
|
55
|
+
"""True when opentelemetry is importable and not disabled by env var."""
|
|
56
|
+
return _OTEL_AVAILABLE and os.environ.get("PREDCACHE_OTEL", "1") != "0"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _tracer():
|
|
60
|
+
global _TRACER
|
|
61
|
+
if _TRACER is None and _OTEL_AVAILABLE:
|
|
62
|
+
# ProxyTracer: resolves the real provider lazily, so caching here is safe
|
|
63
|
+
# even when the host sets the TracerProvider after importing predcache.
|
|
64
|
+
_TRACER = trace.get_tracer("predcache")
|
|
65
|
+
return _TRACER
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _meter():
|
|
69
|
+
global _METER
|
|
70
|
+
if _METER is None and _OTEL_AVAILABLE:
|
|
71
|
+
_METER = metrics.get_meter("predcache")
|
|
72
|
+
return _METER
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def span(name: str, attributes: Optional[Dict[str, Any]] = None):
|
|
76
|
+
"""Start a span as the current span; no-op context when OTel is absent."""
|
|
77
|
+
tracer = _tracer() if otel_available() else None
|
|
78
|
+
if tracer is None:
|
|
79
|
+
return nullcontext()
|
|
80
|
+
return tracer.start_as_current_span(name, attributes=attributes or {})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def add_count(name: str, value: int = 1, attributes: Optional[Dict[str, Any]] = None) -> None:
|
|
84
|
+
"""Increment a monotonic counter; no-op when OTel is absent."""
|
|
85
|
+
meter = _meter() if otel_available() else None
|
|
86
|
+
if meter is None:
|
|
87
|
+
return
|
|
88
|
+
counter = _COUNTERS.get(name)
|
|
89
|
+
if counter is None:
|
|
90
|
+
counter = meter.create_counter(name)
|
|
91
|
+
_COUNTERS[name] = counter
|
|
92
|
+
if attributes:
|
|
93
|
+
counter.add(value, attributes)
|
|
94
|
+
else:
|
|
95
|
+
counter.add(value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _TimedSpan:
|
|
99
|
+
"""Span helper that also reports elapsed seconds as a span attribute."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, name: str, attributes: Optional[Dict[str, Any]] = None):
|
|
102
|
+
self._name = name
|
|
103
|
+
self._attributes = dict(attributes or {})
|
|
104
|
+
self._cm = span(name, self._attributes)
|
|
105
|
+
self._t0: Optional[float] = None
|
|
106
|
+
|
|
107
|
+
def __enter__(self):
|
|
108
|
+
self._cm.__enter__()
|
|
109
|
+
self._t0 = time.perf_counter()
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def __exit__(self, exc_type, exc, tb):
|
|
113
|
+
if self._t0 is not None:
|
|
114
|
+
elapsed = round(time.perf_counter() - self._t0, 6)
|
|
115
|
+
current = trace.get_current_span() if _OTEL_AVAILABLE else None
|
|
116
|
+
if current is not None and current.is_recording():
|
|
117
|
+
current.set_attribute("predcache.elapsed_s", elapsed)
|
|
118
|
+
return self._cm.__exit__(exc_type, exc, tb)
|
|
@@ -12,6 +12,8 @@ from typing import Callable, List, Optional, Sequence
|
|
|
12
12
|
|
|
13
13
|
import numpy as np
|
|
14
14
|
|
|
15
|
+
from .observability import _TimedSpan
|
|
16
|
+
|
|
15
17
|
__all__ = ["normalize_absolute", "FeatureWindowAssembler"]
|
|
16
18
|
|
|
17
19
|
|
|
@@ -81,10 +83,18 @@ class FeatureWindowAssembler:
|
|
|
81
83
|
"""Stack feature rows for every window; returns (X, keys)."""
|
|
82
84
|
if self.row_fn is None:
|
|
83
85
|
raise ValueError("row_fn is required to build features")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
with _TimedSpan(
|
|
87
|
+
"predcache.features",
|
|
88
|
+
{
|
|
89
|
+
"predcache.windows": max(0, len(df) - self.warmup),
|
|
90
|
+
"predcache.window": self.window,
|
|
91
|
+
"predcache.warmup": self.warmup,
|
|
92
|
+
},
|
|
93
|
+
):
|
|
94
|
+
rows: List[np.ndarray] = []
|
|
95
|
+
for win in self.windows(df):
|
|
96
|
+
rows.append(np.asarray(self.row_fn(win), dtype=np.float32))
|
|
97
|
+
if not rows:
|
|
98
|
+
return np.zeros((0, 0), dtype=np.float32), []
|
|
99
|
+
keys = self.keys(df, key_col) if key_col else self.keys(df)
|
|
100
|
+
return np.stack(rows, axis=0), keys
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: predcache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Persistent timestamp-keyed prediction cache and batched inference runner for ML models
|
|
5
5
|
Author-email: Anton Uralskii <uralskyanton@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -28,9 +28,13 @@ Requires-Dist: numpy>=1.21
|
|
|
28
28
|
Provides-Extra: parquet
|
|
29
29
|
Requires-Dist: pandas>=1.5; extra == "parquet"
|
|
30
30
|
Requires-Dist: pyarrow>=10; extra == "parquet"
|
|
31
|
+
Provides-Extra: otel
|
|
32
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
|
|
31
33
|
Provides-Extra: test
|
|
32
34
|
Requires-Dist: pytest>=7; extra == "test"
|
|
33
35
|
Requires-Dist: pandas>=1.5; extra == "test"
|
|
36
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "test"
|
|
37
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
|
|
34
38
|
Dynamic: license-file
|
|
35
39
|
|
|
36
40
|
# predcache
|
|
@@ -104,9 +108,50 @@ out = cache.get_or_compute(
|
|
|
104
108
|
box; register custom backends with `register_backend(name, dump, load)`.
|
|
105
109
|
- **Batched inference runner** — framework-agnostic `(start, stop) -> scores`
|
|
106
110
|
contract, progress callbacks, tuned default batch size (500).
|
|
111
|
+
- **OpenTelemetry instrumentation** — optional spans + counters for cache hits,
|
|
112
|
+
computed predictions, batched inference and feature assembly (see below).
|
|
107
113
|
- **L-inf normalization** helper matching the source system's
|
|
108
114
|
`normalize_vector_absolute`.
|
|
109
115
|
|
|
116
|
+
## Observability (OpenTelemetry)
|
|
117
|
+
|
|
118
|
+
`predcache` emits spans and counters for every expensive operation. The
|
|
119
|
+
`opentelemetry-api` package is **optional**: without it predcache runs
|
|
120
|
+
identically with zero overhead; with it installed, signals flow to whatever
|
|
121
|
+
Tracer/Meter provider your application configured.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
pip install predcache[otel] # pull in opentelemetry-api
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from opentelemetry import trace
|
|
129
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
130
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
|
131
|
+
|
|
132
|
+
trace.set_tracer_provider(TracerProvider())
|
|
133
|
+
trace.get_tracer_provider().add_span_processor(
|
|
134
|
+
BatchSpanProcessor(ConsoleSpanExporter())
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
from predcache import PredictionCache
|
|
138
|
+
|
|
139
|
+
cache = PredictionCache("preds.pkl")
|
|
140
|
+
cache.get_or_compute(keys, compute_fn)
|
|
141
|
+
# -> span "predcache.get_or_compute" with predcache.requested / .computed /
|
|
142
|
+
# .hits / .elapsed_s attributes, + predictions.computed counter
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Signal | Name | Attributes |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| span | `predcache.get_or_compute` | `predcache.requested`, `.computed`, `.hits`, `.elapsed_s` |
|
|
148
|
+
| span | `predcache.inference` | `predcache.rows`, `.batch_size`, `.elapsed_s` |
|
|
149
|
+
| span | `predcache.features` | `predcache.windows`, `.window`, `.warmup`, `.elapsed_s` |
|
|
150
|
+
| counter | `predcache.predictions.computed` / `.hits` | — |
|
|
151
|
+
| counter | `predcache.inference.batches` / `.rows` | — |
|
|
152
|
+
|
|
153
|
+
Set `PREDCACHE_OTEL=0` to disable emission entirely (hot loops, test runs).
|
|
154
|
+
|
|
110
155
|
## Origin
|
|
111
156
|
|
|
112
157
|
This package is the generalized core of the prediction-serving layer of
|
|
@@ -4,10 +4,12 @@ pyproject.toml
|
|
|
4
4
|
src/predcache/__init__.py
|
|
5
5
|
src/predcache/batched.py
|
|
6
6
|
src/predcache/cache.py
|
|
7
|
+
src/predcache/observability.py
|
|
7
8
|
src/predcache/windowing.py
|
|
8
9
|
src/predcache.egg-info/PKG-INFO
|
|
9
10
|
src/predcache.egg-info/SOURCES.txt
|
|
10
11
|
src/predcache.egg-info/dependency_links.txt
|
|
11
12
|
src/predcache.egg-info/requires.txt
|
|
12
13
|
src/predcache.egg-info/top_level.txt
|
|
14
|
+
tests/test_observability.py
|
|
13
15
|
tests/test_predcache.py
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Observability tests: spans + counters with the in-memory exporter.
|
|
2
|
+
|
|
3
|
+
These run only when opentelemetry is installed (the ``otel`` test extra);
|
|
4
|
+
they are skipped cleanly otherwise. The no-op path is covered by forcing
|
|
5
|
+
``PREDCACHE_OTEL=0``.
|
|
6
|
+
|
|
7
|
+
Note: opentelemetry's SDK forbids overriding an already-set global
|
|
8
|
+
Tracer/MeterProvider, so these tests share ONE module-scoped provider and
|
|
9
|
+
read spans/metrics cumulatively (counting only the spans each test adds).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
pytest.importorskip("opentelemetry", reason="observability tests need opentelemetry-api/sdk")
|
|
15
|
+
|
|
16
|
+
from opentelemetry import trace as _trace
|
|
17
|
+
from opentelemetry import metrics as _metrics
|
|
18
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
19
|
+
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
|
20
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
21
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
22
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
23
|
+
|
|
24
|
+
import predcache
|
|
25
|
+
from predcache import BatchedInferenceRunner, FeatureWindowAssembler, PredictionCache
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(scope="module")
|
|
29
|
+
def span_exporter():
|
|
30
|
+
"""One provider for the module; returns the shared in-memory exporter."""
|
|
31
|
+
exporter = InMemorySpanExporter()
|
|
32
|
+
provider = TracerProvider()
|
|
33
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
34
|
+
try:
|
|
35
|
+
_trace.set_tracer_provider(provider)
|
|
36
|
+
except Exception:
|
|
37
|
+
# A provider was already set by another test module — use a proxy
|
|
38
|
+
# tracer from the existing provider but keep collecting into ours.
|
|
39
|
+
pass
|
|
40
|
+
# Re-point predcache's lazy tracer at this provider.
|
|
41
|
+
import predcache.observability as obs
|
|
42
|
+
|
|
43
|
+
obs._TRACER = trace = _trace.get_tracer("predcache-test")
|
|
44
|
+
yield exporter
|
|
45
|
+
obs._TRACER = None # restore lazy resolution
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture(scope="module")
|
|
49
|
+
def metric_reader():
|
|
50
|
+
reader = InMemoryMetricReader()
|
|
51
|
+
provider = MeterProvider(metric_readers=[reader])
|
|
52
|
+
try:
|
|
53
|
+
_metrics.set_meter_provider(provider)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
import predcache.observability as obs
|
|
57
|
+
|
|
58
|
+
obs._METER = _metrics.get_meter("predcache-test")
|
|
59
|
+
# predcache caches counters per meter; reset the cache so the new meter
|
|
60
|
+
# creates fresh counters instead of reusing ones from another provider.
|
|
61
|
+
obs._COUNTERS.clear()
|
|
62
|
+
yield reader
|
|
63
|
+
obs._METER = None
|
|
64
|
+
obs._COUNTERS.clear()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _names(exporter):
|
|
68
|
+
return [s.name for s in exporter.get_finished_spans()]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_get_or_compute_emits_span_with_attributes(span_exporter, metric_reader, tmp_path):
|
|
72
|
+
n_before = len(_names(span_exporter))
|
|
73
|
+
cache = PredictionCache(str(tmp_path / "c.pkl"))
|
|
74
|
+
cache.get_or_compute(["a", "b"], compute_fn=lambda missing: {k: 1 for k in missing})
|
|
75
|
+
cache.get_or_compute(["a", "b", "c"], compute_fn=lambda missing: {k: 2 for k in missing})
|
|
76
|
+
|
|
77
|
+
spans = span_exporter.get_finished_spans()[n_before:]
|
|
78
|
+
names = [s.name for s in spans]
|
|
79
|
+
assert names.count("predcache.get_or_compute") == 2
|
|
80
|
+
|
|
81
|
+
second = spans[-1]
|
|
82
|
+
attrs = dict(second.attributes)
|
|
83
|
+
assert attrs["predcache.requested"] == 3
|
|
84
|
+
assert attrs["predcache.computed"] == 1
|
|
85
|
+
assert attrs["predcache.hits"] == 2
|
|
86
|
+
assert "predcache.elapsed_s" in attrs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_runner_emits_inference_span_and_metrics(span_exporter, metric_reader):
|
|
90
|
+
n_before = len(_names(span_exporter))
|
|
91
|
+
runner = BatchedInferenceRunner(
|
|
92
|
+
n_samples=10, inference_fn=lambda s, e: [[float(i)] for i in range(s, e)], batch_size=4
|
|
93
|
+
)
|
|
94
|
+
runner.run()
|
|
95
|
+
|
|
96
|
+
spans = span_exporter.get_finished_spans()[n_before:]
|
|
97
|
+
inf = [s for s in spans if s.name == "predcache.inference"]
|
|
98
|
+
assert len(inf) == 1
|
|
99
|
+
attrs = dict(inf[0].attributes)
|
|
100
|
+
assert attrs["predcache.rows"] == 10
|
|
101
|
+
assert attrs["predcache.batch_size"] == 4
|
|
102
|
+
assert "predcache.elapsed_s" in attrs
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_features_span(span_exporter, metric_reader):
|
|
106
|
+
import pandas as pd
|
|
107
|
+
|
|
108
|
+
n_before = len(_names(span_exporter))
|
|
109
|
+
df = pd.DataFrame({"time": [f"t{i}" for i in range(6)], "close": range(6)})
|
|
110
|
+
asm = FeatureWindowAssembler(window=3, warmup=1, row_fn=lambda win: [float(win["close"].iloc[0])])
|
|
111
|
+
X, keys = asm.build(df, key_col="time")
|
|
112
|
+
assert len(keys) == 5
|
|
113
|
+
|
|
114
|
+
spans = span_exporter.get_finished_spans()[n_before:]
|
|
115
|
+
feat = [s for s in spans if s.name == "predcache.features"]
|
|
116
|
+
assert len(feat) == 1
|
|
117
|
+
attrs = dict(feat[0].attributes)
|
|
118
|
+
assert attrs["predcache.windows"] == 5
|
|
119
|
+
assert attrs["predcache.window"] == 3
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_env_kill_switch_disables_emission(span_exporter, metric_reader, tmp_path, monkeypatch):
|
|
123
|
+
monkeypatch.setenv("PREDCACHE_OTEL", "0")
|
|
124
|
+
try:
|
|
125
|
+
assert predcache.otel_available() is False
|
|
126
|
+
n_before = len(_names(span_exporter))
|
|
127
|
+
cache = PredictionCache(str(tmp_path / "c2.pkl"))
|
|
128
|
+
cache.get_or_compute(["x"], compute_fn=lambda missing: {k: 9 for k in missing})
|
|
129
|
+
assert len(_names(span_exporter)) == n_before
|
|
130
|
+
finally:
|
|
131
|
+
monkeypatch.delenv("PREDCACHE_OTEL", raising=False)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_package_works_without_otel(monkeypatch, tmp_path):
|
|
135
|
+
"""The no-op path: PREDCACHE_OTEL=0 must not change cache behavior."""
|
|
136
|
+
monkeypatch.setenv("PREDCACHE_OTEL", "0")
|
|
137
|
+
cache = PredictionCache(str(tmp_path / "c3.pkl"))
|
|
138
|
+
r1 = cache.get_or_compute(["k1"], compute_fn=lambda missing: {k: 42 for k in missing})
|
|
139
|
+
r2 = cache.get_or_compute(["k1"], compute_fn=lambda missing: {k: -1 for k in missing})
|
|
140
|
+
assert r1 == {"k1": 42}
|
|
141
|
+
assert r2 == {"k1": 42} # served from cache, compute_fn not called
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|