tensorimmune 0.1.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.
- tensorimmune-0.1.0/LICENSE +21 -0
- tensorimmune-0.1.0/PKG-INFO +129 -0
- tensorimmune-0.1.0/README.md +111 -0
- tensorimmune-0.1.0/pyproject.toml +31 -0
- tensorimmune-0.1.0/setup.cfg +4 -0
- tensorimmune-0.1.0/src/tensorimmune/__init__.py +3 -0
- tensorimmune-0.1.0/src/tensorimmune/autoencoder.py +25 -0
- tensorimmune-0.1.0/src/tensorimmune/calibration.py +23 -0
- tensorimmune-0.1.0/src/tensorimmune/model.py +215 -0
- tensorimmune-0.1.0/src/tensorimmune/shape_utils.py +22 -0
- tensorimmune-0.1.0/src/tensorimmune.egg-info/PKG-INFO +129 -0
- tensorimmune-0.1.0/src/tensorimmune.egg-info/SOURCES.txt +14 -0
- tensorimmune-0.1.0/src/tensorimmune.egg-info/dependency_links.txt +1 -0
- tensorimmune-0.1.0/src/tensorimmune.egg-info/requires.txt +5 -0
- tensorimmune-0.1.0/src/tensorimmune.egg-info/top_level.txt +1 -0
- tensorimmune-0.1.0/tests/test_model.py +131 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Developer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tensorimmune
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight Keras wrapper that injects Out-of-Distribution detection natively into a TensorFlow/Keras model's computational graph.
|
|
5
|
+
Author: Developer
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/prysthedev/TensorImmune
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/prysthedev/TensorImmune/issues
|
|
9
|
+
Project-URL: Source Code, https://github.com/prysthedev/TensorImmune
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: tensorflow>=2.10.0
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# TensorImmune
|
|
20
|
+
|
|
21
|
+
**TensorImmune** is a lightweight Keras wrapper that injects Out-of-Distribution (OOD) detection natively into a TensorFlow/Keras model's computational graph. A single exported artifact can flag anomalous inputs at inference time without any external monitoring infrastructure!
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
|
|
25
|
+
Deploying machine learning models to the real world involves dealing with unexpected or out-of-distribution data. Normally, OOD detection is handled by external monitoring infrastructure or complex multi-model setups. TensorImmune simplifies this by weaving a small, symbiotic autoencoder directly into your model's computational graph.
|
|
26
|
+
|
|
27
|
+
By observing an intermediate layer's activations (the "monitor layer"), TensorImmune simultaneously trains your primary task and the autoencoder. The model calibrates an anomaly threshold during training. When deployed, the model returns both the primary prediction and an immunity score (or anomaly flag), even when converted to edge formats like TensorFlow Lite!
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Install via pip:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install tensorimmune
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import tensorflow as tf
|
|
41
|
+
from tensorimmune import ImmuneModel
|
|
42
|
+
|
|
43
|
+
# 1. Define your base Keras model (Sequential or Functional)
|
|
44
|
+
base_model = tf.keras.Sequential([
|
|
45
|
+
tf.keras.layers.InputLayer(input_shape=(28, 28, 1)),
|
|
46
|
+
tf.keras.layers.Conv2D(32, 3, activation='relu'),
|
|
47
|
+
tf.keras.layers.MaxPooling2D(),
|
|
48
|
+
tf.keras.layers.Conv2D(64, 3, activation='relu'),
|
|
49
|
+
tf.keras.layers.GlobalAveragePooling2D(),
|
|
50
|
+
tf.keras.layers.Dense(64, activation='relu', name='feature_bottleneck'),
|
|
51
|
+
tf.keras.layers.Dense(10, activation='softmax')
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
# 2. Wrap it with ImmuneModel
|
|
55
|
+
# We monitor the 'feature_bottleneck' layer.
|
|
56
|
+
# sensitivity=0.95 means we set the anomaly threshold to the 95th percentile
|
|
57
|
+
# of the reconstruction errors seen during training.
|
|
58
|
+
model = ImmuneModel(
|
|
59
|
+
base_model=base_model,
|
|
60
|
+
monitor_layer='feature_bottleneck',
|
|
61
|
+
sensitivity=0.95
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# 3. Compile and train normally!
|
|
65
|
+
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])
|
|
66
|
+
model.fit(x_train, y_train, epochs=5)
|
|
67
|
+
|
|
68
|
+
# 4. Predict returns task predictions AND anomaly flags
|
|
69
|
+
predictions, is_anomaly = model.predict(x_test)
|
|
70
|
+
|
|
71
|
+
# You can also get the raw continuous immunity score
|
|
72
|
+
predictions, immunity_scores = model.predict(x_test, return_score=True)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## How Symbiotic Training Works
|
|
76
|
+
|
|
77
|
+
Under the hood, `ImmuneModel` creates a `feature_extractor` that splits the graph at the `monitor_layer`. It then dynamically builds a small autoencoder to reconstruct the activations of that layer.
|
|
78
|
+
|
|
79
|
+
By subclassing `tf.keras.Model` and overriding `train_step`, TensorImmune trains both the original task loss and the autoencoder's mean squared error (MSE) reconstruction loss simultaneously in a single backward pass. The losses are combined via the `immune_loss_weight` parameter (which defaults to `1.0`).
|
|
80
|
+
|
|
81
|
+
If the monitor layer outputs a spatial tensor (e.g. from a Conv2D layer), TensorImmune automatically inserts a GlobalAveragePooling layer before the autoencoder to flatten the representations intelligently.
|
|
82
|
+
|
|
83
|
+
## Immunity Score & Sensitivity Calibration
|
|
84
|
+
|
|
85
|
+
During training, TensorImmune maintains a running record of reconstruction error statistics (mean, variance, count) inside the model's graph.
|
|
86
|
+
|
|
87
|
+
The `sensitivity` parameter (between 0 and 1) dictates how strictly the model should flag anomalies. It is treated as a percentile of the training distribution. Using the running statistics and the inverse error function, TensorImmune continuously calibrates a numeric anomaly threshold (e.g. `sensitivity=0.95` maps to ~1.64 standard deviations above the mean).
|
|
88
|
+
|
|
89
|
+
At the end of training, this threshold is automatically stored as a non-trainable `tf.Variable` directly inside the model. When you save and export the model, the threshold is serialized with it!
|
|
90
|
+
|
|
91
|
+
## Edge Deployment with TFLite
|
|
92
|
+
|
|
93
|
+
Because TensorImmune builds its architecture and threshold logic using pure `tf.keras.layers` and `tf` operations, the resulting model can be converted seamlessly to TensorFlow Lite for edge devices.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import tensorflow as tf
|
|
97
|
+
|
|
98
|
+
# Get concrete function for TFLite
|
|
99
|
+
run_model = tf.function(lambda x: model(x, return_score=False))
|
|
100
|
+
concrete_func = run_model.get_concrete_function(
|
|
101
|
+
tf.TensorSpec([1, 28, 28, 1], tf.float32)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Convert! No custom ops required.
|
|
105
|
+
converter = tf.lite.TFLiteConverter.from_concrete_functions([concrete_func])
|
|
106
|
+
tflite_model = converter.convert()
|
|
107
|
+
|
|
108
|
+
with open("immune_model.tflite", "wb") as f:
|
|
109
|
+
f.write(tflite_model)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The exported TFLite model natively returns the boolean anomaly flag as its second output, making edge integration trivial.
|
|
113
|
+
|
|
114
|
+
## Limitations
|
|
115
|
+
|
|
116
|
+
It is important to state a known limitation: **autoencoder reconstruction error on intermediate features is generally a weaker OOD signal than more rigorous methods** (like Mahalanobis distance, Energy-based scoring, or evidential deep learning).
|
|
117
|
+
|
|
118
|
+
TensorImmune is intended as a **lightweight, drop-in first line of defense** for edge deployment where you cannot afford external monitoring infrastructure or complex multi-model pipelines. It should not be used as a replacement for rigorous safety-critical OOD research techniques.
|
|
119
|
+
|
|
120
|
+
## Contributing
|
|
121
|
+
|
|
122
|
+
Contributions are welcome! Please open an issue or submit a pull request if you have ideas for improvements, bug fixes, or new features.
|
|
123
|
+
|
|
124
|
+
When contributing:
|
|
125
|
+
1. Ensure your code passes all existing tests.
|
|
126
|
+
2. Add tests for new features.
|
|
127
|
+
3. Update the documentation as necessary.
|
|
128
|
+
|
|
129
|
+
License: MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# TensorImmune
|
|
2
|
+
|
|
3
|
+
**TensorImmune** is a lightweight Keras wrapper that injects Out-of-Distribution (OOD) detection natively into a TensorFlow/Keras model's computational graph. A single exported artifact can flag anomalous inputs at inference time without any external monitoring infrastructure!
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Deploying machine learning models to the real world involves dealing with unexpected or out-of-distribution data. Normally, OOD detection is handled by external monitoring infrastructure or complex multi-model setups. TensorImmune simplifies this by weaving a small, symbiotic autoencoder directly into your model's computational graph.
|
|
8
|
+
|
|
9
|
+
By observing an intermediate layer's activations (the "monitor layer"), TensorImmune simultaneously trains your primary task and the autoencoder. The model calibrates an anomaly threshold during training. When deployed, the model returns both the primary prediction and an immunity score (or anomaly flag), even when converted to edge formats like TensorFlow Lite!
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install via pip:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install tensorimmune
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import tensorflow as tf
|
|
23
|
+
from tensorimmune import ImmuneModel
|
|
24
|
+
|
|
25
|
+
# 1. Define your base Keras model (Sequential or Functional)
|
|
26
|
+
base_model = tf.keras.Sequential([
|
|
27
|
+
tf.keras.layers.InputLayer(input_shape=(28, 28, 1)),
|
|
28
|
+
tf.keras.layers.Conv2D(32, 3, activation='relu'),
|
|
29
|
+
tf.keras.layers.MaxPooling2D(),
|
|
30
|
+
tf.keras.layers.Conv2D(64, 3, activation='relu'),
|
|
31
|
+
tf.keras.layers.GlobalAveragePooling2D(),
|
|
32
|
+
tf.keras.layers.Dense(64, activation='relu', name='feature_bottleneck'),
|
|
33
|
+
tf.keras.layers.Dense(10, activation='softmax')
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
# 2. Wrap it with ImmuneModel
|
|
37
|
+
# We monitor the 'feature_bottleneck' layer.
|
|
38
|
+
# sensitivity=0.95 means we set the anomaly threshold to the 95th percentile
|
|
39
|
+
# of the reconstruction errors seen during training.
|
|
40
|
+
model = ImmuneModel(
|
|
41
|
+
base_model=base_model,
|
|
42
|
+
monitor_layer='feature_bottleneck',
|
|
43
|
+
sensitivity=0.95
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 3. Compile and train normally!
|
|
47
|
+
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])
|
|
48
|
+
model.fit(x_train, y_train, epochs=5)
|
|
49
|
+
|
|
50
|
+
# 4. Predict returns task predictions AND anomaly flags
|
|
51
|
+
predictions, is_anomaly = model.predict(x_test)
|
|
52
|
+
|
|
53
|
+
# You can also get the raw continuous immunity score
|
|
54
|
+
predictions, immunity_scores = model.predict(x_test, return_score=True)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## How Symbiotic Training Works
|
|
58
|
+
|
|
59
|
+
Under the hood, `ImmuneModel` creates a `feature_extractor` that splits the graph at the `monitor_layer`. It then dynamically builds a small autoencoder to reconstruct the activations of that layer.
|
|
60
|
+
|
|
61
|
+
By subclassing `tf.keras.Model` and overriding `train_step`, TensorImmune trains both the original task loss and the autoencoder's mean squared error (MSE) reconstruction loss simultaneously in a single backward pass. The losses are combined via the `immune_loss_weight` parameter (which defaults to `1.0`).
|
|
62
|
+
|
|
63
|
+
If the monitor layer outputs a spatial tensor (e.g. from a Conv2D layer), TensorImmune automatically inserts a GlobalAveragePooling layer before the autoencoder to flatten the representations intelligently.
|
|
64
|
+
|
|
65
|
+
## Immunity Score & Sensitivity Calibration
|
|
66
|
+
|
|
67
|
+
During training, TensorImmune maintains a running record of reconstruction error statistics (mean, variance, count) inside the model's graph.
|
|
68
|
+
|
|
69
|
+
The `sensitivity` parameter (between 0 and 1) dictates how strictly the model should flag anomalies. It is treated as a percentile of the training distribution. Using the running statistics and the inverse error function, TensorImmune continuously calibrates a numeric anomaly threshold (e.g. `sensitivity=0.95` maps to ~1.64 standard deviations above the mean).
|
|
70
|
+
|
|
71
|
+
At the end of training, this threshold is automatically stored as a non-trainable `tf.Variable` directly inside the model. When you save and export the model, the threshold is serialized with it!
|
|
72
|
+
|
|
73
|
+
## Edge Deployment with TFLite
|
|
74
|
+
|
|
75
|
+
Because TensorImmune builds its architecture and threshold logic using pure `tf.keras.layers` and `tf` operations, the resulting model can be converted seamlessly to TensorFlow Lite for edge devices.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import tensorflow as tf
|
|
79
|
+
|
|
80
|
+
# Get concrete function for TFLite
|
|
81
|
+
run_model = tf.function(lambda x: model(x, return_score=False))
|
|
82
|
+
concrete_func = run_model.get_concrete_function(
|
|
83
|
+
tf.TensorSpec([1, 28, 28, 1], tf.float32)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Convert! No custom ops required.
|
|
87
|
+
converter = tf.lite.TFLiteConverter.from_concrete_functions([concrete_func])
|
|
88
|
+
tflite_model = converter.convert()
|
|
89
|
+
|
|
90
|
+
with open("immune_model.tflite", "wb") as f:
|
|
91
|
+
f.write(tflite_model)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The exported TFLite model natively returns the boolean anomaly flag as its second output, making edge integration trivial.
|
|
95
|
+
|
|
96
|
+
## Limitations
|
|
97
|
+
|
|
98
|
+
It is important to state a known limitation: **autoencoder reconstruction error on intermediate features is generally a weaker OOD signal than more rigorous methods** (like Mahalanobis distance, Energy-based scoring, or evidential deep learning).
|
|
99
|
+
|
|
100
|
+
TensorImmune is intended as a **lightweight, drop-in first line of defense** for edge deployment where you cannot afford external monitoring infrastructure or complex multi-model pipelines. It should not be used as a replacement for rigorous safety-critical OOD research techniques.
|
|
101
|
+
|
|
102
|
+
## Contributing
|
|
103
|
+
|
|
104
|
+
Contributions are welcome! Please open an issue or submit a pull request if you have ideas for improvements, bug fixes, or new features.
|
|
105
|
+
|
|
106
|
+
When contributing:
|
|
107
|
+
1. Ensure your code passes all existing tests.
|
|
108
|
+
2. Add tests for new features.
|
|
109
|
+
3. Update the documentation as necessary.
|
|
110
|
+
|
|
111
|
+
License: MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tensorimmune"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A lightweight Keras wrapper that injects Out-of-Distribution detection natively into a TensorFlow/Keras model's computational graph."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Developer" }
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"tensorflow>=2.10.0",
|
|
17
|
+
"numpy"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
"Homepage" = "https://github.com/prysthedev/TensorImmune"
|
|
30
|
+
"Bug Tracker" = "https://github.com/prysthedev/TensorImmune/issues"
|
|
31
|
+
"Source Code" = "https://github.com/prysthedev/TensorImmune"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import tensorflow as tf
|
|
2
|
+
|
|
3
|
+
def build_autoencoder(input_dim, bottleneck_size=None):
|
|
4
|
+
"""
|
|
5
|
+
Builds a small symbiotic autoencoder to reconstruct layer activations.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
input_dim (int): The dimension of the flattened activation vector.
|
|
9
|
+
bottleneck_size (int, optional): The size of the bottleneck layer. Defaults to roughly input_dim // 4.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
tf.keras.Model: The autoencoder model.
|
|
13
|
+
"""
|
|
14
|
+
if bottleneck_size is None:
|
|
15
|
+
bottleneck_size = max(1, input_dim // 4)
|
|
16
|
+
|
|
17
|
+
# Use standard Keras API to build the autoencoder
|
|
18
|
+
inputs = tf.keras.Input(shape=(input_dim,))
|
|
19
|
+
x = tf.keras.layers.Dense(max(bottleneck_size * 2, 2), activation='relu')(inputs)
|
|
20
|
+
x = tf.keras.layers.Dense(bottleneck_size, activation='relu', name='bottleneck')(x)
|
|
21
|
+
x = tf.keras.layers.Dense(max(bottleneck_size * 2, 2), activation='relu')(x)
|
|
22
|
+
outputs = tf.keras.layers.Dense(input_dim, activation='linear')(x)
|
|
23
|
+
|
|
24
|
+
model = tf.keras.Model(inputs=inputs, outputs=outputs, name='symbiotic_autoencoder')
|
|
25
|
+
return model
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import tensorflow as tf
|
|
2
|
+
|
|
3
|
+
def get_threshold_from_stats(mean, variance, sensitivity):
|
|
4
|
+
"""
|
|
5
|
+
Calculates the numeric anomaly threshold based on running mean and variance and sensitivity.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
mean (tf.Tensor): EMA of reconstruction error.
|
|
9
|
+
variance (tf.Tensor): EMA of variance of reconstruction error.
|
|
10
|
+
sensitivity (float or tf.Tensor): Percentile sensitivity (0 to 1).
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
tf.Tensor: The calibrated threshold.
|
|
14
|
+
"""
|
|
15
|
+
std = tf.sqrt(tf.maximum(variance, 0.0))
|
|
16
|
+
|
|
17
|
+
# Map sensitivity (percentile) to number of standard deviations for a normal distribution
|
|
18
|
+
p = tf.cast(sensitivity, tf.float32)
|
|
19
|
+
p = tf.clip_by_value(p, 0.0001, 0.9999)
|
|
20
|
+
z = tf.math.sqrt(2.0) * tf.math.erfinv(2.0 * p - 1.0)
|
|
21
|
+
|
|
22
|
+
threshold = mean + z * std
|
|
23
|
+
return threshold
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import tensorflow as tf
|
|
2
|
+
from .shape_utils import get_pooling_layer
|
|
3
|
+
from .autoencoder import build_autoencoder
|
|
4
|
+
from .calibration import get_threshold_from_stats
|
|
5
|
+
|
|
6
|
+
@tf.keras.utils.register_keras_serializable(package="TensorImmune")
|
|
7
|
+
class ImmuneModel(tf.keras.Model):
|
|
8
|
+
"""
|
|
9
|
+
A Keras wrapper that injects Out-of-Distribution detection natively into a
|
|
10
|
+
TensorFlow/Keras model's computational graph.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, base_model, monitor_layer, sensitivity=0.95, bottleneck_size=None, immune_loss_weight=1.0, **kwargs):
|
|
13
|
+
super(ImmuneModel, self).__init__(**kwargs)
|
|
14
|
+
|
|
15
|
+
# Validate base_model
|
|
16
|
+
if not hasattr(base_model, "inputs") or not base_model.inputs:
|
|
17
|
+
raise ValueError("The base model must be a Keras Sequential or Functional model. Subclassed models without explicitly defined inputs are not supported because their computational graph cannot be introspected.")
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
target_layer = base_model.get_layer(monitor_layer)
|
|
21
|
+
except ValueError:
|
|
22
|
+
available_layers = [l.name for l in base_model.layers]
|
|
23
|
+
raise ValueError(f"Monitor layer '{monitor_layer}' not found in the base model. Available layers: {available_layers}")
|
|
24
|
+
|
|
25
|
+
self.base_model_ref = base_model
|
|
26
|
+
self.monitor_layer = monitor_layer
|
|
27
|
+
self.sensitivity = float(sensitivity)
|
|
28
|
+
self.bottleneck_size = bottleneck_size
|
|
29
|
+
self.immune_loss_weight = float(immune_loss_weight)
|
|
30
|
+
|
|
31
|
+
# Safely get base model outputs
|
|
32
|
+
base_outputs = base_model.outputs if hasattr(base_model, 'outputs') and base_model.outputs else [base_model.output]
|
|
33
|
+
self.base_output_count = len(base_outputs)
|
|
34
|
+
|
|
35
|
+
# Build feature extractor capturing both task output(s) and monitor layer
|
|
36
|
+
self.feature_extractor = tf.keras.Model(
|
|
37
|
+
inputs=base_model.inputs,
|
|
38
|
+
outputs=base_outputs + [target_layer.output],
|
|
39
|
+
name="feature_extractor"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Determine pool layer based on shape
|
|
43
|
+
monitor_shape = target_layer.output.shape
|
|
44
|
+
rank = len(monitor_shape)
|
|
45
|
+
self.pool_layer = get_pooling_layer(rank)
|
|
46
|
+
|
|
47
|
+
# Calculate flattened dimension
|
|
48
|
+
dummy_input = tf.keras.Input(shape=monitor_shape[1:])
|
|
49
|
+
pooled_dummy = self.pool_layer(dummy_input)
|
|
50
|
+
flat_dim = pooled_dummy.shape[-1]
|
|
51
|
+
|
|
52
|
+
# Build symbiotic autoencoder
|
|
53
|
+
self.autoencoder = build_autoencoder(flat_dim, bottleneck_size)
|
|
54
|
+
|
|
55
|
+
# EMA Statistics variables for threshold calibration
|
|
56
|
+
self.calib_mean = self.add_weight(name='calib_mean', shape=(), initializer='zeros', trainable=False, dtype=tf.float32)
|
|
57
|
+
self.calib_var = self.add_weight(name='calib_var', shape=(), initializer='zeros', trainable=False, dtype=tf.float32)
|
|
58
|
+
self.calib_count = self.add_weight(name='calib_count', shape=(), initializer='zeros', trainable=False, dtype=tf.float32)
|
|
59
|
+
|
|
60
|
+
# Calibrated anomaly threshold (serialized with model)
|
|
61
|
+
self.anomaly_threshold = self.add_weight(name='anomaly_threshold', shape=(), initializer='zeros', trainable=False, dtype=tf.float32)
|
|
62
|
+
|
|
63
|
+
def _update_calibration_stats(self, batch_mse):
|
|
64
|
+
batch_mean = tf.reduce_mean(batch_mse)
|
|
65
|
+
# Prevent variance calculation errors if batch size is 1
|
|
66
|
+
batch_size = tf.cast(tf.shape(batch_mse)[0], tf.float32)
|
|
67
|
+
batch_var = tf.where(batch_size > 1.0, tf.math.reduce_variance(batch_mse), 0.0)
|
|
68
|
+
|
|
69
|
+
momentum = tf.constant(0.99, dtype=tf.float32)
|
|
70
|
+
|
|
71
|
+
is_first = tf.equal(self.calib_count, 0.0)
|
|
72
|
+
|
|
73
|
+
def first_step():
|
|
74
|
+
self.calib_mean.assign(batch_mean)
|
|
75
|
+
self.calib_var.assign(batch_var)
|
|
76
|
+
return tf.constant(0.0)
|
|
77
|
+
|
|
78
|
+
def next_steps():
|
|
79
|
+
delta = batch_mean - self.calib_mean
|
|
80
|
+
new_mean = self.calib_mean + (1.0 - momentum) * delta
|
|
81
|
+
new_var = momentum * self.calib_var + (1.0 - momentum) * batch_var + momentum * (1.0 - momentum) * tf.square(delta)
|
|
82
|
+
self.calib_mean.assign(new_mean)
|
|
83
|
+
self.calib_var.assign(new_var)
|
|
84
|
+
return tf.constant(0.0)
|
|
85
|
+
|
|
86
|
+
tf.cond(is_first, first_step, next_steps)
|
|
87
|
+
self.calib_count.assign_add(1.0)
|
|
88
|
+
|
|
89
|
+
new_threshold = get_threshold_from_stats(self.calib_mean, self.calib_var, self.sensitivity)
|
|
90
|
+
self.anomaly_threshold.assign(new_threshold)
|
|
91
|
+
|
|
92
|
+
@tf.function
|
|
93
|
+
def call(self, inputs, training=False, return_score=False):
|
|
94
|
+
extractor_outputs = self.feature_extractor(inputs, training=training)
|
|
95
|
+
|
|
96
|
+
task_output = extractor_outputs[:-1]
|
|
97
|
+
if self.base_output_count == 1:
|
|
98
|
+
task_output = task_output[0]
|
|
99
|
+
|
|
100
|
+
monitor_acts = extractor_outputs[-1]
|
|
101
|
+
|
|
102
|
+
pooled_acts = self.pool_layer(monitor_acts)
|
|
103
|
+
reconstructed = self.autoencoder(pooled_acts, training=training)
|
|
104
|
+
|
|
105
|
+
mse = tf.reduce_mean(tf.square(pooled_acts - reconstructed), axis=-1)
|
|
106
|
+
|
|
107
|
+
if return_score:
|
|
108
|
+
return task_output, mse
|
|
109
|
+
else:
|
|
110
|
+
return task_output, mse > self.anomaly_threshold
|
|
111
|
+
|
|
112
|
+
def train_step(self, data):
|
|
113
|
+
if len(data) == 3:
|
|
114
|
+
x, y, sample_weight = data
|
|
115
|
+
else:
|
|
116
|
+
x, y = data
|
|
117
|
+
sample_weight = None
|
|
118
|
+
|
|
119
|
+
with tf.GradientTape() as tape:
|
|
120
|
+
extractor_outputs = self.feature_extractor(x, training=True)
|
|
121
|
+
task_output = extractor_outputs[:-1]
|
|
122
|
+
if self.base_output_count == 1:
|
|
123
|
+
task_output = task_output[0]
|
|
124
|
+
monitor_acts = extractor_outputs[-1]
|
|
125
|
+
|
|
126
|
+
pooled_acts = self.pool_layer(monitor_acts)
|
|
127
|
+
reconstructed = self.autoencoder(pooled_acts, training=True)
|
|
128
|
+
|
|
129
|
+
if hasattr(self, "compute_loss"):
|
|
130
|
+
loss = self.compute_loss(x=x, y=y, y_pred=task_output, sample_weight=sample_weight)
|
|
131
|
+
else:
|
|
132
|
+
loss = self.compiled_loss(y, task_output, sample_weight=sample_weight, regularization_losses=self.losses)
|
|
133
|
+
|
|
134
|
+
batch_mse = tf.reduce_mean(tf.square(pooled_acts - reconstructed), axis=-1)
|
|
135
|
+
ae_loss = tf.reduce_mean(batch_mse)
|
|
136
|
+
|
|
137
|
+
total_loss = loss + self.immune_loss_weight * ae_loss
|
|
138
|
+
|
|
139
|
+
trainable_vars = self.trainable_variables
|
|
140
|
+
gradients = tape.gradient(total_loss, trainable_vars)
|
|
141
|
+
self.optimizer.apply_gradients(zip(gradients, trainable_vars))
|
|
142
|
+
|
|
143
|
+
self._update_calibration_stats(batch_mse)
|
|
144
|
+
|
|
145
|
+
if hasattr(self, "compute_metrics"):
|
|
146
|
+
self.compute_metrics(x=x, y=y, y_pred=task_output, sample_weight=sample_weight)
|
|
147
|
+
results = {m.name: m.result() for m in self.metrics}
|
|
148
|
+
else:
|
|
149
|
+
self.compiled_metrics.update_state(y, task_output, sample_weight=sample_weight)
|
|
150
|
+
results = {m.name: m.result() for m in self.metrics}
|
|
151
|
+
|
|
152
|
+
results["ae_loss"] = ae_loss
|
|
153
|
+
results["threshold"] = self.anomaly_threshold
|
|
154
|
+
return results
|
|
155
|
+
|
|
156
|
+
def predict_step(self, data):
|
|
157
|
+
if isinstance(data, tuple):
|
|
158
|
+
x = data[0]
|
|
159
|
+
else:
|
|
160
|
+
x = data
|
|
161
|
+
return self(x, training=False, return_score=True)
|
|
162
|
+
|
|
163
|
+
def predict(self, x, *args, **kwargs):
|
|
164
|
+
return_score = kwargs.pop('return_score', False)
|
|
165
|
+
outputs = super().predict(x, *args, **kwargs)
|
|
166
|
+
|
|
167
|
+
# Keras predict unrolls outputs. The last element is always the MSE score.
|
|
168
|
+
if isinstance(outputs, list) and len(outputs) > 1:
|
|
169
|
+
scores = outputs[-1]
|
|
170
|
+
task_preds = outputs[:-1]
|
|
171
|
+
if len(task_preds) == 1:
|
|
172
|
+
task_preds = task_preds[0]
|
|
173
|
+
else:
|
|
174
|
+
# Fallback if outputs is somehow not unrolled
|
|
175
|
+
task_preds, scores = outputs[0], outputs[1]
|
|
176
|
+
|
|
177
|
+
if return_score:
|
|
178
|
+
return task_preds, scores
|
|
179
|
+
else:
|
|
180
|
+
flags = scores > self.anomaly_threshold.numpy()
|
|
181
|
+
return task_preds, flags
|
|
182
|
+
|
|
183
|
+
def get_config(self):
|
|
184
|
+
config = super().get_config()
|
|
185
|
+
try:
|
|
186
|
+
base_model_config = tf.keras.utils.serialize_keras_object(self.base_model_ref)
|
|
187
|
+
except AttributeError:
|
|
188
|
+
if hasattr(tf.keras.saving, 'serialize_keras_object'):
|
|
189
|
+
base_model_config = tf.keras.saving.serialize_keras_object(self.base_model_ref)
|
|
190
|
+
else:
|
|
191
|
+
base_model_config = self.base_model_ref.get_config()
|
|
192
|
+
|
|
193
|
+
config.update({
|
|
194
|
+
"base_model": base_model_config,
|
|
195
|
+
"monitor_layer": self.monitor_layer,
|
|
196
|
+
"sensitivity": self.sensitivity,
|
|
197
|
+
"bottleneck_size": self.bottleneck_size,
|
|
198
|
+
"immune_loss_weight": self.immune_loss_weight,
|
|
199
|
+
})
|
|
200
|
+
return config
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def from_config(cls, config, custom_objects=None):
|
|
204
|
+
base_model_config = config.pop("base_model")
|
|
205
|
+
try:
|
|
206
|
+
base_model = tf.keras.utils.deserialize_keras_object(base_model_config, custom_objects=custom_objects)
|
|
207
|
+
except AttributeError:
|
|
208
|
+
if hasattr(tf.keras.saving, 'deserialize_keras_object'):
|
|
209
|
+
base_model = tf.keras.saving.deserialize_keras_object(base_model_config, custom_objects=custom_objects)
|
|
210
|
+
else:
|
|
211
|
+
if "layers" in base_model_config:
|
|
212
|
+
base_model = tf.keras.Sequential.from_config(base_model_config, custom_objects=custom_objects)
|
|
213
|
+
else:
|
|
214
|
+
base_model = tf.keras.Model.from_config(base_model_config, custom_objects=custom_objects)
|
|
215
|
+
return cls(base_model=base_model, **config)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import tensorflow as tf
|
|
2
|
+
|
|
3
|
+
def get_pooling_layer(rank):
|
|
4
|
+
"""
|
|
5
|
+
Returns the appropriate pooling or flattening layer based on the input tensor's rank.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
rank (int): The rank (number of dimensions) of the tensor.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
tf.keras.layers.Layer: The pooling or flatten layer.
|
|
12
|
+
"""
|
|
13
|
+
if rank == 2:
|
|
14
|
+
return tf.keras.layers.Flatten()
|
|
15
|
+
elif rank == 3:
|
|
16
|
+
return tf.keras.layers.GlobalAveragePooling1D()
|
|
17
|
+
elif rank == 4:
|
|
18
|
+
return tf.keras.layers.GlobalAveragePooling2D()
|
|
19
|
+
elif rank == 5:
|
|
20
|
+
return tf.keras.layers.GlobalAveragePooling3D()
|
|
21
|
+
else:
|
|
22
|
+
return tf.keras.layers.Flatten()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tensorimmune
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight Keras wrapper that injects Out-of-Distribution detection natively into a TensorFlow/Keras model's computational graph.
|
|
5
|
+
Author: Developer
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/prysthedev/TensorImmune
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/prysthedev/TensorImmune/issues
|
|
9
|
+
Project-URL: Source Code, https://github.com/prysthedev/TensorImmune
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: tensorflow>=2.10.0
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# TensorImmune
|
|
20
|
+
|
|
21
|
+
**TensorImmune** is a lightweight Keras wrapper that injects Out-of-Distribution (OOD) detection natively into a TensorFlow/Keras model's computational graph. A single exported artifact can flag anomalous inputs at inference time without any external monitoring infrastructure!
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
|
|
25
|
+
Deploying machine learning models to the real world involves dealing with unexpected or out-of-distribution data. Normally, OOD detection is handled by external monitoring infrastructure or complex multi-model setups. TensorImmune simplifies this by weaving a small, symbiotic autoencoder directly into your model's computational graph.
|
|
26
|
+
|
|
27
|
+
By observing an intermediate layer's activations (the "monitor layer"), TensorImmune simultaneously trains your primary task and the autoencoder. The model calibrates an anomaly threshold during training. When deployed, the model returns both the primary prediction and an immunity score (or anomaly flag), even when converted to edge formats like TensorFlow Lite!
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Install via pip:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install tensorimmune
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import tensorflow as tf
|
|
41
|
+
from tensorimmune import ImmuneModel
|
|
42
|
+
|
|
43
|
+
# 1. Define your base Keras model (Sequential or Functional)
|
|
44
|
+
base_model = tf.keras.Sequential([
|
|
45
|
+
tf.keras.layers.InputLayer(input_shape=(28, 28, 1)),
|
|
46
|
+
tf.keras.layers.Conv2D(32, 3, activation='relu'),
|
|
47
|
+
tf.keras.layers.MaxPooling2D(),
|
|
48
|
+
tf.keras.layers.Conv2D(64, 3, activation='relu'),
|
|
49
|
+
tf.keras.layers.GlobalAveragePooling2D(),
|
|
50
|
+
tf.keras.layers.Dense(64, activation='relu', name='feature_bottleneck'),
|
|
51
|
+
tf.keras.layers.Dense(10, activation='softmax')
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
# 2. Wrap it with ImmuneModel
|
|
55
|
+
# We monitor the 'feature_bottleneck' layer.
|
|
56
|
+
# sensitivity=0.95 means we set the anomaly threshold to the 95th percentile
|
|
57
|
+
# of the reconstruction errors seen during training.
|
|
58
|
+
model = ImmuneModel(
|
|
59
|
+
base_model=base_model,
|
|
60
|
+
monitor_layer='feature_bottleneck',
|
|
61
|
+
sensitivity=0.95
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# 3. Compile and train normally!
|
|
65
|
+
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])
|
|
66
|
+
model.fit(x_train, y_train, epochs=5)
|
|
67
|
+
|
|
68
|
+
# 4. Predict returns task predictions AND anomaly flags
|
|
69
|
+
predictions, is_anomaly = model.predict(x_test)
|
|
70
|
+
|
|
71
|
+
# You can also get the raw continuous immunity score
|
|
72
|
+
predictions, immunity_scores = model.predict(x_test, return_score=True)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## How Symbiotic Training Works
|
|
76
|
+
|
|
77
|
+
Under the hood, `ImmuneModel` creates a `feature_extractor` that splits the graph at the `monitor_layer`. It then dynamically builds a small autoencoder to reconstruct the activations of that layer.
|
|
78
|
+
|
|
79
|
+
By subclassing `tf.keras.Model` and overriding `train_step`, TensorImmune trains both the original task loss and the autoencoder's mean squared error (MSE) reconstruction loss simultaneously in a single backward pass. The losses are combined via the `immune_loss_weight` parameter (which defaults to `1.0`).
|
|
80
|
+
|
|
81
|
+
If the monitor layer outputs a spatial tensor (e.g. from a Conv2D layer), TensorImmune automatically inserts a GlobalAveragePooling layer before the autoencoder to flatten the representations intelligently.
|
|
82
|
+
|
|
83
|
+
## Immunity Score & Sensitivity Calibration
|
|
84
|
+
|
|
85
|
+
During training, TensorImmune maintains a running record of reconstruction error statistics (mean, variance, count) inside the model's graph.
|
|
86
|
+
|
|
87
|
+
The `sensitivity` parameter (between 0 and 1) dictates how strictly the model should flag anomalies. It is treated as a percentile of the training distribution. Using the running statistics and the inverse error function, TensorImmune continuously calibrates a numeric anomaly threshold (e.g. `sensitivity=0.95` maps to ~1.64 standard deviations above the mean).
|
|
88
|
+
|
|
89
|
+
At the end of training, this threshold is automatically stored as a non-trainable `tf.Variable` directly inside the model. When you save and export the model, the threshold is serialized with it!
|
|
90
|
+
|
|
91
|
+
## Edge Deployment with TFLite
|
|
92
|
+
|
|
93
|
+
Because TensorImmune builds its architecture and threshold logic using pure `tf.keras.layers` and `tf` operations, the resulting model can be converted seamlessly to TensorFlow Lite for edge devices.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import tensorflow as tf
|
|
97
|
+
|
|
98
|
+
# Get concrete function for TFLite
|
|
99
|
+
run_model = tf.function(lambda x: model(x, return_score=False))
|
|
100
|
+
concrete_func = run_model.get_concrete_function(
|
|
101
|
+
tf.TensorSpec([1, 28, 28, 1], tf.float32)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Convert! No custom ops required.
|
|
105
|
+
converter = tf.lite.TFLiteConverter.from_concrete_functions([concrete_func])
|
|
106
|
+
tflite_model = converter.convert()
|
|
107
|
+
|
|
108
|
+
with open("immune_model.tflite", "wb") as f:
|
|
109
|
+
f.write(tflite_model)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The exported TFLite model natively returns the boolean anomaly flag as its second output, making edge integration trivial.
|
|
113
|
+
|
|
114
|
+
## Limitations
|
|
115
|
+
|
|
116
|
+
It is important to state a known limitation: **autoencoder reconstruction error on intermediate features is generally a weaker OOD signal than more rigorous methods** (like Mahalanobis distance, Energy-based scoring, or evidential deep learning).
|
|
117
|
+
|
|
118
|
+
TensorImmune is intended as a **lightweight, drop-in first line of defense** for edge deployment where you cannot afford external monitoring infrastructure or complex multi-model pipelines. It should not be used as a replacement for rigorous safety-critical OOD research techniques.
|
|
119
|
+
|
|
120
|
+
## Contributing
|
|
121
|
+
|
|
122
|
+
Contributions are welcome! Please open an issue or submit a pull request if you have ideas for improvements, bug fixes, or new features.
|
|
123
|
+
|
|
124
|
+
When contributing:
|
|
125
|
+
1. Ensure your code passes all existing tests.
|
|
126
|
+
2. Add tests for new features.
|
|
127
|
+
3. Update the documentation as necessary.
|
|
128
|
+
|
|
129
|
+
License: MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/tensorimmune/__init__.py
|
|
5
|
+
src/tensorimmune/autoencoder.py
|
|
6
|
+
src/tensorimmune/calibration.py
|
|
7
|
+
src/tensorimmune/model.py
|
|
8
|
+
src/tensorimmune/shape_utils.py
|
|
9
|
+
src/tensorimmune.egg-info/PKG-INFO
|
|
10
|
+
src/tensorimmune.egg-info/SOURCES.txt
|
|
11
|
+
src/tensorimmune.egg-info/dependency_links.txt
|
|
12
|
+
src/tensorimmune.egg-info/requires.txt
|
|
13
|
+
src/tensorimmune.egg-info/top_level.txt
|
|
14
|
+
tests/test_model.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tensorimmune
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import numpy as np
|
|
3
|
+
import tensorflow as tf
|
|
4
|
+
from tensorimmune import ImmuneModel
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
def create_sequential_base_model():
|
|
9
|
+
model = tf.keras.Sequential([
|
|
10
|
+
tf.keras.layers.InputLayer(input_shape=(10,)),
|
|
11
|
+
tf.keras.layers.Dense(16, activation='relu', name='feature_bottleneck'),
|
|
12
|
+
tf.keras.layers.Dense(2, activation='softmax', name='output')
|
|
13
|
+
])
|
|
14
|
+
return model
|
|
15
|
+
|
|
16
|
+
def create_functional_base_model():
|
|
17
|
+
inputs = tf.keras.Input(shape=(10,))
|
|
18
|
+
x = tf.keras.layers.Dense(16, activation='relu', name='feature_bottleneck')(inputs)
|
|
19
|
+
outputs = tf.keras.layers.Dense(2, activation='softmax', name='output')(x)
|
|
20
|
+
return tf.keras.Model(inputs=inputs, outputs=outputs)
|
|
21
|
+
|
|
22
|
+
def test_build_from_sequential():
|
|
23
|
+
base_model = create_sequential_base_model()
|
|
24
|
+
immune_model = ImmuneModel(base_model, monitor_layer='feature_bottleneck', sensitivity=0.95)
|
|
25
|
+
assert immune_model.monitor_layer == 'feature_bottleneck'
|
|
26
|
+
assert immune_model.anomaly_threshold is not None
|
|
27
|
+
|
|
28
|
+
def test_build_from_functional():
|
|
29
|
+
base_model = create_functional_base_model()
|
|
30
|
+
immune_model = ImmuneModel(base_model, monitor_layer='feature_bottleneck', sensitivity=0.95)
|
|
31
|
+
assert immune_model.monitor_layer == 'feature_bottleneck'
|
|
32
|
+
|
|
33
|
+
def test_reject_invalid_layer():
|
|
34
|
+
base_model = create_sequential_base_model()
|
|
35
|
+
with pytest.raises(ValueError, match="not found in the base model"):
|
|
36
|
+
ImmuneModel(base_model, monitor_layer='nonexistent_layer')
|
|
37
|
+
|
|
38
|
+
def test_training_and_prediction():
|
|
39
|
+
base_model = create_sequential_base_model()
|
|
40
|
+
immune_model = ImmuneModel(base_model, monitor_layer='feature_bottleneck', sensitivity=0.95)
|
|
41
|
+
|
|
42
|
+
immune_model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])
|
|
43
|
+
|
|
44
|
+
# Synthetic data (in-distribution)
|
|
45
|
+
x_train = np.random.normal(0, 1, size=(100, 10)).astype(np.float32)
|
|
46
|
+
y_train = np.random.randint(0, 2, size=(100,)).astype(np.float32)
|
|
47
|
+
|
|
48
|
+
immune_model.fit(x_train, y_train, epochs=2, batch_size=16, verbose=0)
|
|
49
|
+
|
|
50
|
+
# Test predict shapes and types
|
|
51
|
+
preds, flags = immune_model.predict(x_train[:5], return_score=False)
|
|
52
|
+
assert preds.shape == (5, 2)
|
|
53
|
+
assert flags.shape == (5,)
|
|
54
|
+
assert flags.dtype == bool
|
|
55
|
+
|
|
56
|
+
preds, scores = immune_model.predict(x_train[:5], return_score=True)
|
|
57
|
+
assert scores.shape == (5,)
|
|
58
|
+
assert scores.dtype == np.float32
|
|
59
|
+
|
|
60
|
+
# Check threshold is a float variable
|
|
61
|
+
threshold = immune_model.anomaly_threshold.numpy()
|
|
62
|
+
assert isinstance(threshold, (float, np.float32))
|
|
63
|
+
assert threshold >= 0.0
|
|
64
|
+
|
|
65
|
+
def test_ood_detection():
|
|
66
|
+
base_model = create_sequential_base_model()
|
|
67
|
+
immune_model = ImmuneModel(base_model, monitor_layer='feature_bottleneck', sensitivity=0.5)
|
|
68
|
+
|
|
69
|
+
immune_model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')
|
|
70
|
+
|
|
71
|
+
# Train on specific distribution
|
|
72
|
+
x_train = np.random.normal(0, 1, size=(500, 10)).astype(np.float32)
|
|
73
|
+
y_train = np.random.randint(0, 2, size=(500,)).astype(np.float32)
|
|
74
|
+
|
|
75
|
+
immune_model.fit(x_train, y_train, epochs=5, batch_size=32, verbose=0)
|
|
76
|
+
|
|
77
|
+
# OOD data (different distribution)
|
|
78
|
+
x_ood = np.random.normal(10, 5, size=(100, 10)).astype(np.float32)
|
|
79
|
+
|
|
80
|
+
_, scores_id = immune_model.predict(x_train[:100], return_score=True)
|
|
81
|
+
_, scores_ood = immune_model.predict(x_ood, return_score=True)
|
|
82
|
+
|
|
83
|
+
assert np.mean(scores_ood) > np.mean(scores_id)
|
|
84
|
+
|
|
85
|
+
def test_save_and_load():
|
|
86
|
+
base_model = create_functional_base_model()
|
|
87
|
+
immune_model = ImmuneModel(base_model, monitor_layer='feature_bottleneck', sensitivity=0.95)
|
|
88
|
+
immune_model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')
|
|
89
|
+
|
|
90
|
+
x_train = np.random.normal(0, 1, size=(50, 10)).astype(np.float32)
|
|
91
|
+
y_train = np.random.randint(0, 2, size=(50,)).astype(np.float32)
|
|
92
|
+
immune_model.fit(x_train, y_train, epochs=1, verbose=0)
|
|
93
|
+
|
|
94
|
+
preds_orig, scores_orig = immune_model.predict(x_train[:5], return_score=True)
|
|
95
|
+
|
|
96
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
97
|
+
save_path = os.path.join(tmpdir, "model.keras")
|
|
98
|
+
# Save model
|
|
99
|
+
immune_model.save(save_path)
|
|
100
|
+
|
|
101
|
+
# Load model
|
|
102
|
+
# We need custom_objects to load ImmuneModel
|
|
103
|
+
custom_objects = {"ImmuneModel": ImmuneModel}
|
|
104
|
+
loaded_model = tf.keras.models.load_model(save_path, custom_objects=custom_objects)
|
|
105
|
+
|
|
106
|
+
preds_loaded, scores_loaded = loaded_model.predict(x_train[:5], return_score=True)
|
|
107
|
+
|
|
108
|
+
np.testing.assert_allclose(preds_orig, preds_loaded, rtol=1e-5)
|
|
109
|
+
np.testing.assert_allclose(scores_orig, scores_loaded, rtol=1e-5)
|
|
110
|
+
assert loaded_model.anomaly_threshold.numpy() == immune_model.anomaly_threshold.numpy()
|
|
111
|
+
|
|
112
|
+
def test_tflite_conversion():
|
|
113
|
+
base_model = create_sequential_base_model()
|
|
114
|
+
immune_model = ImmuneModel(base_model, monitor_layer='feature_bottleneck', sensitivity=0.95)
|
|
115
|
+
immune_model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')
|
|
116
|
+
|
|
117
|
+
x_train = np.random.normal(0, 1, size=(10, 10)).astype(np.float32)
|
|
118
|
+
y_train = np.random.randint(0, 2, size=(10,)).astype(np.float32)
|
|
119
|
+
immune_model.fit(x_train, y_train, epochs=1, verbose=0)
|
|
120
|
+
|
|
121
|
+
# Get concrete function for TFLite
|
|
122
|
+
run_model = tf.function(lambda x: immune_model(x, return_score=False))
|
|
123
|
+
concrete_func = run_model.get_concrete_function(
|
|
124
|
+
tf.TensorSpec(immune_model.base_model_ref.inputs[0].shape, immune_model.base_model_ref.inputs[0].dtype)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
converter = tf.lite.TFLiteConverter.from_concrete_functions([concrete_func])
|
|
128
|
+
tflite_model = converter.convert()
|
|
129
|
+
|
|
130
|
+
assert tflite_model is not None
|
|
131
|
+
assert len(tflite_model) > 0
|