diff --git a/pyproject.toml b/pyproject.toml index 4fb5fb7..8a1af0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ [project] name = "pysits" -version = "2.0.0.dev4" +version = "2.0.0.dev5" description = "Python wrapper for the sits R package" readme = "README.md" requires-python = ">=3.10,<4" diff --git a/pysits/conversions/common.py b/pysits/conversions/common.py index a9d452a..30face7 100644 --- a/pysits/conversions/common.py +++ b/pysits/conversions/common.py @@ -58,6 +58,12 @@ EPOCH_START = date(1970, 1, 1) +# +# Prefixes of R expressions deparsed as text +# +R_DEPARSED_PREFIXES = ("c(", "list(") + + # # Base utilities # @@ -197,6 +203,27 @@ def eval_r_language(obj): return obj +def eval_r_deparsed(obj): + """Evaluate a deparsed R expression. + + Args: + obj: The R object to evaluate. + + Returns: + The evaluated R object. Objects that are not deparsed R expressions are + returned unchanged. + """ + value = obj + + if isinstance(value, ro.StrVector) and len(value) == 1: + value = value[0] + + if isinstance(value, str) and value.startswith(R_DEPARSED_PREFIXES): + return ro.r(value) + + return obj + + def convert_to_python(obj, as_type="str"): """Convert an R object to a Python representation. diff --git a/pysits/models/data/tuning.py b/pysits/models/data/tuning.py index 28ec042..ecacf82 100644 --- a/pysits/models/data/tuning.py +++ b/pysits/models/data/tuning.py @@ -21,14 +21,65 @@ from typing import Any from rpy2.rinterface_lib.sexp import NULLType -from rpy2.robjects.vectors import ListVector - -from pysits.conversions.common import convert_to_python, eval_r_language +from rpy2.robjects.vectors import ( + BoolVector, + FloatVector, + IntVector, + ListVector, + StrVector, +) + +from pysits.conversions.common import ( + R_DEPARSED_PREFIXES, + convert_to_python, + eval_r_deparsed, + eval_r_language, +) from pysits.models.data.base import SITSData from pysits.models.data.matrix import SITSConfusionMatrix from pysits.models.data.ts import SITSTimeSeriesModel +# +# Python types associated with each R vector type +# +HPARAM_TYPES = { + BoolVector: "bool", + IntVector: "int", + FloatVector: "float", + StrVector: "str", +} + + +# +# Tuning results columns that are not hyper-parameters +# +TUNING_METRICS = ("accuracy", "kappa", "acc", "samples_validation") + + +# +# Utilities +# +def convert_hparam(value: Any) -> Any: + """Convert a hyper-parameter value from R to Python. + + Args: + value: The hyper-parameter value to convert. + + Returns: + Any: Converted Python object. + """ + value = eval_r_deparsed(value) + + for vector_type, as_type in HPARAM_TYPES.items(): + if isinstance(value, vector_type): + return convert_to_python(value, as_type=as_type) + return convert_to_python(value) + + +# +# Classes +# class SITSTuningResults(SITSData): """Base class for sits accuracy results.""" @@ -62,14 +113,13 @@ def _convert_attribute(self, attribute: str, transform_func: Callable) -> list[A if isinstance(values, NULLType): return [None for i in range(len(self._instance.rx2("accuracy")))] - # Check if value is a vector as string + # Check if value is a deparsed R expression (e.g., "c(128, 128, 128)") is_vector_as_string = any( - isinstance(x, str) and (x.startswith("c(") or x.startswith("list(")) - for x in values + isinstance(x, str) and x.startswith(R_DEPARSED_PREFIXES) for x in values ) if is_vector_as_string: - return list(values) + return [transform_func(eval_r_deparsed(x)) for x in values] if isinstance(values, ListVector): return [ @@ -138,47 +188,47 @@ def samples_validation(self) -> list[SITSTimeSeriesModel | None]: ] @property - def cnn_layers(self) -> list[float]: + def cnn_layers(self) -> list[list[float]]: """Get the CNN layer configurations from the tuning results. Returns: - A list of float values representing the CNN layer configurations. + A list with the CNN layer configuration of each trial. """ return self._convert_from_r_list("cnn_layers", "float") @property - def cnn_kernels(self) -> list[float]: + def cnn_kernels(self) -> list[list[float]]: """Get the CNN kernel configurations from the tuning results. Returns: - A list of float values representing the CNN kernel configurations. + A list with the CNN kernel configuration of each trial. """ return self._convert_from_r_list("cnn_kernels", "float") @property - def cnn_dropout_rates(self) -> list[float]: + def cnn_dropout_rates(self) -> list[list[float]]: """Get the CNN dropout rates from the tuning results. Returns: - A list of float values representing the CNN dropout rates. + A list with the CNN dropout rates of each trial. """ return self._convert_from_r_list("cnn_dropout_rates", "float") @property - def dense_layer_nodes(self) -> list[float]: + def dense_layer_nodes(self) -> list[list[float]]: """Get the dense layer node configurations from the tuning results. Returns: - A list of float values representing the dense layer node configurations. + A list with the dense layer node configuration of each trial. """ return self._convert_from_r_list("dense_layer_nodes", "float") @property - def dense_layer_dropout_rate(self) -> list[float]: + def dense_layer_dropout_rate(self) -> list[list[float]]: """Get the dense layer dropout rates from the tuning results. Returns: - A list of float values representing the dense layer dropout rates. + A list with the dense layer dropout rates of each trial. """ return self._convert_from_r_list("dense_layer_dropout_rate", "float") @@ -219,40 +269,33 @@ def optimizer(self) -> list[str]: return [eval_r_language(x).rclass[0] for x in self._instance.rx2("optimizer")] @property - def opt_hparams(self) -> list[dict[str, float]]: + def opt_hparams(self) -> list[dict[str, Any]]: """Get the optimizer hyperparameters from the tuning results. Returns: A list of dictionaries containing optimizer hyperparameters. - Each dictionary maps parameter names to their float values. + Each dictionary maps parameter names to their values. """ - # Results - results = [] - - # Convert to Python - values = self._convert_from_r_list("opt_hparams", "float") - - # Merge dict results - for value in values: - results.append({k: v for d in value for k, v in d.items()}) - - return results + return self._convert_attribute( + "opt_hparams", + lambda x: {str(k): convert_hparam(v) for k, v in x.items()}, + ) @property - def lr_decay_epochs(self) -> list[float]: + def lr_decay_epochs(self) -> list[list[float]]: """Get the learning rate decay epochs from the tuning results. Returns: - A list of float values representing the learning rate decay epochs. + A list with the learning rate decay epochs of each trial. """ return self._convert_from_r_list("lr_decay_epochs", "float") @property - def lr_decay_rate(self) -> list[float]: + def lr_decay_rate(self) -> list[list[float]]: """Get the learning rate decay rates from the tuning results. Returns: - A list of float values representing the learning rate decay rates. + A list with the learning rate decay rates of each trial. """ return self._convert_from_r_list("lr_decay_rate", "float") @@ -283,9 +326,38 @@ def verbose(self) -> list[bool]: """ return self._convert_from_r_list("verbose", "bool") + @property + def hparams(self) -> dict[str, list[Any]]: + """Get all the hyper-parameters tuned, one value per trial. + + Returns: + A dictionary mapping each hyper-parameter name to its values. + """ + return { + name: getattr(self, name) + for name in self._instance.names + if name not in TUNING_METRICS + } + # # Dunder methods # + def __getattr__(self, name: str) -> list[Any]: + """Get a hyper-parameter with no property associated with it. + + Args: + name: The name of the hyper-parameter to get. + + Returns: + A list with the hyper-parameter values, one per trial. + """ + if name.startswith("_") or name not in self._instance.names: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + return self._convert_attribute(name, convert_hparam) + def __str__(self): """String representation.""" return str(self._instance) diff --git a/pysits/templates/tuning.html b/pysits/templates/tuning.html index fe59560..a433027 100644 --- a/pysits/templates/tuning.html +++ b/pysits/templates/tuning.html @@ -93,6 +93,7 @@
{# Assuming number of accuracy is the same as number of runs #} {% set n_runs = tuning_obj.accuracy|length if tuning_obj.accuracy else 0 %} + {% set hparams = tuning_obj.hparams %} {% for i in range(n_runs) %}
@@ -109,73 +110,12 @@ Value - {% if tuning_obj.cnn_layers %} + {% for name, values in hparams.items() %} - CNN Layers - {{ render_list_value(tuning_obj.cnn_layers[i]) }} + {{ name }} + {{ render_list_value(values[i]) }} - - CNN Kernels - {{ render_list_value(tuning_obj.cnn_kernels[i]) }} - - - CNN Dropout Rates - {{ render_list_value(tuning_obj.cnn_dropout_rates[i]) }} - - {% endif %} - - {% if tuning_obj.dense_layer_nodes %} - - Dense Layer Nodes - {{ render_list_value(tuning_obj.dense_layer_nodes[i]) }} - - - Dense Layer Dropout Rate - {{ render_list_value(tuning_obj.dense_layer_dropout_rate[i]) }} - - {% endif %} - - - Epochs - {{ tuning_obj.epochs[i] }} - - - Batch Size - {{ tuning_obj.batch_size[i] }} - - - Validation Split - {{ tuning_obj.validation_split[i] }} - - - {% if tuning_obj.optimizer %} - - Optimizer - {{ tuning_obj.optimizer[i] }} - - {% endif %} - - {% if tuning_obj.lr_decay_epochs %} - - Learning Rate Decay Epochs - {{ render_list_value(tuning_obj.lr_decay_epochs[i]) }} - - - Learning Rate Decay Rate - {{ render_list_value(tuning_obj.lr_decay_rate[i]) }} - - {% endif %} - - {% if tuning_obj.patience %} - - Patience - {{ tuning_obj.patience[i] }} - - - Minimum Delta - {{ tuning_obj.min_delta[i] }} - - {% endif %} + {% endfor %}
diff --git a/tests/test_conversions.py b/tests/test_conversions.py index 6d84808..4266ae8 100644 --- a/tests/test_conversions.py +++ b/tests/test_conversions.py @@ -29,6 +29,7 @@ convert_dict_like_to_r, convert_list_like_to_r, convert_to_python, + eval_r_deparsed, ) from pysits.conversions.tibble import ( _column_to_datetime, @@ -330,3 +331,21 @@ def test_pandas_cube_to_tibble_empty(): """Test conversion of a cube pandas DataFrame without tiles.""" with pytest.raises(ValueError, match="at least one tile"): pandas_cube_to_tibble(PandasDataFrame()) + + +def test_eval_r_deparsed(): + """Test evaluation of deparsed R expressions.""" + # Deparsed expression (e.g., as returned by ``sits_tuning`` hyper-parameters) + result = eval_r_deparsed("c(128, 128, 128)") + assert convert_to_python(result, as_type="float") == [128.0, 128.0, 128.0] + + # Deparsed expression stored in a character vector + result = eval_r_deparsed(ro.StrVector(["c(0.9, 0.999)"])) + assert convert_to_python(result, as_type="float") == [0.9, 0.999] + + # Values that are not deparsed expressions are returned unchanged + assert eval_r_deparsed("radial") == "radial" + + values = ro.FloatVector([0.2]) + + assert eval_r_deparsed(values) is values diff --git a/tests/test_tuning.py b/tests/test_tuning.py index 57f353c..ee463ae 100644 --- a/tests/test_tuning.py +++ b/tests/test_tuning.py @@ -22,21 +22,19 @@ from pysits.conversions.dsl.tuning import hparam from pysits.models.data.tuning import SITSTuningResults from pysits.sits.context import samples_modis_ndvi -from pysits.sits.ml import sits_tempcnn +from pysits.sits.ml import sits_rfor, sits_tempcnn from pysits.sits.tuning import sits_tuning, sits_tuning_hparams # # Hyper-parameters options available for tuning # +TRIALS = 2 + CNN_LAYERS = [[128.0, 128.0, 128.0], [64.0, 64.0, 64.0]] CNN_KERNELS = [[3.0, 3.0, 3.0], [5.0, 5.0, 5.0]] BETAS = [[0.9, 0.999], [0.85, 0.99]] OPTIMIZERS = ["optim_adamw", "optim_adam"] - -# -# Number of trials used in the tests -# -TRIALS = 2 +NUM_TREES = [50.0, 100.0] @pytest.fixture(scope="module") @@ -60,6 +58,20 @@ def tuned_tempcnn() -> SITSTuningResults: ) +@pytest.fixture(scope="module") +def tuned_rfor() -> SITSTuningResults: + """Tuning results of a ``sits_rfor`` model.""" + return sits_tuning( + samples=samples_modis_ndvi, + ml_method=sits_rfor, + params=sits_tuning_hparams( + num_trees=hparam("choice", *NUM_TREES), + ), + trials=TRIALS, + multicores=1, + ) + + def test_tuning_metrics(tuned_tempcnn): """Test tuning metrics results.""" assert isinstance(tuned_tempcnn, SITSTuningResults) @@ -81,9 +93,9 @@ def test_tuning_vector_hparams(tuned_tempcnn): def test_tuning_scalar_hparams(tuned_tempcnn): """Test tuning results of hyper-parameters defined as scalars.""" - assert tuned_tempcnn.epochs == [[1.0]] * TRIALS - assert tuned_tempcnn.validation_split == [[0.2]] * TRIALS - assert tuned_tempcnn.verbose == [[False]] * TRIALS + assert tuned_tempcnn.epochs == [1.0] * TRIALS + assert tuned_tempcnn.validation_split == [0.2] * TRIALS + assert tuned_tempcnn.verbose == [False] * TRIALS def test_tuning_optimizer_hparams(tuned_tempcnn): @@ -98,9 +110,32 @@ def test_tuning_optimizer_hparams(tuned_tempcnn): assert opt_hparams["betas"] in BETAS -def test_tuning_html_representation(tuned_tempcnn): +def test_tuning_generic_hparams(tuned_rfor): + """Test tuning results of models with no hyper-parameter properties.""" + assert len(tuned_rfor.num_trees) == TRIALS + + for num_trees in tuned_rfor.num_trees: + assert num_trees in NUM_TREES + + # Dict accessor and property accessor must be equivalent + assert tuned_rfor.hparams["num_trees"] == tuned_rfor.num_trees + + # Hyper-parameters with no value defined are returned as ``None`` + assert tuned_rfor.mtry == [None] * TRIALS + + # Hyper-parameters not tuned are not available + with pytest.raises(AttributeError): + tuned_rfor.unknown_hparam + + +def test_tuning_html_representation(tuned_tempcnn, tuned_rfor): """Test tuning HTML representation.""" + # tempcnn representation html = tuned_tempcnn._repr_html_() - for layers in tuned_tempcnn.cnn_layers: assert str(layers) in html + + # rfor representation + html = tuned_rfor._repr_html_() + for num_trees in tuned_rfor.num_trees: + assert str(num_trees) in html diff --git a/uv.lock b/uv.lock index d6c9bd8..f2f4566 100644 --- a/uv.lock +++ b/uv.lock @@ -2266,7 +2266,7 @@ wheels = [ [[package]] name = "pysits" -version = "2.0.0.dev4" +version = "2.0.0.dev5" source = { editable = "." } dependencies = [ { name = "geopandas" },