diff --git a/.github/actions/publish/action.yaml b/.github/actions/publish/action.yaml index d97fd753..94c60f55 100644 --- a/.github/actions/publish/action.yaml +++ b/.github/actions/publish/action.yaml @@ -100,7 +100,7 @@ runs: vname="${{ github.event.release.tag_name }}" vname=${vname:1} echo $vname - sed -i -r 's/__version__ *= *".*"/__version__ = "'"$vname"'" /g' ${{ inputs.PACKAGE_DIRECTORY }}__init__.py + sed -i -r 's/__version__ *= *".*"/__version__ = "'"$vname"'"/g' ${{ inputs.PACKAGE_DIRECTORY }}__init__.py sed -i '0,/version =.*/s//version = "'"$vname"'"/' ./pyproject.toml fi shell: bash diff --git a/.github/workflows/release_pypi.yaml b/.github/workflows/release_pypi.yaml index e27eb77f..0df3d9a2 100644 --- a/.github/workflows/release_pypi.yaml +++ b/.github/workflows/release_pypi.yaml @@ -41,7 +41,7 @@ jobs: PACKAGE_DIRECTORY: "./choice_learn/" PYTHON_VERSION: "3.9" PUBLISH_REGISTRY_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - PUBLISH_REGISTRY_USER: ${{ secrets.PYPI_USERNAME }} + PUBLISH_REGISTRY_USERNAME: ${{ secrets.PYPI_USERNAME }} UPDATE_CODE_VERSION: false BRANCH: ${{ steps.install.outputs.BRANCH }} diff --git a/choice_learn/data/storage.py b/choice_learn/data/storage.py index c59f9c82..edd0eae7 100644 --- a/choice_learn/data/storage.py +++ b/choice_learn/data/storage.py @@ -260,6 +260,11 @@ def __init__(self, values=None, values_names=None, name=None): values = np.array(values) elif not isinstance(values, np.ndarray): raise ValueError("ArrayStorage Values must be a list or a numpy array") + + # Checking that FeaturesStorage increases dimensions + # key -> features of ndim >= 1 + if not values.ndim > 1: + raise ValueError("ArrayStorage Values must be a list or a numpy array of ndim >= 1") # self.storage = storage self.values_names = values_names self.name = name diff --git a/choice_learn/models/base_model.py b/choice_learn/models/base_model.py index 881d0827..659c3a73 100644 --- a/choice_learn/models/base_model.py +++ b/choice_learn/models/base_model.py @@ -125,6 +125,19 @@ def __init__( self.regularization_strength = 0.0 self.regularization = None + @property + def trainable_weights(self): + """Trainable weights need to be specified in children classes. + + Basically it determines which weights need to be optimized during training. + MUST be a list + """ + raise NotImplementedError( + """Trainable_weights must be specified in children classes, + when you inherit from ChoiceModel. + See custom models documentation for more details and examples.""" + ) + @abstractmethod def compute_batch_utility( self, diff --git a/choice_learn/models/baseline_models.py b/choice_learn/models/baseline_models.py index 294e4bc7..b7b2b62c 100644 --- a/choice_learn/models/baseline_models.py +++ b/choice_learn/models/baseline_models.py @@ -15,7 +15,7 @@ def __init__(self, **kwargs): @property def trainable_weights(self): - """Return an empty list.""" + """Return an empty list - there is no trainable weight.""" return [] def compute_batch_utility( @@ -75,13 +75,13 @@ class DistribMimickingModel(ChoiceModel): def __init__(self, **kwargs): """Initialize of the model.""" super().__init__(**kwargs) - self.weights = [] + self._trainable_weights = [] self.is_fitted = False @property def trainable_weights(self): - """Return the weights.""" - return self.weights + """Trainable weights of the model.""" + return [self._trainable_weights] def fit(self, choice_dataset, *args, **kwargs): """Compute the choice frequency of each product and defines it as choice probabilities. @@ -95,8 +95,8 @@ def fit(self, choice_dataset, *args, **kwargs): _ = args choices = choice_dataset.choices for i in range(choice_dataset.get_n_items()): - self.weights.append(tf.reduce_sum(tf.cast(choices == i, tf.float32))) - self.weights = tf.stack(self.weights) / len(choices) + self._trainable_weights.append(tf.reduce_sum(tf.cast(choices == i, tf.float32))) + self._trainable_weights = tf.stack(self._trainable_weights) / len(choices) self.is_fitted = True def _fit_with_lbfgs(self, choice_dataset, *args, **kwargs): @@ -111,8 +111,8 @@ def _fit_with_lbfgs(self, choice_dataset, *args, **kwargs): _ = args choices = choice_dataset.choices for i in range(choice_dataset.get_n_items()): - self.weights.append(tf.reduce_sum(tf.cast(choices == i, tf.float32))) - self.weights = tf.stack(self.weights) / len(choices) + self._trainable_weights.append(tf.reduce_sum(tf.cast(choices == i, tf.float32))) + self._trainable_weights = tf.stack(self._trainable_weights) / len(choices) self.is_fitted = True def compute_batch_utility( @@ -153,4 +153,4 @@ def compute_batch_utility( _ = items_features_by_choice, shared_features_by_choice, available_items_by_choice if not self.is_fitted: raise ValueError("Model not fitted") - return np.stack([np.log(self.trainable_weights.numpy())] * len(choices), axis=0) + return tf.stack([tf.math.log(self.trainable_weights[0])] * len(choices), axis=0) diff --git a/choice_learn/models/conditional_logit.py b/choice_learn/models/conditional_logit.py index 47d615cc..d9fa2e8e 100644 --- a/choice_learn/models/conditional_logit.py +++ b/choice_learn/models/conditional_logit.py @@ -316,7 +316,7 @@ def instantiate(self, choice_dataset): if not self.instantiated: if not isinstance(self.coefficients, MNLCoefficients): self._build_coefficients_from_dict(n_items=choice_dataset.get_n_items()) - self.trainable_weights = self._instantiate_tf_weights() + self._trainable_weights = self._instantiate_tf_weights() # Checking that no weight has been attributed to non existing feature in dataset dataset_stacked_features_names = [] @@ -360,10 +360,15 @@ def _instantiate_tf_weights(self): weights.append(weight) self.coefficients._add_tf_weight(weight_name, weight_nb) - self.trainable_weights = weights + self._trainable_weights = weights return weights + @property + def trainable_weights(self): + """Trainable weights of the model.""" + return self._trainable_weights + def _build_coefficients_from_dict(self, n_items): """Build coefficients when they are given as a dictionnay. @@ -699,7 +704,7 @@ def get_weights_std(self, choice_dataset): for _w in self.trainable_weights: mw.append(w[:, index : index + _w.shape[1]]) index += _w.shape[1] - model.trainable_weights = mw + model._trainable_weights = mw batch = next(choice_dataset.iter_batch(batch_size=-1)) utilities = model.compute_batch_utility(*batch) probabilities = tf.nn.softmax(utilities, axis=-1) @@ -732,7 +737,7 @@ def clone(self): if hasattr(self, "report"): clone.report = self.report if hasattr(self, "trainable_weights"): - clone.trainable_weights = self.trainable_weights + clone._trainable_weights = self.trainable_weights if hasattr(self, "lr"): clone.lr = self.lr if hasattr(self, "_shared_features_by_choice_names"): diff --git a/choice_learn/models/nested_logit.py b/choice_learn/models/nested_logit.py index 7cc49ee4..f356927f 100644 --- a/choice_learn/models/nested_logit.py +++ b/choice_learn/models/nested_logit.py @@ -251,7 +251,7 @@ def instantiate(self, choice_dataset): if not self.instantiated: if not isinstance(self.coefficients, MNLCoefficients): self._build_coefficients_from_dict(n_items=choice_dataset.get_n_items()) - self.trainable_weights = self._instantiate_tf_weights() + self._trainable_weights = self._instantiate_tf_weights() # Checking that no weight has been attributed to non existing feature in dataset dataset_stacked_features_names = [] @@ -312,10 +312,15 @@ def _instantiate_tf_weights(self): ) ) - self.trainable_weights = weights + self._trainable_weights = weights return weights + @property + def trainable_weights(self): + """Trainable weights of the model.""" + return self._trainable_weights + def _build_coefficients_from_dict(self, n_items): """Build coefficients when they are given as a dictionnay. diff --git a/choice_learn/models/rumnet.py b/choice_learn/models/rumnet.py index 363436ec..cc9a3834 100644 --- a/choice_learn/models/rumnet.py +++ b/choice_learn/models/rumnet.py @@ -591,8 +591,6 @@ def instantiate(self): l2_regularization_coeff=self.l2_regularization_coef, ) - # Storing weights for back-propagation - self.trainable_weights = self.x_model.weights + self.z_model.weights + self.u_model.weights self.loss = tf_ops.CustomCategoricalCrossEntropy( from_logits=False, label_smoothing=self.label_smoothing, @@ -600,6 +598,11 @@ def instantiate(self): ) self.instantiated = True + @property + def trainable_weights(self): + """Trainable weights of the model.""" + return self.x_model.weights + self.z_model.weights + self.u_model.weights + def compute_batch_utility( self, shared_features_by_choice, @@ -978,18 +981,21 @@ def instantiate(self): width=self.width_u, depth=self.depth_u, add_last=True ) - # Storing weights for back-propagation - self.trainable_weights = ( - self.x_model.trainable_variables - + self.z_model.trainable_variables - + self.u_model.trainable_variables - ) self.loss = tf_ops.CustomCategoricalCrossEntropy( from_logits=False, label_smoothing=self.label_smoothing ) self.time_dict = {} self.instantiated = True + @property + def trainable_weights(self): + """Trainable weights of the model.""" + return ( + self.x_model.trainable_variables + + self.z_model.trainable_variables + + self.u_model.trainable_variables + ) + def compute_batch_utility( self, shared_features_by_choice, diff --git a/choice_learn/models/simple_mnl.py b/choice_learn/models/simple_mnl.py index f5672c0d..16377eb7 100644 --- a/choice_learn/models/simple_mnl.py +++ b/choice_learn/models/simple_mnl.py @@ -99,9 +99,14 @@ def instantiate(self, n_items, n_shared_features, n_items_features): self.instantiated = True self.indexes = indexes - self.trainable_weights = weights + self._trainable_weights = weights return indexes, weights + @property + def trainable_weights(self): + """Trainable weights of the model.""" + return self._trainable_weights + def compute_batch_utility( self, shared_features_by_choice, @@ -188,7 +193,7 @@ def fit(self, choice_dataset, get_report=False, **kwargs): """ if not self.instantiated: # Lazy Instantiation - self.indexes, self.trainable_weights = self.instantiate( + self.indexes, self._trainable_weights = self.instantiate( n_items=choice_dataset.get_n_items(), n_shared_features=choice_dataset.get_n_shared_features(), n_items_features=choice_dataset.get_n_items_features(), @@ -220,7 +225,7 @@ def _fit_with_lbfgs(self, choice_dataset, sample_weight=None, get_report=False, """ if not self.instantiated: # Lazy Instantiation - self.indexes, self.trainable_weights = self.instantiate( + self.indexes, self._trainable_weights = self.instantiate( n_items=choice_dataset.get_n_items(), n_shared_features=choice_dataset.get_n_shared_features(), n_items_features=choice_dataset.get_n_items_features(), @@ -304,7 +309,7 @@ def get_weights_std(self, choice_dataset): for _w in self.trainable_weights: mw.append(w[index : index + _w.shape[0]]) index += _w.shape[0] - model.trainable_weights = mw + model._trainable_weights = mw for batch in choice_dataset.iter_batch(batch_size=-1): utilities = model.compute_batch_utility(*batch) probabilities = tf.nn.softmax(utilities, axis=-1) @@ -336,7 +341,7 @@ def clone(self): if hasattr(self, "report"): clone.report = self.report if hasattr(self, "trainable_weights"): - clone.trainable_weights = self.trainable_weights + clone._trainable_weights = self.trainable_weights if hasattr(self, "indexes"): clone.indexes = self.indexes if hasattr(self, "intercept"): diff --git a/docs/index.md b/docs/index.md index c0ad7a4f..2662ece2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -128,8 +128,10 @@ The use of this software is under the MIT license, with no limitation of usage, Choice-Learn has been developed through a collaboration between researchers at the Artefact Research Center and the laboratory MICS from CentraleSupélec, Université Paris Saclay.
- ![Elements](https://raw.githubusercontent.com/artefactory/choice-learn/main/docs/illustrations/logos/logo_arc.png){: style="height:83px"} - ![Dandi](https://raw.githubusercontent.com/artefactory/choice-learn/main/docs/illustrations/logos/logo_atf.png){: style="height:83px"} + + ![Elements](https://raw.githubusercontent.com/artefactory/choice-learn/main/docs/illustrations/logos/logo_arc.png){: style="height:60px"} + + ![Dandi](https://raw.githubusercontent.com/artefactory/choice-learn/main/docs/illustrations/logos/logo_atf.png){: style="height:60px"}

diff --git a/mkdocs.yaml b/mkdocs.yaml index 14a73bb7..f22b11f3 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -66,7 +66,7 @@ nav: - Intoduction to data handling: notebooks/introduction/2_data_handling.md - Exhaustive example of ChoiceDataset creation: notebooks/data/dataset_creation.md - Optimize RAM usage with Features Storage, in-depth examples: notebooks/data/features_byID_examples.md - - Modelling: + - Modeling: - Introduction to Choice Models - the SimpleMNL: notebooks/models/simple_mnl.md - Conditional Logit Usage: notebooks/introduction/3_model_clogit.md - Nested Logit Usage: notebooks/models/nested_logit.md @@ -96,5 +96,5 @@ nav: - Latent Class BaseModel: reference/models/references_latent_class_base_model.md - Latent Class MNL: references/models/references_latent_class_mnl.md - Toolbox: - - Assortment Optimizer: references/toolbox/references_assortment_optimizer.md + - Assortment Optimizer and Pricing: references/toolbox/references_assortment_optimizer.md - explanations.md diff --git a/notebooks/introduction/3_model_clogit.ipynb b/notebooks/introduction/3_model_clogit.ipynb index 4e69e85c..57cd2d66 100644 --- a/notebooks/introduction/3_model_clogit.ipynb +++ b/notebooks/introduction/3_model_clogit.ipynb @@ -1128,7 +1128,7 @@ "gt_model.instantiate(canada_dataset)\n", "canada_dataset\n", "# Here we estimate the negative log-likelihood with these coefficients (also, we obtain same value as in those papers):\n", - "gt_model.trainable_weights = gt_weights\n", + "gt_model._trainable_weights = gt_weights\n", "print(\"'Ground Truth' Negative LogLikelihood:\", gt_model.evaluate(canada_dataset) * len(canada_dataset))" ] }, @@ -1211,9 +1211,9 @@ "name": "stderr", "output_type": "stream", "text": [ - "Epoch 1999 Train Loss 0.6801: 100%|██████████| 2000/2000 [00:13<00:00, 148.87it/s]\n", - "Epoch 3999 Train Loss 0.6776: 100%|██████████| 4000/4000 [00:24<00:00, 160.24it/s]\n", - "Epoch 19999 Train Loss 0.6767: 100%|██████████| 20000/20000 [01:59<00:00, 166.89it/s]\n" + "Epoch 1999 Train Loss 0.6801: 100%|██████████| 2000/2000 [00:11<00:00, 176.83it/s]\n", + "Epoch 3999 Train Loss 0.6776: 100%|██████████| 4000/4000 [00:20<00:00, 198.77it/s]\n", + "Epoch 19999 Train Loss 0.6767: 100%|██████████| 20000/20000 [01:38<00:00, 202.55it/s]\n" ] } ], diff --git a/notebooks/introduction/4_model_customization.ipynb b/notebooks/introduction/4_model_customization.ipynb index cdddd3ca..f9bdf6dd 100644 --- a/notebooks/introduction/4_model_customization.ipynb +++ b/notebooks/introduction/4_model_customization.ipynb @@ -220,19 +220,30 @@ " batch_size=batch_size)\n", "\n", " # Create model weights. Basically is one weight by feature + one for intercept\n", - " beta_inter = tf.Variable(tf.random_normal_initializer(0.0, 0.02, seed=42)(shape=(1, 3)),\n", + " self.beta_inter = tf.Variable(tf.random_normal_initializer(0.0, 0.02, seed=42)(shape=(1, 3)),\n", " name=\"beta_inter\")\n", - " beta_freq_cost_ovt = tf.Variable(\n", + " self.beta_freq_cost_ovt = tf.Variable(\n", " tf.random_normal_initializer(0.0, 0.02, seed=42)(shape=(1, 3)),\n", " name=\"beta_freq_cost_ovt\"\n", " )\n", - " beta_income = tf.Variable(tf.random_normal_initializer(0.0, 0.02, seed=42)(shape=(1, 3)),\n", + " self.beta_income = tf.Variable(tf.random_normal_initializer(0.0, 0.02, seed=42)(shape=(1, 3)),\n", " name=\"beta_income\")\n", - " beta_ivt = tf.Variable(tf.random_normal_initializer(0.0, 0.02, seed=42)(shape=(1, 4)),\n", + " self.beta_ivt = tf.Variable(tf.random_normal_initializer(0.0, 0.02, seed=42)(shape=(1, 4)),\n", " name=\"beta_ivt\")\n", "\n", - " # Do not forget to add them to the list of trainable_weights, it is mandatory !\n", - " self.trainable_weights = [beta_inter, beta_freq_cost_ovt, beta_income, beta_ivt]\n", + " # Do not forget to add them to the list of trainable_weights, it is mandatory !\n", + " @property\n", + " def trainable_weights(self):\n", + " \"\"\"Do not forget to add the weights to the list of trainable_weights.\n", + " \n", + " It is needed to use the @property definition as here.\n", + "\n", + " Return:\n", + " -------\n", + " list:\n", + " list of tf.Variable to be optimized\n", + " \"\"\"\n", + " return [self.beta_inter, self.beta_freq_cost_ovt, self.beta_income, self.beta_ivt]\n", "\n", "\n", " def compute_batch_utility(self,\n", @@ -266,15 +277,15 @@ " _ = (available_items_by_choice, choices) # Avoid unused variable warning\n", "\n", " # Adding the 0 value intercept of first item to get the right shape\n", - " full_beta_inter = tf.concat([tf.constant([[.0]]), self.trainable_weights[0]], axis=-1)\n", + " full_beta_inter = tf.concat([tf.constant([[.0]]), self.beta_inter], axis=-1)\n", " # Concatenation to reach right shape for dot product\n", - " full_beta_income = tf.concat([tf.constant([[.0]]), self.trainable_weights[2]], axis=-1) # shape = (1, n_items)\n", + " full_beta_income = tf.concat([tf.constant([[.0]]), self.beta_income], axis=-1) # shape = (1, n_items)\n", "\n", " items_ivt_by_choice = items_features_by_choice[:, :, 3] # shape = (n_choices, n_items, )\n", " items_cost_freq_ovt_by_choice = items_features_by_choice[:, :, :3 ]# shape = (n_choices, n_items, 3)\n", " u_cost_freq_ovt = tf.squeeze(tf.tensordot(items_cost_freq_ovt_by_choice,\n", - " tf.transpose(self.trainable_weights[1]), axes=1)) # shape = (n_choices, n_items)\n", - " u_ivt = tf.multiply(items_ivt_by_choice, self.trainable_weights[3]) # shape = (n_choices, n_items)\n", + " tf.transpose(self.beta_freq_cost_ovt), axes=1)) # shape = (n_choices, n_items)\n", + " u_ivt = tf.multiply(items_ivt_by_choice, self.beta_ivt) # shape = (n_choices, n_items)\n", "\n", " u_income = tf.tensordot(shared_features_by_choice, full_beta_income, axes=1) # shape = (n_choices, n_items)\n", "\n", @@ -502,9 +513,9 @@ " list\n", " list of trainable_weights\n", " \"\"\"\n", - " return model.dense_items_features.trainable_variables\\\n", - " + model.dense_shared_features.trainable_variables\\\n", - " + model.final_layer.trainable_variables\n", + " return self.dense_items_features.trainable_variables\\\n", + " + self.dense_shared_features.trainable_variables\\\n", + " + self.final_layer.trainable_variables\n", "\n", " def compute_batch_utility(self,\n", " shared_features_by_choice,\n", diff --git a/notebooks/models/custom_modelling.md b/notebooks/models/custom_modelling.md index b7092efa..b5d19af9 100644 --- a/notebooks/models/custom_modelling.md +++ b/notebooks/models/custom_modelling.md @@ -1 +1 @@ -Custom modelling with Choice-Learn is part of the introductive tutorial and is detailed [here](../introduction/3_model_clogit.ipynb). +Custom modelling with Choice-Learn is part of the introductive tutorial and is detailed [here](../introduction/4_model_customization.ipynb). diff --git a/tests/integration_tests/data/test_dataset_indexer.py b/tests/integration_tests/data/test_dataset_indexer.py index 6dcb2b5f..de3ae809 100644 --- a/tests/integration_tests/data/test_dataset_indexer.py +++ b/tests/integration_tests/data/test_dataset_indexer.py @@ -143,7 +143,6 @@ def test_batch_2(): ) batch = dataset.get_choices_batch(0) - print(batch) assert (batch[0][0] == np.array([2, 40, 1])).all() assert (batch[0][1] == np.array([1, 0, 0, 0])).all() diff --git a/tests/integration_tests/models/test_conditional_logit.py b/tests/integration_tests/models/test_conditional_logit.py index 3be2b1c3..c78a7699 100644 --- a/tests/integration_tests/models/test_conditional_logit.py +++ b/tests/integration_tests/models/test_conditional_logit.py @@ -32,7 +32,7 @@ def test_mode_canada_gt(): gt_model = ConditionalLogit(coefficients=coefficients) gt_model.instantiate(canada_dataset) - gt_model.trainable_weights = gt_weights + gt_model._trainable_weights = gt_weights total_nll = gt_model.evaluate(canada_dataset) * len(canada_dataset) assert total_nll <= 1874.4, f"Got NLL: {total_nll}" assert total_nll >= 1874.1, f"Got NLL: {total_nll}" diff --git a/tests/unit_tests/models/test_baseline_models.py b/tests/unit_tests/models/test_baseline_models.py index 834f8c53..7e3d3408 100644 --- a/tests/unit_tests/models/test_baseline_models.py +++ b/tests/unit_tests/models/test_baseline_models.py @@ -109,7 +109,7 @@ def test_mimicking_choice_model_with_sgd(): assert (np.abs(y_pred[:, 1] - 0.25) < 0.01).all() assert (np.abs(y_pred[:, 2] - 0.25) < 0.01).all() - assert (model.trainable_weights.numpy() == np.array([0.5, 0.25, 0.25])).all() + assert (model.trainable_weights[0].numpy() == np.array([0.5, 0.25, 0.25])).all() def test_catch_not_fitted_issue():