Skip to content

Commit

Permalink
feat: add FunctionalTableTransformer (#901)
Browse files Browse the repository at this point in the history
Closes #858 

### Summary of Changes

This implements a Transformer that wraps a Callable[[Table], Table] so
operations on Tables can be inserted into the operation order of a
SequentialTableTransformer.
Note that this transformer inherently cannot be invertible.
Since there is no type checking at runtime, callables with wrong
argument or return types will only throw an exception upon calling
transform but not during init.

---------

Co-authored-by: Simon <simon@schwubbel.dip0.t-ipconnect.de>
Co-authored-by: srose <118634249+wastedareas@users.noreply.github.com>
Co-authored-by: megalinter-bot <129584137+megalinter-bot@users.noreply.github.com>
Co-authored-by: Lars Reimann <mail@larsreimann.com>
  • Loading branch information
5 people authored Jul 12, 2024
1 parent 134e7d8 commit 37905be
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 0 deletions.
4 changes: 4 additions & 0 deletions src/safeds/data/tabular/transformation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

if TYPE_CHECKING:
from ._discretizer import Discretizer
from ._functional_table_transformer import FunctionalTableTransformer
from ._invertible_table_transformer import InvertibleTableTransformer
from ._k_nearest_neighbors_imputer import KNearestNeighborsImputer
from ._label_encoder import LabelEncoder
Expand All @@ -16,10 +17,12 @@
from ._standard_scaler import StandardScaler
from ._table_transformer import TableTransformer


apipkg.initpkg(
__name__,
{
"Discretizer": "._discretizer:Discretizer",
"FunctionalTableTransformer": "._functional_table_transformer:FunctionalTableTransformer",
"InvertibleTableTransformer": "._invertible_table_transformer:InvertibleTableTransformer",
"LabelEncoder": "._label_encoder:LabelEncoder",
"OneHotEncoder": "._one_hot_encoder:OneHotEncoder",
Expand All @@ -34,6 +37,7 @@

__all__ = [
"Discretizer",
"FunctionalTableTransformer",
"InvertibleTableTransformer",
"LabelEncoder",
"OneHotEncoder",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from safeds._utils import _structural_hash

if TYPE_CHECKING:
from collections.abc import Callable

from safeds.data.tabular.containers import Table

from ._table_transformer import TableTransformer


class FunctionalTableTransformer(TableTransformer):
"""
Wraps a callable so that it conforms to the TableTransformer interface.
Parameters
----------
transformer:
A callable that receives a table and returns a table.
"""

# ------------------------------------------------------------------------------------------------------------------
# Dunder methods
# ------------------------------------------------------------------------------------------------------------------

def __init__(
self,
transformer: Callable[[Table], Table],
) -> None:
super().__init__(None)
self._transformer = transformer

def __hash__(self) -> int:
return _structural_hash(
super().__hash__(),
self._transformer,
)

# ------------------------------------------------------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------------------------------------------------------

@property
def is_fitted(self) -> bool:
"""FunctionalTableTransformer is always considered to be fitted."""
return True

# ------------------------------------------------------------------------------------------------------------------
# Learning and transformation
# ------------------------------------------------------------------------------------------------------------------

def fit(self, table: Table) -> FunctionalTableTransformer: # noqa: ARG002
"""
**Note:** For FunctionalTableTransformer this is a no-OP.
Parameters
----------
table:
Required only to be consistent with other transformers.
Returns
-------
fitted_transformer:
Returns self, because this transformer is always fitted.
"""
return self

def transform(self, table: Table) -> Table:
"""
Apply the callable to a table.
**Note:** The given table is not modified.
Parameters
----------
table:
The table on which on which the callable is executed.
Returns
-------
transformed_table:
The transformed table.
Raises
------
Exception:
Raised when the wrapped callable encounters an error.
"""
return self._transformer(table)

def fit_and_transform(self, table: Table) -> tuple[FunctionalTableTransformer, Table]:
"""
**Note:** For the FunctionalTableTransformer this is the same as transform().
Parameters
----------
table:
The table on which the callable is to be executed.
Returns
-------
fitted_transformer:
Return self because the transformer is always fitted.
transformed_table:
The transformed table.
"""
fitted_transformer = self
transformed_table = self.transform(table)
return fitted_transformer, transformed_table
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import pytest
from safeds.data.tabular.containers import Table
from safeds.data.tabular.transformation import FunctionalTableTransformer
from safeds.exceptions import ColumnNotFoundError


def valid_callable(table: Table) -> Table:
return table.remove_columns(["col1"])


class TestInit:
def test_should_not_raise_type_error(self) -> None:
FunctionalTableTransformer(valid_callable)


class TestFit:
def test_should_return_self(self) -> None:
table = Table(
{
"col1": [1, 2, 3],
"col2": [1, 2, 3],
},
)
transformer = FunctionalTableTransformer(valid_callable)
assert transformer.fit(table) is transformer


class TestIsFitted:
def test_should_always_be_fitted(self) -> None:
transformer = FunctionalTableTransformer(valid_callable)
assert transformer.is_fitted


class TestTransform:
def test_should_raise_specific_error_when_error_in_method(self) -> None:
table = Table(
{
"col2": [1, 2, 3],
},
)
transformer = FunctionalTableTransformer(valid_callable)
with pytest.raises(ColumnNotFoundError):
transformer.transform(table)

def test_should_not_modify_original_table(self) -> None:
table = Table(
{
"col1": [1, 2, 3],
"col2": [1, 2, 3],
},
)
transformer = FunctionalTableTransformer(valid_callable)
transformer.transform(table)
assert table == Table(
{
"col1": [1, 2, 3],
"col2": [1, 2, 3],
},
)

def test_should_return_modified_table(self) -> None:
table = Table(
{
"col1": [1, 2, 3],
"col2": [1, 2, 3],
},
)
transformer = FunctionalTableTransformer(valid_callable)
transformed_table = transformer.transform(table)
assert transformed_table == Table(
{
"col2": [1, 2, 3],
},
)


class TestFitAndTransform:
def test_should_return_self(self) -> None:
table = Table(
{
"col1": [1, 2, 3],
"col2": [1, 2, 3],
},
)
transformer = FunctionalTableTransformer(valid_callable)
assert transformer.fit_and_transform(table)[0] is transformer

def test_should_not_modify_original_table(self) -> None:
table = Table(
{
"col1": [1, 2, 3],
"col2": [1, 2, 3],
},
)
transformer = FunctionalTableTransformer(valid_callable)
transformer.fit_and_transform(table)
assert table == Table(
{
"col1": [1, 2, 3],
"col2": [1, 2, 3],
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from safeds.data.tabular.containers import Table
from safeds.data.tabular.transformation import (
Discretizer,
FunctionalTableTransformer,
KNearestNeighborsImputer,
LabelEncoder,
OneHotEncoder,
Expand Down Expand Up @@ -51,6 +52,10 @@ def transformers_non_numeric() -> list[TableTransformer]:
]


def valid_callable_for_functional_table_transformer(table: Table) -> Table:
return table.remove_columns(["col1"])


def transformers() -> list[TableTransformer]:
"""
Return the list of all transformers to test.
Expand All @@ -69,6 +74,7 @@ def transformers() -> list[TableTransformer]:
+ [
SimpleImputer(strategy=SimpleImputer.Strategy.mode()),
KNearestNeighborsImputer(neighbor_count=3, value_to_replace=None),
FunctionalTableTransformer(valid_callable_for_functional_table_transformer),
]
)

Expand Down

0 comments on commit 37905be

Please sign in to comment.