-
Notifications
You must be signed in to change notification settings - Fork 12
Add Python 3.12 support with Keras 3 migration #522
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8c4a08f
2ad4664
7e3f0d2
98936ed
908501b
1d21e13
c404033
165dff5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,3 +55,6 @@ build/ | |
| dist/ | ||
| *.egg-info/ | ||
| *.egg | ||
|
|
||
| # Syngen test data | ||
| test_data/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # syntax=docker/dockerfile:1 | ||
| # Dockerfile for Python 3.12 with Keras 3 support | ||
| # This is an experimental build for Python 3.12 compatibility. | ||
| # For production use with Python 3.11, use the standard Dockerfile. | ||
|
|
||
| FROM python:3.12-bookworm | ||
|
|
||
| WORKDIR /src | ||
|
|
||
| COPY requirements.txt . | ||
| COPY requirements-streamlit.txt . | ||
|
|
||
| RUN apt-get update && \ | ||
| apt-get install -y --no-install-recommends git build-essential python3.12-dev && \ | ||
| apt-get clean && \ | ||
| rm -rf /var/lib/apt/lists/* && \ | ||
| pip install --no-cache-dir --upgrade pip setuptools wheel && \ | ||
| pip install --no-cache-dir -r requirements.txt && \ | ||
| pip install --no-cache-dir -r requirements-streamlit.txt && \ | ||
| pip uninstall -y pip | ||
|
|
||
| COPY src/ . | ||
| COPY src/syngen/streamlit_app/.streamlit syngen/.streamlit | ||
| COPY src/syngen/streamlit_app/.streamlit/config.toml /root/.streamlit/config.toml | ||
| ENV HOME=/tmp | ||
| ENV MPLCONFIGDIR=/tmp | ||
| ENV PYTHONPATH="${PYTHONPATH}:/src/syngen" | ||
| RUN mkdir model_artifacts uploaded_files mlruns && \ | ||
| groupadd syngen && \ | ||
| useradd -mg syngen syngen && \ | ||
| chown -R syngen:syngen model_artifacts uploaded_files mlruns | ||
|
|
||
| USER syngen | ||
| ENTRYPOINT ["python3", "-m", "start"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,107 @@ | ||
| from typing import Dict | ||
| from typing import Optional | ||
|
|
||
| from tensorflow.keras.layers import Layer | ||
| import tensorflow.keras.backend as K | ||
| import keras | ||
| from keras.layers import Layer | ||
| import keras.ops as ops | ||
|
|
||
|
|
||
| # Module-level seed generator for reproducible random operations | ||
| _seed_generator: Optional[keras.random.SeedGenerator] = None | ||
|
|
||
|
|
||
| def set_seed_generator(seed: Optional[int] = None): | ||
| """ | ||
| Set the module-level seed generator for reproducible random operations. | ||
| Call this before building the VAE model. | ||
| """ | ||
| global _seed_generator | ||
| if seed is not None: | ||
| _seed_generator = keras.random.SeedGenerator(seed) | ||
| else: | ||
| _seed_generator = None | ||
|
Comment on lines
+8
to
+21
|
||
|
|
||
|
|
||
| def get_seed_generator() -> Optional[keras.random.SeedGenerator]: | ||
| """Get the current seed generator.""" | ||
| return _seed_generator | ||
|
|
||
|
|
||
| class FeatureLossLayer(Layer): | ||
| def __init__(self, feature, **kwargs): | ||
| self.feature = feature | ||
| """ | ||
| Custom layer that computes and adds reconstruction loss for a feature. | ||
| In Keras 3, Model.add_loss() doesn't work for Functional models, | ||
| so we use this layer to add losses via Layer.add_loss() in the call method. | ||
|
|
||
| Supports weight_randomizer for dynamic loss weighting during training. | ||
| """ | ||
|
|
||
| def __init__(self, feature, loss_type='categorical', weight=1.0, | ||
| weight_randomizer=None, seed_generator=None, custom_loss=None, **kwargs): | ||
| super().__init__(**kwargs) | ||
|
|
||
| def call(self, inputs, **kwargs): | ||
| self.feature = feature | ||
| self.loss_type = loss_type | ||
| self.weight = weight | ||
| self.seed_generator = seed_generator | ||
| self.custom_loss = custom_loss | ||
|
|
||
| # Handle weight_randomizer: convert to (low, high) tuple | ||
| if weight_randomizer is None: | ||
| # Get from feature if available, else use fixed weight | ||
| if hasattr(feature, 'weight_randomizer'): | ||
| self.weight_randomizer = feature.weight_randomizer | ||
| else: | ||
| self.weight_randomizer = (weight, weight) | ||
| elif isinstance(weight_randomizer, (list, tuple)) and len(weight_randomizer) == 2: | ||
| self.weight_randomizer = tuple(weight_randomizer) | ||
| elif isinstance(weight_randomizer, bool): | ||
| self.weight_randomizer = (0, 1) if weight_randomizer else (weight, weight) | ||
| elif isinstance(weight_randomizer, (int, float)): | ||
| self.weight_randomizer = (weight_randomizer, weight_randomizer) | ||
| else: | ||
| self.weight_randomizer = (weight, weight) | ||
|
|
||
| def call(self, inputs, training=None, **kwargs): | ||
| """ | ||
| Compute the reconstruction loss from input and decoder output. | ||
|
|
||
| Args: | ||
| inputs: tuple of (feature_input, feature_decoder) | ||
| training: whether the model is in training mode | ||
| """ | ||
| feature_input, feature_decoder = inputs | ||
| self.add_loss(self.feature.loss, inputs=inputs) | ||
|
|
||
| # Compute random weight for loss (weight_randomizer support) | ||
| low, high = self.weight_randomizer | ||
| if low == high: | ||
| random_weight = low | ||
| else: | ||
| # Use random weight during training for regularization | ||
| seed = self.seed_generator if self.seed_generator is not None else get_seed_generator() | ||
| random_weight = keras.random.uniform( | ||
| shape=(1,), minval=low, maxval=high, seed=seed | ||
| ) | ||
|
|
||
| # Compute loss based on feature type | ||
| if self.loss_type == 'continuous': | ||
| loss = random_weight * ops.mean(keras.losses.mean_squared_error(feature_input, feature_decoder)) | ||
| elif self.loss_type == 'binary': | ||
| loss = random_weight * ops.mean(keras.losses.binary_crossentropy(feature_input, feature_decoder)) | ||
| else: # categorical | ||
| loss = random_weight * ops.mean(keras.losses.categorical_crossentropy(feature_input, feature_decoder)) | ||
|
|
||
| self.add_loss(loss) | ||
|
Comment on lines
+84
to
+92
|
||
| return feature_decoder | ||
|
|
||
| def get_config(self): | ||
| config = super().get_config() | ||
| config.update({ | ||
| "loss_type": self.loss_type, | ||
| "weight": self.weight, | ||
| "weight_randomizer": self.weight_randomizer, | ||
| # Note: custom_loss is not serializable, so we omit it from config | ||
| }) | ||
| return config | ||
|
|
||
|
|
||
| class SampleLayer(Layer): | ||
| def __init__(self, gamma, capacity, **kwargs): | ||
|
|
@@ -22,43 +110,44 @@ def __init__(self, gamma, capacity, **kwargs): | |
| self.max_capacity = capacity | ||
|
|
||
| def build(self, input_shape): | ||
| super(SampleLayer, self).build(input_shape) | ||
| super().build(input_shape) | ||
| self.built = True | ||
|
|
||
| def call(self, layer_inputs, **kwargs): | ||
| if len(layer_inputs) != 2: | ||
| raise Exception("input layers must be a list: mean and stddev") | ||
| if len(K.int_shape(layer_inputs[0])) != 2 or len(K.int_shape(layer_inputs[1])) != 2: | ||
| if len(layer_inputs[0].shape) != 2 or len(layer_inputs[1].shape) != 2: | ||
| raise Exception("input shape is not a vector [batchSize, latentSize]") | ||
|
|
||
| mean = layer_inputs[0] | ||
| log_var = layer_inputs[1] | ||
|
|
||
| batch = K.shape(mean)[0] | ||
| dim = K.int_shape(mean)[1] | ||
| batch = ops.shape(mean)[0] | ||
| dim = mean.shape[1] | ||
|
|
||
| latent_loss = -0.5 * (1 + log_var - K.square(mean) - K.exp(log_var)) | ||
| latent_loss = K.sum(latent_loss, axis=1, keepdims=True) | ||
| latent_loss = K.mean(latent_loss) | ||
| latent_loss = self.gamma * K.abs(latent_loss - self.max_capacity) | ||
| latent_loss = -0.5 * (1 + log_var - ops.square(mean) - ops.exp(log_var)) | ||
| latent_loss = ops.sum(latent_loss, axis=1, keepdims=True) | ||
| latent_loss = ops.mean(latent_loss) | ||
| latent_loss = self.gamma * ops.abs(latent_loss - self.max_capacity) | ||
|
|
||
| latent_loss = K.reshape(latent_loss, [1, 1]) | ||
| latent_loss = ops.reshape(latent_loss, [1, 1]) | ||
|
|
||
| epsilon = K.random_normal(shape=(batch, dim), mean=0.0, stddev=1.0) | ||
| layer_output = mean + K.exp(0.5 * log_var) * epsilon | ||
| epsilon = keras.random.normal( | ||
| shape=(batch, dim), mean=0.0, stddev=1.0, seed=get_seed_generator() | ||
| ) | ||
| layer_output = mean + ops.exp(0.5 * log_var) * epsilon | ||
|
|
||
| self.add_loss(losses=[latent_loss], inputs=[layer_inputs]) | ||
| self.add_loss(latent_loss) | ||
|
|
||
| return layer_output | ||
|
|
||
| def compute_output_shape(self, input_shape): | ||
| return input_shape[0] | ||
|
|
||
| @property | ||
| def get_config(self): | ||
| config = { | ||
| config = super().get_config() | ||
| config.update({ | ||
| "gamma": self.gamma, | ||
| "capacity": self.max_capacity, | ||
| } | ||
| base_config: Dict = super(SampleLayer, self).get_config() | ||
| return dict(list(base_config.items()) + list(config.items())) | ||
| }) | ||
| return config | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The PR description mentions uncertainty about whether to keep this in a separate branch for Python 3.12 ("maybe we need to create new dockerfile for 3.12"). Consider adding a comment in the Dockerfile indicating this is for Python 3.12 with Keras 3, or creating a separate Dockerfile (e.g.,
Dockerfile.python3.12) as suggested in the PR description, maintaining backward compatibility with Python 3.11.