From db8561746827c5a9d679e14a8edd2b8ff264e8ed Mon Sep 17 00:00:00 2001 From: Lars Reimann Date: Sun, 12 Jan 2025 20:48:02 +0100 Subject: [PATCH] feat: stabilize `Table` class (#979) Closes #875 Closes #877 Closes partially #977 ### Summary of Changes Stabilize the API of the `Table` class. This PR introduces several breaking changes to this class: - All optional parameters are now keyword-only, so we can reposition them later. - The `data` parameter of `__init__` is now required. - Rename `remove_columns_except` to `select_columns` - The new method can also be called with a callback that determines which columns to select. - Rename `add_table_as_columns` to `add_tables_as_columns` - Multiple tables can now be passed at once. - Rename `add_table_as_rows` to `add_tables_as_rows` - Multiple tables can now be passed at once. It also adds new functionality throughout the library: - New method `Table.add_index_column` to add a new column with auto-incrementing integer values to a table. - New method `Table.filter_rows` to keep only the rows matched by some predicate. - New method `Table.filter_rows_by_column` to keep only the rows that have a value in a specific column that matches some predicate. - New parameter `random_seed` for `Table.shuffle_rows` and `Table.split_rows` to control the pseudorandom number generator. Previously, the methods were deterministic, but the seed was hidden. - New parameter `missing_value_ratio_threshold` of `Table.remove_columns_with_missing_values` to be able to keep columns with only a few missing values. - Various static factory methods under `ColumnType` to instantiate column types. This prepares for #754. Finally, the methods `Table.summarize_statistics` and `Column.summarize_statistics` are now considerably faster. --------- Co-authored-by: megalinter-bot <129584137+megalinter-bot@users.noreply.github.com> --- benchmarks/metrics/classification.py | 4 +- benchmarks/table/column_operations.py | 3 +- docs/tutorials/classification.ipynb | 102 +- ...ral_network_for_image_classification.ipynb | 224 +-- docs/tutorials/data_processing.ipynb | 38 +- docs/tutorials/data_visualization.ipynb | 2 +- docs/tutorials/image_list_processing.ipynb | 232 +-- docs/tutorials/image_processing.ipynb | 210 +-- docs/tutorials/machine_learning.ipynb | 223 ++- docs/tutorials/regression.ipynb | 4 +- docs/tutorials/time_series_forecasting.ipynb | 248 ++- poetry.lock | 228 +-- pyproject.toml | 2 - src/safeds/_utils/__init__.py | 5 +- src/safeds/_utils/_collections.py | 34 + src/safeds/_validation/__init__.py | 32 +- ...heck_bounds.py => _check_bounds_module.py} | 6 +- ....py => _check_column_is_numeric_module.py} | 8 +- ...py => _check_columns_dont_exist_module.py} | 19 +- ...xist.py => _check_columns_exist_module.py} | 19 +- .../_check_row_counts_are_equal_module.py | 84 + .../_validation/_check_schema_module.py | 122 ++ ... _normalize_and_check_file_path_module.py} | 12 +- .../containers/_multi_size_image_list.py | 4 +- .../containers/_single_size_image_list.py | 4 +- .../data/labeled/containers/_image_dataset.py | 4 +- .../labeled/containers/_tabular_dataset.py | 27 +- .../containers/_time_series_dataset.py | 6 +- .../data/tabular/containers/__init__.py | 2 +- src/safeds/data/tabular/containers/_cell.py | 36 +- src/safeds/data/tabular/containers/_column.py | 169 +-- .../containers/_lazy_vectorized_row.py | 4 +- src/safeds/data/tabular/containers/_row.py | 4 +- src/safeds/data/tabular/containers/_table.py | 1352 ++++++++++++----- .../data/tabular/plotting/_column_plotter.py | 2 +- .../data/tabular/plotting/_table_plotter.py | 11 +- .../data/tabular/transformation/__init__.py | 4 +- .../tabular/transformation/_discretizer.py | 13 +- .../_invertible_table_transformer.py | 2 +- .../_k_nearest_neighbors_imputer.py | 10 +- .../tabular/transformation/_label_encoder.py | 15 +- .../transformation/_one_hot_encoder.py | 18 +- .../tabular/transformation/_range_scaler.py | 13 +- .../tabular/transformation/_robust_scaler.py | 16 +- .../_sequential_table_transformer.py | 26 +- .../tabular/transformation/_simple_imputer.py | 9 +- .../transformation/_standard_scaler.py | 13 +- .../transformation/_table_transformer.py | 2 +- src/safeds/data/tabular/typing/__init__.py | 6 +- .../data/tabular/typing/_column_type.py | 321 ++++ src/safeds/data/tabular/typing/_data_type.py | 75 - ...rs_data_type.py => _polars_column_type.py} | 42 +- .../data/tabular/typing/_polars_schema.py | 100 -- src/safeds/data/tabular/typing/_schema.py | 153 +- src/safeds/exceptions/__init__.py | 68 +- src/safeds/exceptions/_data.py | 51 - src/safeds/exceptions/_ml.py | 7 - .../_bases/_support_vector_machine_base.py | 6 +- src/safeds/ml/classical/_supervised_model.py | 34 +- .../classification/_ada_boost_classifier.py | 8 +- .../classification/_baseline_classifier.py | 8 +- .../classical/classification/_classifier.py | 31 +- .../_decision_tree_classifier.py | 7 +- .../_support_vector_classifier.py | 4 +- src/safeds/ml/classical/regression/_arima.py | 10 +- .../regression/_baseline_regressor.py | 8 +- .../regression/_decision_tree_regressor.py | 7 +- .../ml/classical/regression/_regressor.py | 39 +- .../regression/_support_vector_regressor.py | 4 +- src/safeds/ml/metrics/__init__.py | 4 +- .../ml/metrics/_classification_metrics.py | 4 +- src/safeds/ml/metrics/_regression_metrics.py | 4 +- src/safeds/ml/nn/_model.py | 44 +- .../_input_converter_time_series.py | 36 +- src/safeds/ml/nn/layers/__init__.py | 8 +- tests/conftest.py | 1 - tests/helpers/__init__.py | 8 +- tests/helpers/_assertions.py | 27 +- tests/helpers/_devices.py | 3 +- tests/helpers/_resources.py | 2 + .../{emptytable.csv => csv/empty.csv} | 0 tests/resources/csv/non-empty.csv | 2 + .../{table.csv => csv/special-character.csv} | 0 tests/resources/dummy_excel_file.xlsx | Bin 4927 -> 0 bytes tests/resources/empty_excel_file.xlsx | Bin 6596 -> 0 bytes tests/resources/emptytable.json | 1 - tests/resources/json/empty.json | 1 + tests/resources/json/non-empty.json | 6 + .../special-character.json} | 0 tests/resources/parquet/empty.parquet | Bin 0 -> 135 bytes tests/resources/parquet/non-empty.parquet | Bin 0 -> 844 bytes .../parquet/special-character.parquet | Bin 0 -> 791 bytes tests/safeds/_config/test_torch.py | 2 +- tests/safeds/_utils/test_hashing.py | 1 + tests/safeds/_validation/__init__.py | 0 .../_validation/test_get_similar_columns.py | 39 + .../data/image/typing/test_image_size.py | 4 +- .../_tabular_dataset/test_extras.py | 4 +- .../_tabular_dataset/test_features.py | 5 +- .../containers/_tabular_dataset/test_hash.py | 1 + .../containers/_tabular_dataset/test_init.py | 7 +- .../_tabular_dataset/test_into_dataloader.py | 4 +- .../_tabular_dataset/test_repr_html.py | 7 +- .../_tabular_dataset/test_sizeof.py | 1 + .../_tabular_dataset/test_target.py | 3 +- .../_tabular_dataset/test_to_table.py | 6 +- .../_time_series_dataset/test_continuous.py | 5 +- .../_time_series_dataset/test_extras.py | 4 +- .../_time_series_dataset/test_features.py | 5 +- .../_time_series_dataset/test_hash.py | 1 + .../_time_series_dataset/test_init.py | 7 +- .../test_into_dataloader.py | 206 ++- .../_time_series_dataset/test_repr_html.py | 7 +- .../_time_series_dataset/test_sizeof.py | 1 + .../_time_series_dataset/test_target.py | 3 +- .../_time_series_dataset/test_to_table.py | 10 +- .../labeled/containers/test_image_dataset.py | 5 +- .../data/tabular/containers/_cell/test_add.py | 2 +- .../data/tabular/containers/_cell/test_and.py | 2 +- .../data/tabular/containers/_cell/test_div.py | 2 +- .../data/tabular/containers/_cell/test_eq.py | 2 +- .../containers/_cell/test_first_not_none.py | 1 + .../tabular/containers/_cell/test_floordiv.py | 2 +- .../data/tabular/containers/_cell/test_ge.py | 2 +- .../data/tabular/containers/_cell/test_gt.py | 2 +- .../data/tabular/containers/_cell/test_le.py | 2 +- .../data/tabular/containers/_cell/test_lt.py | 2 +- .../data/tabular/containers/_cell/test_mod.py | 2 +- .../data/tabular/containers/_cell/test_mul.py | 2 +- .../data/tabular/containers/_cell/test_ne.py | 2 +- .../data/tabular/containers/_cell/test_or.py | 2 +- .../data/tabular/containers/_cell/test_pow.py | 2 +- .../tabular/containers/_cell/test_sizeof.py | 1 + .../data/tabular/containers/_cell/test_sub.py | 2 +- .../data/tabular/containers/_cell/test_xor.py | 2 +- .../tabular/containers/_column/test_all.py | 1 + .../tabular/containers/_column/test_any.py | 1 + .../containers/_column/test_contains.py | 1 + .../_column/test_correlation_with.py | 5 +- .../containers/_column/test_count_if.py | 1 + .../_column/test_distinct_value_count.py | 1 + .../_column/test_from_polars_series.py | 1 + .../_column/test_get_distinct_values.py | 1 + .../containers/_column/test_get_value.py | 1 + .../containers/_column/test_getitem.py | 1 + .../tabular/containers/_column/test_hash.py | 4 +- .../tabular/containers/_column/test_idness.py | 1 + .../containers/_column/test_is_numeric.py | 23 - .../containers/_column/test_is_temporal.py | 29 - .../tabular/containers/_column/test_iter.py | 1 + .../tabular/containers/_column/test_len.py | 1 + .../tabular/containers/_column/test_max.py | 1 + .../tabular/containers/_column/test_mean.py | 1 + .../tabular/containers/_column/test_median.py | 1 + .../tabular/containers/_column/test_min.py | 1 + .../_column/test_missing_value_count.py | 1 + .../_column/test_missing_value_ratio.py | 1 + .../tabular/containers/_column/test_mode.py | 1 + .../tabular/containers/_column/test_none.py | 1 + .../containers/_column/test_plot_box_plot.py | 3 +- .../containers/_column/test_plot_histogram.py | 3 +- .../containers/_column/test_plot_lag_plot.py | 3 +- .../_column/test_plot_violin_plot.py | 3 +- .../tabular/containers/_column/test_repr.py | 1 + .../containers/_column/test_repr_html.py | 1 + .../containers/_column/test_row_count.py | 1 + .../tabular/containers/_column/test_sizeof.py | 1 + .../containers/_column/test_stability.py | 1 + .../_column/test_standard_deviation.py | 1 + .../tabular/containers/_column/test_str.py | 1 + .../_column/test_summarize_statistics.py | 221 +-- .../containers/_column/test_to_list.py | 1 + .../containers/_column/test_to_table.py | 1 + .../containers/_column/test_transform.py | 1 + .../tabular/containers/_column/test_type.py | 2 +- .../containers/_column/test_variance.py | 1 + .../containers/_row/test_get_column_type.py | 10 +- .../containers/_string_cell/test_sizeof.py | 1 + .../containers/_string_cell/test_substring.py | 2 +- .../_table/__snapshots__/test_hash.ambr | 10 + ...h_snapshot[four columns (all numeric)].png | Bin 20427 -> 0 bytes ...pshot[four columns (some non-numeric)].png | Bin 12084 -> 0 bytes ...test_should_match_snapshot[one column].png | Bin 7951 -> 0 bytes .../test_should_match_snapshot[normal].png | Bin 11831 -> 0 bytes ...st_should_match_snapshot[four columns].png | Bin 20479 -> 0 bytes ...test_should_match_snapshot[one column].png | Bin 7705 -> 0 bytes ...columns with compressed visualization].png | Bin 14032 -> 0 bytes .../test_should_match_snapshot.1.png | Bin 28546 -> 0 bytes ...test_should_match_snapshot[functional].png | Bin 21296 -> 0 bytes ..._should_match_snapshot[sorted grouped].png | Bin 22212 -> 0 bytes ...hould_match_snapshot[unsorted grouped].png | Bin 27731 -> 0 bytes ...test_should_match_snapshot[functional].png | Bin 12643 -> 0 bytes ...est_should_match_snapshot[overlapping].png | Bin 11230 -> 0 bytes .../containers/_table/test_add_column.py | 51 - .../containers/_table/test_add_columns.py | 182 ++- .../_table/test_add_computed_column.py | 69 + .../_table/test_add_index_column.py | 74 + .../_table/test_add_tables_as_columns.py | 135 ++ .../_table/test_add_tables_as_rows.py | 121 ++ .../containers/_table/test_column_count.py | 8 +- .../containers/_table/test_column_names.py | 12 +- .../containers/_table/test_count_rows_if.py | 6 +- .../containers/_table/test_dataframe.py | 7 +- .../data/tabular/containers/_table/test_eq.py | 118 +- .../containers/_table/test_filter_rows.py | 63 + .../_table/test_filter_rows_by_column.py | 69 + .../containers/_table/test_from_columns.py | 16 +- .../containers/_table/test_from_csv_file.py | 52 +- .../containers/_table/test_from_dict.py | 12 +- .../containers/_table/test_from_json_file.py | 52 +- .../_table/test_from_parquet_file.py | 45 + .../_table/test_from_polars_data_frame.py | 25 + .../_table/test_from_polars_dataframe.py | 79 - .../_table/test_from_polars_lazy_frame.py | 25 + .../containers/_table/test_get_column.py | 26 +- .../containers/_table/test_get_column_type.py | 30 + .../_table/test_get_similar_columns.py | 38 - .../containers/_table/test_has_column.py | 8 +- .../tabular/containers/_table/test_hash.py | 88 +- .../tabular/containers/_table/test_init.py | 23 +- .../_table/test_inverse_transform_table.py | 145 +- .../tabular/containers/_table/test_join.py | 311 +++- .../containers/_table/test_remove_columns.py | 113 +- .../_table/test_remove_columns_except.py | 55 - ...test_remove_columns_with_missing_values.py | 84 +- .../_table/test_remove_duplicate_rows.py | 17 +- .../_table/test_remove_non_numeric_columns.py | 63 +- .../containers/_table/test_remove_rows.py | 65 +- .../_table/test_remove_rows_by_column.py | 69 + .../test_remove_rows_with_missing_values.py | 91 +- .../_table/test_remove_rows_with_outliers.py | 340 ++--- .../containers/_table/test_rename_column.py | 52 +- .../containers/_table/test_replace_column.py | 210 +-- .../tabular/containers/_table/test_repr.py | 34 +- .../containers/_table/test_repr_html.py | 58 +- .../containers/_table/test_row_count.py | 2 +- .../containers/_table/test_select_columns.py | 82 + .../containers/_table/test_shuffle_rows.py | 43 +- .../tabular/containers/_table/test_sizeof.py | 8 +- .../containers/_table/test_slice_rows.py | 47 +- .../containers/_table/test_sort_rows.py | 72 +- .../_table/test_sort_rows_by_column.py | 62 + .../containers/_table/test_split_rows.py | 126 +- .../tabular/containers/_table/test_str.py | 34 +- .../_table/test_summarize_statistics.py | 226 ++- .../containers/_table/test_to_columns.py | 27 +- .../containers/_table/test_to_csv_file.py | 77 +- .../tabular/containers/_table/test_to_dict.py | 28 +- .../containers/_table/test_to_json_file.py | 72 +- .../containers/_table/test_to_parquet_file.py | 50 + .../_table/test_to_tabular_dataset.py | 151 ++ .../_table/test_to_time_series_dataset.py | 1 + .../_table/test_transform_column.py | 79 +- .../containers/_table/test_transform_table.py | 147 +- .../containers/_temporal_cell/test_sizeof.py | 1 + .../data/tabular/containers/test_row.py | 518 ------- .../plotting/test_moving_average_plot.py | 3 +- .../transformation/test_discretizer.py | 6 +- .../test_functional_table_transformer.py | 1 + .../test_k_nearest_neighbors_imputer.py | 4 +- .../transformation/test_label_encoder.py | 7 +- .../transformation/test_one_hot_encoder.py | 7 +- .../transformation/test_range_scaler.py | 7 +- .../transformation/test_robust_scaler.py | 14 +- .../test_sequential_table_transformer.py | 22 +- .../transformation/test_simple_imputer.py | 5 +- .../transformation/test_standard_scaler.py | 14 +- .../transformation/test_table_transformer.py | 7 +- .../tabular/typing/_column_type/__init__.py | 0 .../_column_type/__snapshots__/test_hash.ambr | 55 + .../tabular/typing/_column_type/test_eq.py | 89 ++ .../tabular/typing/_column_type/test_hash.py | 86 ++ .../typing/_column_type/test_is_float.py | 50 + .../typing/_column_type/test_is_int.py | 50 + .../typing/_column_type/test_is_numeric.py | 50 + .../typing/_column_type/test_is_signed_int.py | 50 + .../typing/_column_type/test_is_temporal.py | 50 + .../_column_type/test_is_unsigned_int.py | 50 + .../tabular/typing/_column_type/test_repr.py | 50 + .../typing/_column_type/test_sizeof.py | 52 + .../tabular/typing/_column_type/test_str.py | 50 + .../data/tabular/typing/_schema/__init__.py | 0 .../_schema/__snapshots__/test_hash.ambr | 10 + .../typing/_schema/test_column_count.py | 20 + .../typing/_schema/test_column_names.py | 20 + .../data/tabular/typing/_schema/test_eq.py | 107 ++ .../typing/_schema/test_get_column_type.py | 29 + .../tabular/typing/_schema/test_has_column.py | 16 + .../data/tabular/typing/_schema/test_hash.py | 78 + .../data/tabular/typing/_schema/test_repr.py | 29 + .../typing/_schema/test_repr_markdown.py | 29 + .../tabular/typing/_schema/test_sizeof.py | 22 + .../data/tabular/typing/_schema/test_str.py | 29 + .../tabular/typing/_schema/test_to_dict.py | 29 + .../data/tabular/typing/test_data_type.py | 124 -- .../safeds/data/tabular/typing/test_schema.py | 517 ------- .../test_index_out_of_bounds_error.py | 1 + .../exceptions/test_out_of_bounds_error.py | 154 -- .../test_unknown_column_name_error.py | 43 - .../classification/test_ada_boost.py | 5 +- .../test_baseline_classifier.py | 5 +- .../classification/test_classifier.py | 33 +- .../classification/test_decision_tree.py | 12 +- .../classification/test_gradient_boosting.py | 5 +- .../test_k_nearest_neighbors.py | 5 +- .../test_logistic_classifier.py | 5 +- .../classification/test_random_forest.py | 5 +- .../test_support_vector_machine.py | 5 +- .../ml/classical/regression/test_ada_boost.py | 5 +- .../classical/regression/test_arima_model.py | 114 +- .../regression/test_baseline_regressor.py | 5 +- .../regression/test_decision_tree.py | 11 +- .../regression/test_gradient_boosting.py | 5 +- .../regression/test_k_nearest_neighbors.py | 5 +- .../regression/test_linear_regressor.py | 5 +- .../regression/test_random_forest.py | 5 +- .../ml/classical/regression/test_regressor.py | 30 +- .../regression/test_support_vector_machine.py | 5 +- .../safeds/ml/classical/test_util_sklearn.py | 2 +- .../safeds/ml/hyperparameters/test_choice.py | 1 + .../test_input_converter_time_series.py | 10 +- .../nn/layers/test_convolutional2d_layer.py | 3 +- .../safeds/ml/nn/layers/test_forward_layer.py | 3 +- tests/safeds/ml/nn/layers/test_gru_layer.py | 3 +- tests/safeds/ml/nn/layers/test_lstm_layer.py | 3 +- tests/safeds/ml/nn/test_forward_workflow.py | 2 +- tests/safeds/ml/nn/test_lstm_workflow.py | 21 +- tests/safeds/ml/nn/test_model.py | 16 +- .../ml/nn/typing/test_model_image_size.py | 4 +- 329 files changed, 7703 insertions(+), 5154 deletions(-) create mode 100644 src/safeds/_utils/_collections.py rename src/safeds/_validation/{_check_bounds.py => _check_bounds_module.py} (94%) rename src/safeds/_validation/{_check_columns_are_numeric.py => _check_column_is_numeric_module.py} (89%) rename src/safeds/_validation/{_check_columns_dont_exist.py => _check_columns_dont_exist_module.py} (68%) rename src/safeds/_validation/{_check_columns_exist.py => _check_columns_exist_module.py} (77%) create mode 100644 src/safeds/_validation/_check_row_counts_are_equal_module.py create mode 100644 src/safeds/_validation/_check_schema_module.py rename src/safeds/_validation/{_normalize_and_check_file_path.py => _normalize_and_check_file_path_module.py} (79%) create mode 100644 src/safeds/data/tabular/typing/_column_type.py delete mode 100644 src/safeds/data/tabular/typing/_data_type.py rename src/safeds/data/tabular/typing/{_polars_data_type.py => _polars_column_type.py} (52%) delete mode 100644 src/safeds/data/tabular/typing/_polars_schema.py rename tests/resources/{emptytable.csv => csv/empty.csv} (100%) create mode 100644 tests/resources/csv/non-empty.csv rename tests/resources/{table.csv => csv/special-character.csv} (100%) delete mode 100644 tests/resources/dummy_excel_file.xlsx delete mode 100644 tests/resources/empty_excel_file.xlsx delete mode 100644 tests/resources/emptytable.json create mode 100644 tests/resources/json/empty.json create mode 100644 tests/resources/json/non-empty.json rename tests/resources/{table.json => json/special-character.json} (100%) create mode 100644 tests/resources/parquet/empty.parquet create mode 100644 tests/resources/parquet/non-empty.parquet create mode 100644 tests/resources/parquet/special-character.parquet create mode 100644 tests/safeds/_validation/__init__.py create mode 100644 tests/safeds/_validation/test_get_similar_columns.py delete mode 100644 tests/safeds/data/tabular/containers/_column/test_is_numeric.py delete mode 100644 tests/safeds/data/tabular/containers/_column/test_is_temporal.py create mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_hash.ambr delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_boxplots/test_should_match_snapshot[four columns (all numeric)].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_boxplots/test_should_match_snapshot[four columns (some non-numeric)].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_boxplots/test_should_match_snapshot[one column].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_correlation_heatmap/test_should_match_snapshot[normal].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_histograms/test_should_match_snapshot[four columns].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_histograms/test_should_match_snapshot[one column].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_histograms/test_should_match_snapshot[two columns with compressed visualization].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot.1.png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot[functional].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot[sorted grouped].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot[unsorted grouped].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_scatterplot/test_should_match_snapshot[functional].png delete mode 100644 tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_scatterplot/test_should_match_snapshot[overlapping].png delete mode 100644 tests/safeds/data/tabular/containers/_table/test_add_column.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_add_computed_column.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_add_index_column.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_add_tables_as_columns.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_add_tables_as_rows.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_filter_rows.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_filter_rows_by_column.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_from_parquet_file.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_from_polars_data_frame.py delete mode 100644 tests/safeds/data/tabular/containers/_table/test_from_polars_dataframe.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_from_polars_lazy_frame.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_get_column_type.py delete mode 100644 tests/safeds/data/tabular/containers/_table/test_get_similar_columns.py delete mode 100644 tests/safeds/data/tabular/containers/_table/test_remove_columns_except.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_remove_rows_by_column.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_select_columns.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_sort_rows_by_column.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_to_parquet_file.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_to_tabular_dataset.py create mode 100644 tests/safeds/data/tabular/containers/_table/test_to_time_series_dataset.py delete mode 100644 tests/safeds/data/tabular/containers/test_row.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/__init__.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/__snapshots__/test_hash.ambr create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_eq.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_hash.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_is_float.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_is_int.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_is_numeric.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_is_signed_int.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_is_temporal.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_is_unsigned_int.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_repr.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_sizeof.py create mode 100644 tests/safeds/data/tabular/typing/_column_type/test_str.py create mode 100644 tests/safeds/data/tabular/typing/_schema/__init__.py create mode 100644 tests/safeds/data/tabular/typing/_schema/__snapshots__/test_hash.ambr create mode 100644 tests/safeds/data/tabular/typing/_schema/test_column_count.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_column_names.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_eq.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_get_column_type.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_has_column.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_hash.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_repr.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_repr_markdown.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_sizeof.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_str.py create mode 100644 tests/safeds/data/tabular/typing/_schema/test_to_dict.py delete mode 100644 tests/safeds/data/tabular/typing/test_data_type.py delete mode 100644 tests/safeds/data/tabular/typing/test_schema.py delete mode 100644 tests/safeds/exceptions/test_out_of_bounds_error.py delete mode 100644 tests/safeds/exceptions/test_unknown_column_name_error.py diff --git a/benchmarks/metrics/classification.py b/benchmarks/metrics/classification.py index da7551bb0..c6f5b5656 100644 --- a/benchmarks/metrics/classification.py +++ b/benchmarks/metrics/classification.py @@ -3,10 +3,10 @@ from timeit import timeit import polars as pl -from safeds.data.tabular.containers import Table -from safeds.ml.metrics import ClassificationMetrics from benchmarks.table.utils import create_synthetic_table +from safeds.data.tabular.containers import Table +from safeds.ml.metrics import ClassificationMetrics REPETITIONS = 10 diff --git a/benchmarks/table/column_operations.py b/benchmarks/table/column_operations.py index dbbb30742..016abdf0b 100644 --- a/benchmarks/table/column_operations.py +++ b/benchmarks/table/column_operations.py @@ -1,8 +1,7 @@ from timeit import timeit -from safeds.data.tabular.containers import Table - from benchmarks.table.utils import create_synthetic_table +from safeds.data.tabular.containers import Table REPETITIONS = 10 diff --git a/docs/tutorials/classification.ipynb b/docs/tutorials/classification.ipynb index f0cecce53..361c4d5d2 100644 --- a/docs/tutorials/classification.ipynb +++ b/docs/tutorials/classification.ipynb @@ -3,7 +3,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "In this tutorial, we use `safeds` on **Titanic passenger data** to predict who will survive and who will not." @@ -12,7 +15,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "### Loading Data\n", @@ -23,7 +29,10 @@ "cell_type": "code", "execution_count": 1, "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [ { @@ -75,7 +84,7 @@ "from safeds.data.tabular.containers import Table\n", "\n", "raw_data = Table.from_csv_file(\"data/titanic.csv\")\n", - "#For visualisation purposes we only print out the first 15 rows.\n", + "# For visualisation purposes we only print out the first 15 rows.\n", "raw_data.slice_rows(length=15)" ] }, @@ -169,18 +178,18 @@ "source": [ "We remove certain columns for the following reasons:\n", "1. **high idness**: `id` , `ticket`\n", - "2. **high stability**: `parents_children` \n", + "2. **high stability**: `parents_children`\n", "3. **high missing value ratio**: `cabin`" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "train_table = train_table.remove_columns([\"id\",\"ticket\", \"parents_children\", \"cabin\"])\n", - "test_table = test_table.remove_columns([\"id\",\"ticket\", \"parents_children\", \"cabin\"])" + "train_table = train_table.remove_columns([\"id\", \"ticket\", \"parents_children\", \"cabin\"])\n", + "test_table = test_table.remove_columns([\"id\", \"ticket\", \"parents_children\", \"cabin\"])" ] }, { @@ -199,7 +208,7 @@ "source": [ "from safeds.data.tabular.transformation import SimpleImputer\n", "\n", - "simple_imputer = SimpleImputer(column_names=[\"age\",\"fare\"],strategy=SimpleImputer.Strategy.mean())\n", + "simple_imputer = SimpleImputer(column_names=[\"age\", \"fare\"], strategy=SimpleImputer.Strategy.mean())\n", "fitted_simple_imputer_train, transformed_train_data = simple_imputer.fit_and_transform(train_table)\n", "transformed_test_data = fitted_simple_imputer_train.transform(test_table)" ] @@ -207,7 +216,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "### Handling Nominal Categorical Data\n", @@ -219,13 +231,18 @@ "cell_type": "code", "execution_count": 6, "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ "from safeds.data.tabular.transformation import OneHotEncoder\n", "\n", - "fitted_one_hot_encoder_train, transformed_train_data = OneHotEncoder(column_names=[\"sex\", \"port_embarked\"]).fit_and_transform(transformed_train_data)\n", + "fitted_one_hot_encoder_train, transformed_train_data = OneHotEncoder(\n", + " column_names=[\"sex\", \"port_embarked\"],\n", + ").fit_and_transform(transformed_train_data)\n", "transformed_test_data = fitted_one_hot_encoder_train.transform(transformed_test_data)" ] }, @@ -299,7 +316,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "### Marking the Target Column\n", @@ -314,17 +334,23 @@ "cell_type": "code", "execution_count": 8, "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "tagged_train_table = transformed_train_data.to_tabular_dataset(\"survived\",extra_names=[\"name\"])" + "tagged_train_table = transformed_train_data.to_tabular_dataset(\"survived\", extra_names=[\"name\"])" ] }, { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "### Fitting a Classifier\n", @@ -335,7 +361,10 @@ "cell_type": "code", "execution_count": 9, "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ @@ -348,7 +377,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "### Predicting with the Classifier\n", @@ -360,7 +392,10 @@ "cell_type": "code", "execution_count": 10, "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ @@ -433,14 +468,17 @@ ], "source": [ "reverse_transformed_prediction = prediction.to_table().inverse_transform_table(fitted_one_hot_encoder_train)\n", - "#For visualisation purposes we only print out the first 15 rows.\n", + "# For visualisation purposes we only print out the first 15 rows.\n", "reverse_transformed_prediction.slice_rows(length=15)" ] }, { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "### Testing the Accuracy of the Model\n", @@ -449,28 +487,18 @@ }, { "cell_type": "code", - "execution_count": 12, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Accuracy on test data: 79.3893%\n" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "accuracy = fitted_classifier.accuracy(transformed_test_data) * 100\n", - "print(f'Accuracy on test data: {accuracy:.4f}%')" + "f\"Accuracy on test data: {accuracy:.4f}%\"" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -488,5 +516,5 @@ } }, "nbformat": 4, - "nbformat_minor": 0 + "nbformat_minor": 4 } diff --git a/docs/tutorials/convolutional_neural_network_for_image_classification.ipynb b/docs/tutorials/convolutional_neural_network_for_image_classification.ipynb index 77a15821e..8c34f9b6e 100644 --- a/docs/tutorials/convolutional_neural_network_for_image_classification.ipynb +++ b/docs/tutorials/convolutional_neural_network_for_image_classification.ipynb @@ -2,14 +2,18 @@ "cells": [ { "cell_type": "markdown", - "source": [], + "id": "6d2c03ea985891aa", "metadata": { "collapsed": false }, - "id": "6d2c03ea985891aa" + "source": [] }, { "cell_type": "markdown", + "id": "d65ab4a80f6bc842", + "metadata": { + "collapsed": false + }, "source": [ "# Image Classification with Convolutional Neural Networks\n", "\n", @@ -21,119 +25,121 @@ " All operations on an NeuralNetworkClassifier return a new NeuralNetworkClassifier. The original NeuralNetworkClassifier will not be changed.\n", "

\n", "" - ], - "metadata": { - "collapsed": false - }, - "id": "d65ab4a80f6bc842" + ] }, { "cell_type": "markdown", - "source": "## Load data into an `ImageDataset`", + "id": "74dacfa56deeaed3", "metadata": { "collapsed": false }, - "id": "74dacfa56deeaed3" + "source": "## Load data into an `ImageDataset`" }, { "cell_type": "markdown", - "source": [ - "1. Load images via files in an `ImageList`. The data is available under `docs/tutorials/data/shapes`. If the `return_filenames` parameter is set to `True`, a list of all filepaths will be returned as well in the same order as the images in the returned `ImageList`." - ], + "id": "90dfbc18037f0201", "metadata": { "collapsed": false }, - "id": "90dfbc18037f0201" + "source": [ + "1. Load images via files in an `ImageList`. The data is available under `docs/tutorials/data/shapes`. If the `return_filenames` parameter is set to `True`, a list of all filepaths will be returned as well in the same order as the images in the returned `ImageList`." + ] }, { "cell_type": "code", + "execution_count": null, "id": "initial_id", "metadata": { "collapsed": true }, + "outputs": [], "source": [ "from safeds.data.image.containers import ImageList\n", "\n", "images, filepaths = ImageList.from_files(\"data/shapes\", return_filenames=True)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "source": [ - "2. Create a `Column` with the labels of the images:" - ], + "id": "76bc8612f449edf", "metadata": { "collapsed": false }, - "id": "76bc8612f449edf" + "source": [ + "2. Create a `Column` with the labels of the images:" + ] }, { "cell_type": "code", + "execution_count": null, + "id": "66dcf95a3fa51f23", + "metadata": { + "collapsed": false + }, + "outputs": [], "source": [ "import re\n", + "\n", "from safeds.data.tabular.containers import Column\n", "\n", "labels = Column(\n", - " \"label\", \n", - " [re.search(r\"(.*)[\\\\/](.*)[\\\\/](.*)\\.\", filepath).group(2) for filepath in filepaths]\n", + " \"label\",\n", + " [re.search(r\"(.*)[\\\\/](.*)[\\\\/](.*)\\.\", filepath).group(2) for filepath in filepaths],\n", ")" - ], - "metadata": { - "collapsed": false - }, - "id": "66dcf95a3fa51f23", - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "source": [ - "3. Create an `ImageDataset` from the `ImageList` and the `Column` of labels. If the `shuffle` parameter is set to `True`, the `ImageDataset` will be shuffled after each epoch while training a neural network." - ], + "id": "596b0c9ec9627ad0", "metadata": { "collapsed": false }, - "id": "596b0c9ec9627ad0" + "source": [ + "3. Create an `ImageDataset` from the `ImageList` and the `Column` of labels. If the `shuffle` parameter is set to `True`, the `ImageDataset` will be shuffled after each epoch while training a neural network." + ] }, { "cell_type": "code", - "source": [ - "from safeds.data.labeled.containers import ImageDataset\n", - "\n", - "dataset = ImageDataset[Column](images, labels, shuffle=True)" - ], + "execution_count": null, + "id": "32056ddf5396e070", "metadata": { "collapsed": false }, - "id": "32056ddf5396e070", "outputs": [], - "execution_count": null + "source": [ + "from safeds.data.labeled.containers import ImageDataset\n", + "\n", + "dataset = ImageDataset[Column](images, labels, shuffle=True)" + ] }, { "cell_type": "markdown", - "source": "## Create the neural network with a `NeuralNetworkClassifier`", + "id": "358bd4cc05c8daf3", "metadata": { "collapsed": false }, - "id": "358bd4cc05c8daf3" + "source": "## Create the neural network with a `NeuralNetworkClassifier`" }, { "cell_type": "markdown", - "source": [ - "1. Create a list of `Layer` instances for your neural network:" - ], + "id": "fe40c93a1cfd3a7b", "metadata": { "collapsed": false }, - "id": "fe40c93a1cfd3a7b" + "source": [ + "1. Create a list of `Layer` instances for your neural network:" + ] }, { "cell_type": "code", + "execution_count": null, + "id": "806a8091249d533a", + "metadata": { + "collapsed": false + }, + "outputs": [], "source": [ - "from safeds.ml.nn.layers import (Convolutional2DLayer, FlattenLayer,\n", - " ForwardLayer, MaxPooling2DLayer)\n", + "from safeds.ml.nn.layers import Convolutional2DLayer, FlattenLayer, ForwardLayer, MaxPooling2DLayer\n", "\n", "layers = [\n", " Convolutional2DLayer(output_channel=16, kernel_size=3, padding=1),\n", @@ -144,155 +150,151 @@ " ForwardLayer(neuron_count=128),\n", " ForwardLayer(neuron_count=3),\n", "]" - ], - "metadata": { - "collapsed": false - }, - "id": "806a8091249d533a", - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "source": "2. Create a `NeuralNetworkClassifier` from an `InputConversion`, the list of `Layer` instances:", + "id": "fe4f6a4d14404a85", "metadata": { "collapsed": false }, - "id": "fe4f6a4d14404a85" + "source": "2. Create a `NeuralNetworkClassifier` from an `InputConversion`, the list of `Layer` instances:" }, { "cell_type": "code", + "execution_count": null, + "id": "af68cc0d32655d32", + "metadata": { + "collapsed": false + }, + "outputs": [], "source": [ "from safeds.ml.nn import NeuralNetworkClassifier\n", "from safeds.ml.nn.converters import InputConversionImageToColumn\n", "\n", "cnn = NeuralNetworkClassifier[ImageDataset[Column], ImageList](\n", - " InputConversionImageToColumn(dataset.input_size), \n", + " InputConversionImageToColumn(dataset.input_size),\n", " layers,\n", ")" - ], - "metadata": { - "collapsed": false - }, - "id": "af68cc0d32655d32", - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "source": "## Fit and predict the `NeuralNetworkClassifier`", + "id": "4f9387686ba50c37", "metadata": { "collapsed": false }, - "id": "4f9387686ba50c37" + "source": "## Fit and predict the `NeuralNetworkClassifier`" }, { "cell_type": "markdown", - "source": [ - "1. Fit the `NeuralNetworkClassifier`:" - ], + "id": "3d8efa74951725cb", "metadata": { "collapsed": false }, - "id": "3d8efa74951725cb" + "source": [ + "1. Fit the `NeuralNetworkClassifier`:" + ] }, { + "cell_type": "code", + "execution_count": null, + "id": "381627a94d500675", "metadata": { "collapsed": false }, - "cell_type": "code", - "source": "cnn_fitted = cnn.fit(dataset, epoch_count=8, batch_size=16)", - "id": "381627a94d500675", "outputs": [], - "execution_count": null + "source": [ + "cnn_fitted = cnn.fit(dataset, epoch_count=8, batch_size=16)" + ] }, { "cell_type": "markdown", - "source": [ - "2. Predict values from the `NeuralNetworkClassifier`:" - ], + "id": "35bb7d0ebfabf597", "metadata": { "collapsed": false }, - "id": "35bb7d0ebfabf597" + "source": [ + "2. Predict values from the `NeuralNetworkClassifier`:" + ] }, { "cell_type": "code", - "source": [ - "prediction = cnn_fitted.predict(dataset.get_input())" - ], + "execution_count": null, + "id": "62f63dd68362c8b7", "metadata": { "collapsed": false }, - "id": "62f63dd68362c8b7", "outputs": [], - "execution_count": null + "source": [ + "prediction = cnn_fitted.predict(dataset.get_input())" + ] }, { "cell_type": "markdown", - "source": [ - "3. Shuffle the prediction to get a random order:" - ], + "id": "a8ecd71982a0cc97", "metadata": { "collapsed": false }, - "id": "a8ecd71982a0cc97" + "source": [ + "3. Shuffle the prediction to get a random order:" + ] }, { "cell_type": "code", - "source": [ - "shuffled_prediction = prediction.shuffle()" - ], + "execution_count": null, + "id": "779277d73e30554d", "metadata": { "collapsed": false }, - "id": "779277d73e30554d", "outputs": [], - "execution_count": null + "source": [ + "shuffled_prediction = prediction.shuffle()" + ] }, { "cell_type": "markdown", - "source": [ - "4. Display a subset of the input data:" - ], + "id": "2c1ae7438df15cae", "metadata": { "collapsed": false }, - "id": "2c1ae7438df15cae" + "source": [ + "4. Display a subset of the input data:" + ] }, { "cell_type": "code", - "source": [ - "shuffled_prediction.get_input().remove_image_by_index(list(range(9, len(prediction))))" - ], + "execution_count": null, + "id": "a5ddbbfba41aa7f", "metadata": { "collapsed": false }, - "id": "a5ddbbfba41aa7f", "outputs": [], - "execution_count": null + "source": [ + "shuffled_prediction.get_input().remove_image_by_index(list(range(9, len(prediction))))" + ] }, { "cell_type": "markdown", - "source": [ - "5. Display the corresponding predicted labels:" - ], + "id": "131db684a431d4ec", "metadata": { "collapsed": false }, - "id": "131db684a431d4ec" + "source": [ + "5. Display the corresponding predicted labels:" + ] }, { "cell_type": "code", - "source": [ - "shuffled_prediction.get_output().to_list()[0:9]" - ], + "execution_count": null, + "id": "7081595d7100fb42", "metadata": { "collapsed": false }, - "id": "7081595d7100fb42", "outputs": [], - "execution_count": null + "source": [ + "shuffled_prediction.get_output().to_list()[0:9]" + ] } ], "metadata": { diff --git a/docs/tutorials/data_processing.ipynb b/docs/tutorials/data_processing.ipynb index ae9d61a6c..e52b5c721 100644 --- a/docs/tutorials/data_processing.ipynb +++ b/docs/tutorials/data_processing.ipynb @@ -113,7 +113,7 @@ "source": [ "titanic_slice = titanic.slice_rows(length=10)\n", "\n", - "titanic_slice # just to show the output" + "titanic_slice # just to show the output" ] }, { @@ -233,10 +233,12 @@ } ], "source": [ - "Table.from_columns([\n", - " titanic_slice.get_column(\"name\"),\n", - " titanic_slice.get_column(\"age\")\n", - "])" + "Table.from_columns(\n", + " [\n", + " titanic_slice.get_column(\"name\"),\n", + " titanic_slice.get_column(\"age\"),\n", + " ],\n", + ")" ] }, { @@ -296,14 +298,16 @@ } ], "source": [ - "titanic_slice.remove_columns([\n", - " \"id\",\n", - " \"name\",\n", - " \"ticket\",\n", - " \"cabin\",\n", - " \"port_embarked\",\n", - " \"survived\"\n", - "])" + "titanic_slice.remove_columns(\n", + " [\n", + " \"id\",\n", + " \"name\",\n", + " \"ticket\",\n", + " \"cabin\",\n", + " \"port_embarked\",\n", + " \"survived\",\n", + " ],\n", + ")" ] }, { @@ -363,7 +367,7 @@ } ], "source": [ - "titanic_slice.remove_columns_except([\"name\", \"survived\"])" + "titanic_slice.select_columns([\"name\", \"survived\"])" ] }, { @@ -434,7 +438,7 @@ ], "source": [ "titanic.remove_rows(\n", - " lambda row: row[\"age\"] < 1\n", + " lambda row: row[\"age\"] < 1,\n", ")" ] }, @@ -506,7 +510,9 @@ "source": [ "from safeds.data.tabular.transformation import SimpleImputer\n", "\n", - "imputer = SimpleImputer(SimpleImputer.Strategy.constant(0), column_names=[\"age\", \"fare\", \"cabin\", \"port_embarked\"]).fit(titanic)\n", + "imputer = SimpleImputer(SimpleImputer.Strategy.constant(0), column_names=[\"age\", \"fare\", \"cabin\", \"port_embarked\"]).fit(\n", + " titanic,\n", + ")\n", "imputer.transform(titanic_slice)" ] }, diff --git a/docs/tutorials/data_visualization.ipynb b/docs/tutorials/data_visualization.ipynb index 1304d2cdb..6a1b4ea39 100644 --- a/docs/tutorials/data_visualization.ipynb +++ b/docs/tutorials/data_visualization.ipynb @@ -122,7 +122,7 @@ "outputs": [], "source": [ "titanic_numerical = titanic.remove_columns(\n", - " [\"id\", \"name\", \"sex\", \"ticket\", \"cabin\", \"port_embarked\"]\n", + " [\"id\", \"name\", \"sex\", \"ticket\", \"cabin\", \"port_embarked\"],\n", ")" ] }, diff --git a/docs/tutorials/image_list_processing.ipynb b/docs/tutorials/image_list_processing.ipynb index 2f4556db5..3185c1d6b 100644 --- a/docs/tutorials/image_list_processing.ipynb +++ b/docs/tutorials/image_list_processing.ipynb @@ -25,37 +25,39 @@ }, { "cell_type": "markdown", - "source": [ - "1. Load images via files in an `ImageList`:" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "1. Load images via files in an `ImageList`:" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "from safeds.data.image.containers import ImageList\n", "\n", "planes = ImageList.from_files([\"data/plane.png\", \"data/small_plane.png\"])\n", "planes" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "source": [ - "2. Create an `ImageList` from multiple `Image` objects:" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "2. Create an `ImageList` from multiple `Image` objects:" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "from safeds.data.image.containers import Image\n", "\n", @@ -63,278 +65,278 @@ "small_plane = Image.from_file(\"data/small_plane.png\")\n", "\n", "ImageList.from_images([plane, small_plane])" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## Process the image\n", "\n", "1. Resize the images in the `ImageList` to have the width 200 and height 200:" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "planes.resize(200, 200)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "source": [ - "2. Convert the images in the `ImageList` to grayscale:" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "2. Convert the images in the `ImageList` to grayscale:" + ] }, { "cell_type": "code", - "source": [ - "planes.convert_to_grayscale()\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.convert_to_grayscale()" + ] }, { "cell_type": "markdown", - "source": [ - "3. Crop the images in the `ImageList` to be 200x200 with the top-left corner being at (115, 30):" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "3. Crop the images in the `ImageList` to be 200x200 with the top-left corner being at (115, 30):" + ] }, { "cell_type": "code", - "source": [ - "planes.crop(x=115, y=30, width=200, height=200)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.crop(x=115, y=30, width=200, height=200)" + ] }, { "cell_type": "markdown", - "source": [ - "4. Flip the images in the `ImageList` horizontally (or vertically):" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "4. Flip the images in the `ImageList` horizontally (or vertically):" + ] }, { "cell_type": "code", - "source": "planes.flip_left_and_right()\n", + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.flip_left_and_right()" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "5. Adjust the brightness of the images in the `ImageList`.\n", "\n", "Giving a factor below 1 will result in darker images, a factor above 1 will return brighter images." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "planes.adjust_brightness(1.5)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.adjust_brightness(1.5)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "6. Adjust the contrast of the images in the `ImageList`.\n", "\n", "Giving a factor below 1 will decrease the contrast of the images, a factor above 1 will increase the contrast of the images." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "planes.adjust_contrast(1.5)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.adjust_contrast(1.5)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "7. Adjust the color balance of the images in the `ImageList`.\n", "\n", "A factor of 0 will return black and white images, a factor of 1 will return a copy of the original `ImageList`." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "planes.adjust_color_balance(0.5)" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.adjust_color_balance(0.5)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "8. Blur the images in the `ImageList`:\n", "\n", "The higher the radius, the blurrier the images get.\n", "The radius defines the amount of pixels combined in each direction." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "planes.blur(5)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.blur(5)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "9. Sharpen the images in the `ImageList`:\n", "\n", "The factor defines the sharpness of the returned images." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "planes.sharpen(5)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.sharpen(5)" + ] }, { "cell_type": "markdown", - "source": [ - "10. Invert the colors of the images in the `ImageList`:" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "10. Invert the colors of the images in the `ImageList`:" + ] }, { "cell_type": "code", - "source": [ - "planes.invert_colors()\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.invert_colors()" + ] }, { "cell_type": "markdown", - "source": [ - "11. Rotate the images in the `ImageList` to the right (or the left):" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "11. Rotate the images in the `ImageList` to the right (or the left):" + ] }, { "cell_type": "code", - "source": [ - "planes.rotate_right()\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.rotate_right()" + ] }, { "cell_type": "markdown", - "source": [ - "12. Convert the images in the `ImageList` to grayscale and highlight the edges:" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "12. Convert the images in the `ImageList` to grayscale and highlight the edges:" + ] }, { "cell_type": "code", - "source": [ - "planes.find_edges()\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.find_edges()" + ] }, { "cell_type": "markdown", - "source": [ - "13. Add noise to the images in the `ImageList`. A higher `standard_deviation` will result in noisier images:\n" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "13. Add noise to the images in the `ImageList`. A higher `standard_deviation` will result in noisier images:\n" + ] }, { "cell_type": "code", - "source": [ - "planes.add_noise(standard_deviation=0.1)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "planes.add_noise(standard_deviation=0.1)" + ] } ], "metadata": { diff --git a/docs/tutorials/image_processing.ipynb b/docs/tutorials/image_processing.ipynb index 78ebb3310..49e5787cd 100644 --- a/docs/tutorials/image_processing.ipynb +++ b/docs/tutorials/image_processing.ipynb @@ -25,284 +25,286 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "from safeds.data.image.containers import Image\n", "\n", "plane = Image.from_file(\"data/plane.png\")\n", "plane" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## Process the image\n", "\n", "1. Resize the `Image` to have the width 284 and height 160:" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "plane.resize(284, 160)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "source": [ - "2. Convert the `Image` to grayscale:" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "2. Convert the `Image` to grayscale:" + ] }, { "cell_type": "code", - "source": [ - "plane.convert_to_grayscale()\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.convert_to_grayscale()" + ] }, { "cell_type": "markdown", - "source": [ - "3. Crop the `Image` to be 200x200 with the top-left corner being at (255, 30):" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "3. Crop the `Image` to be 200x200 with the top-left corner being at (255, 30):" + ] }, { "cell_type": "code", - "source": [ - "plane.crop(x=255, y=30, width=200, height=200)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.crop(x=255, y=30, width=200, height=200)" + ] }, { "cell_type": "markdown", - "source": [ - "4. Flip the `Image` horizontally (or vertically):" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "4. Flip the `Image` horizontally (or vertically):" + ] }, { "cell_type": "code", - "source": "plane.flip_left_and_right()\n", + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.flip_left_and_right()" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "5. Adjust the brightness of the `Image`.\n", "\n", "Giving a factor below 1 will result in a darker `Image`, a factor above 1 will return a brighter `Image`." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "plane.adjust_brightness(1.5)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.adjust_brightness(1.5)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "6. Adjust the contrast of the `Image`.\n", "\n", "Giving a factor below 1 will decrease the contrast of the `Image`, a factor above 1 will increase the contrast of the `Image`." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "plane.adjust_contrast(1.5)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.adjust_contrast(1.5)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "7. Adjust the color balance of the `Image`.\n", "\n", "A factor of 0 will return a black and white `Image`, a factor of 1 will return a copy of the original `Image`." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "plane.adjust_color_balance(0.5)" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.adjust_color_balance(0.5)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "8. Blur the `Image`:\n", "\n", "The higher the radius, the blurrier the `Image` gets.\n", "The radius defines the amount of pixels combined in each direction." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "plane.blur(5)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.blur(5)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "9. Sharpen the `Image`:\n", "\n", "The factor defines the sharpness of the returned `Image`." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "plane.sharpen(5)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.sharpen(5)" + ] }, { "cell_type": "markdown", - "source": [ - "10. Invert the colors of the `Image`:" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "10. Invert the colors of the `Image`:" + ] }, { "cell_type": "code", - "source": [ - "plane.invert_colors()\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.invert_colors()" + ] }, { "cell_type": "markdown", - "source": [ - "11. Rotate the `Image` to the right (or the left):" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "11. Rotate the `Image` to the right (or the left):" + ] }, { "cell_type": "code", - "source": [ - "plane.rotate_right()\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.rotate_right()" + ] }, { "cell_type": "markdown", - "source": [ - "12. Convert the `Image` to grayscale and highlight the edges:" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "12. Convert the `Image` to grayscale and highlight the edges:" + ] }, { "cell_type": "code", - "source": [ - "plane.find_edges()\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.find_edges()" + ] }, { "cell_type": "markdown", - "source": [ - "13. Add noise to the `Image`. A higher `standard_deviation` will result in a noisier `Image`:\n" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "13. Add noise to the `Image`. A higher `standard_deviation` will result in a noisier `Image`:\n" + ] }, { "cell_type": "code", - "source": [ - "plane.add_noise(standard_deviation=0.1)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], - "execution_count": null + "source": [ + "plane.add_noise(standard_deviation=0.1)" + ] } ], "metadata": { diff --git a/docs/tutorials/machine_learning.ipynb b/docs/tutorials/machine_learning.ipynb index 8f7c46cc2..54a9b5855 100644 --- a/docs/tutorials/machine_learning.ipynb +++ b/docs/tutorials/machine_learning.ipynb @@ -2,6 +2,9 @@ "cells": [ { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "# Machine Learning\n", "\n", @@ -11,99 +14,98 @@ "\n", "First, we need to create a `TabularDataset` from the training data. `TabularDataset`s are used to train supervised machine learning models, because they keep track of the target\n", "column. A `TabularDataset` can be created from a `Table` by calling the `to_tabular_dataset` method:" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], "source": [ "from safeds.data.tabular.containers import Table\n", "\n", - "training_set = Table({\n", - " \"a\": [3, 4, 8, 6, 5],\n", - " \"b\": [2, 2, 1, 6, 3],\n", - " \"c\": [1, 1, 1, 1, 1],\n", - " \"result\": [6, 7, 10, 13, 9]\n", - "})\n", + "training_set = Table(\n", + " {\n", + " \"a\": [3, 4, 8, 6, 5],\n", + " \"b\": [2, 2, 1, 6, 3],\n", + " \"c\": [1, 1, 1, 1, 1],\n", + " \"result\": [6, 7, 10, 13, 9],\n", + " },\n", + ")\n", "\n", "tabular_dataset = training_set.to_tabular_dataset(\n", - " target_name=\"result\"\n", + " \"result\",\n", ")" - ], - "metadata": { - "collapsed": false - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## Create and train model\n", "\n", "In this example, we want to predict the column `result`, which is the sum of `a`, `b`, and `c`. We will train a linear regression model with this training data. In Safe-DS, machine learning models are modeled as classes. First, their constructor must be called to configure hyperparameters, which returns a model object. Then, training is started by calling the `fit` method on the model object and passing the training data:" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], "source": [ "from safeds.ml.classical.regression import LinearRegressor\n", "\n", "model = LinearRegressor()\n", "fitted_model = model.fit(tabular_dataset)" - ], - "metadata": { - "collapsed": false - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## Predicting new values\n", "\n", "The `fit` method returns the fitted model, the original model is **not** changed. Predictions are made by calling the `predict` method on the fitted model. The `predict` method takes a `Table` as input and returns a `Table` with the predictions:" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "source": [ - "test_set = Table({\n", - " \"a\": [1, 1, 0, 2, 4],\n", - " \"b\": [2, 0, 5, 2, 7],\n", - " \"c\": [1, 4, 3, 2, 1]})\n", - "\n", - "fitted_model.predict(dataset=test_set)\n" - ], + "execution_count": null, "metadata": { "collapsed": false }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "test_set = Table({\"a\": [1, 1, 0, 2, 4], \"b\": [2, 0, 5, 2, 7], \"c\": [1, 4, 3, 2, 1]})\n", + "\n", + "fitted_model.predict(dataset=test_set)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## Metrics\n", "\n", "A machine learning metric, also known as an evaluation metric, is a measure used to assess the performance of a machine learning model on a test set or is used during cross-validation to gain insights about performance and compare different models or parameter settings.\n", "In `Safe-DS`, the available metrics are: `Accuracy`, `Confusion Matrix`, `F1-Score`, `Precision`, and `Recall`. Before we go through each of these in detail, we need an understanding of the different `components of evaluation metrics`.\n" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## Components of evaluation metrics\n", "\n", @@ -112,39 +114,39 @@ "* `False positives` FP : the negative tuples that were falsely labeled as positive.\n", "* `True negatives` TN: the negative tuples that the classifier correctly labeled.\n", "* `False negatives` FN: the positive tuples that were falsely labeled as negative.\n" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "The confusion matrix is divided into four cells, representing different combinations of predicted and true labels:\n", "* The top-left cell represents the `True Negatives`. The value 1 in this cell indicates that one instance was correctly predicted as the negative class (0).\n", "* The top-right cell represents the `False Positives`. The value 1 in this cell indicates that one instance was incorrectly predicted as the positive class (1) while the true label is negative (0).\n", "* The bottom-left cell represents the `False Negatives`. The value 1 in this cell indicates that one instance was incorrectly predicted as the negative class (0) while the true label is positive (1).\n", "* The bottom-right cell represents the `True Positives`. The value 2 in this cell indicates that two instances were correctly predicted as the positive class (1)." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## Accuracy\n", "Accuracy, also known as `classification rate`, can be defined as the proportion of correctly classified instances out of the total number of instances. That is, it provides a measure of how well a classification model performs overall.\n", "* Formula: $ Accuracy = \\frac{TP+TN}{TP+FP+TN+FN} $\n", "\n", "Let's consider the same dataset used for deriving the confusion matrix to get the accuracy:" - ], - "metadata": { - "collapsed": false - } + ] }, { + "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "from safeds.data.tabular.containers import Column\n", "from safeds.ml.metrics import ClassificationMetrics\n", @@ -152,40 +154,37 @@ "predicted = Column(\"predicted\", [0, 1, 1, 1, 0])\n", "expected = Column(\"predicted\", [0, 1, 1, 0, 1])\n", "\n", - "f1_score = ClassificationMetrics.accuracy(predicted, expected)\n", - "print(\"Accuracy:\", f1_score)" - ], - "metadata": { - "collapsed": false - }, - "execution_count": null, - "outputs": [] + "ClassificationMetrics.accuracy(predicted, expected)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "The accuracy score 0.6 is calculated as the ratio of the number of correct predictions (3) to the total number of instances (5).\n", "* `Accuracy` is suitable when the classes are balanced and there is no significant class imbalance. However, accuracy alone may not always provide a complete picture of a model's performance, especially when dealing with imbalanced datasets. In such cases, other metrics like precision and recall may provide a more comprehensive evaluation of the model's performance." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## F1-Score\n", "The F1-Score is the harmonic mean of precision and recall. That is, it combines precision and recall into a single value.\n", "* Formula: $ F1-Score = \\frac{2PR}{P+R} $\n", "\n", "Let's consider the following dataset to get a better understanding:" - ], - "metadata": { - "collapsed": false - } + ] }, { + "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "from safeds.data.tabular.containers import Column\n", "from safeds.ml.metrics import ClassificationMetrics\n", @@ -193,40 +192,37 @@ "predicted = Column(\"predicted\", [0, 1, 1, 1, 0])\n", "expected = Column(\"predicted\", [0, 1, 1, 0, 1])\n", "\n", - "f1_score = ClassificationMetrics.f1_score(predicted, expected, positive_class=1)\n", - "print(\"F1 Score:\", f1_score)" - ], - "metadata": { - "collapsed": false - }, - "execution_count": null, - "outputs": [] + "ClassificationMetrics.f1_score(predicted, expected, positive_class=1)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "Out of the 5 instances in the dataset, 3 have been correctly predicted. However, there is one false positive (4th instance) and one false negative (5th instance). The harmonic mean of precision and recall in this dataset has an f1-score of approximately 0.67.\n", "* The `F1-score` is suitable when there is an imbalance between the classes, especially when the values of the false positives and false negatives differs.\n" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## Precision\n", "The ability of a classification model to identify only the relevant data points. It measures the proportion of correctly predicted positive instances (true positives) out of all instances predicted as positive (both true positives and false positives).\n", "* Formula: $ P = \\frac{TP}{TP+FP} $\n", "\n", "Let's consider the following dataset to get a clearer picture of Precision:" - ], - "metadata": { - "collapsed": false - } + ] }, { + "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "from safeds.data.tabular.containers import Column\n", "from safeds.ml.metrics import ClassificationMetrics\n", @@ -234,40 +230,37 @@ "predicted = Column(\"predicted\", [0, 1, 1, 1, 0])\n", "expected = Column(\"predicted\", [0, 1, 1, 0, 1])\n", "\n", - "precision = ClassificationMetrics.precision(predicted, expected, positive_class=1)\n", - "print(\"Precision:\", precision)" - ], - "metadata": { - "collapsed": false - }, - "execution_count": null, - "outputs": [] + "ClassificationMetrics.precision(predicted, expected, positive_class=1)" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "The classifier correctly predicts 2 true positive and 1 true negative. However, it also has one false positive, predicting a negative instance as positive. Using the above precision formular, we get a precision score of approximately 0.67. A precision score of 1 indicates that all positive predictions made by the model are correct, while a lower score suggests a higher proportion of false positives.\n", "* Precision is useful when the focus is on minimizing the negative tuples that were falsely labeled as positive." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false + }, "source": [ "## Recall\n", "Also known as `sensitivity` or `true positive rate`, is the ability of a classification model to identify all the relevant data points.\n", "* Formula: $ R = \\frac{TP}{TP + FN} $\n", "\n", "Considering the same dataset used so far, let's calculate the recall score to understand it better:" - ], - "metadata": { - "collapsed": false - } + ] }, { + "metadata": {}, "cell_type": "code", + "outputs": [], + "execution_count": null, "source": [ "from safeds.data.tabular.containers import Column\n", "from safeds.ml.metrics import ClassificationMetrics\n", @@ -275,24 +268,18 @@ "predicted = Column(\"predicted\", [0, 1, 1, 1, 0])\n", "expected = Column(\"predicted\", [0, 1, 1, 0, 1])\n", "\n", - "recall = ClassificationMetrics.recall(predicted, expected, positive_class=1)\n", - "print(\"Recall:\", recall)" - ], - "metadata": { - "collapsed": false - }, - "execution_count": null, - "outputs": [] + "ClassificationMetrics.recall(predicted, expected, positive_class=1)" + ] }, { "cell_type": "markdown", - "source": [ - "The classifier misses one positive instance, resulting in one false negative, where a positive instance is incorrectly classified as negative. Using the above recall formular, we get a recall score of approximately 0.67. A recall score of 1 indicates that all positive instances in the dataset are correctly identified by the model, while a lower score suggests a higher proportion of false negatives.\n", - "* Recall is useful when the focus is on minimizing the positive tuples that were falsely labeled as negative.\n" - ], "metadata": { "collapsed": false - } + }, + "source": [ + "The classifier misses one positive instance, resulting in one false negative, where a positive instance is incorrectly classified as negative. Using the above recall formular, we get a recall score of approximately 0.67. A recall score of 1 indicates that all positive instances in the dataset are correctly identified by the model, while a lower score suggests a higher proportion of false negatives.\n", + "* Recall is useful when the focus is on minimizing the positive tuples that were falsely labeled as negative." + ] } ], "metadata": { diff --git a/docs/tutorials/regression.ipynb b/docs/tutorials/regression.ipynb index 6ec3792a3..9fcd7da1a 100644 --- a/docs/tutorials/regression.ipynb +++ b/docs/tutorials/regression.ipynb @@ -211,9 +211,7 @@ "pricing = Table.from_csv_file(\"data/house_sales.csv\")\n", "\n", "pricing_columns = (\n", - " pricing.remove_columns([\"latitude\", \"longitude\"])\n", - " .remove_rows_with_missing_values()\n", - " .remove_rows_with_outliers()\n", + " pricing.remove_columns([\"latitude\", \"longitude\"]).remove_rows_with_missing_values().remove_rows_with_outliers()\n", ")\n", "\n", "train_table, testing_table = pricing_columns.split_rows(0.60)\n", diff --git a/docs/tutorials/time_series_forecasting.ipynb b/docs/tutorials/time_series_forecasting.ipynb index 6f011c08e..c9a95119b 100644 --- a/docs/tutorials/time_series_forecasting.ipynb +++ b/docs/tutorials/time_series_forecasting.ipynb @@ -3,7 +3,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "# Time series forecasting\n", @@ -14,7 +17,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "1. Load your data into a `Table`. The data is available under `docs/tutorials/data/US_Inflation_rates.csv`:\n" @@ -22,40 +28,33 @@ }, { "cell_type": "code", - "execution_count": 1, - "outputs": [ - { - "data": { - "text/plain": "+------------+----------+\n| date | value |\n| --- | --- |\n| str | f64 |\n+=======================+\n| 1947-01-01 | 21.48000 |\n| 1947-02-01 | 21.62000 |\n| 1947-03-01 | 22.00000 |\n| 1947-04-01 | 22.00000 |\n| 1947-05-01 | 21.95000 |\n| … | … |\n| 1947-11-01 | 23.06000 |\n| 1947-12-01 | 23.41000 |\n| 1948-01-01 | 23.68000 |\n| 1948-02-01 | 23.67000 |\n| 1948-03-01 | 23.50000 |\n+------------+----------+", - "text/html": "
\nshape: (15, 2)
datevalue
strf64
"1947-01-01"21.48
"1947-02-01"21.62
"1947-03-01"22.0
"1947-04-01"22.0
"1947-05-01"21.95
"1947-11-01"23.06
"1947-12-01"23.41
"1948-01-01"23.68
"1948-02-01"23.67
"1948-03-01"23.5
" - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" + "execution_count": null, + "metadata": { + "collapsed": false, + "jupyter": { + "is_executing": true, + "outputs_hidden": false + }, + "pycharm": { + "name": "#%%\n" } - ], + }, + "outputs": [], "source": [ "from safeds.data.tabular.containers import Table\n", "\n", "inflation = Table.from_csv_file(\"data/US_Inflation_rates.csv\")\n", "# For visualisation purposes we only print out the first 15 rows.\n", - "inflation.slice_rows(0,15)" - ], - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%%\n" - }, - "ExecuteTime": { - "end_time": "2024-07-12T12:00:54.778131900Z", - "start_time": "2024-07-12T12:00:54.702583900Z" - } - } + "inflation.slice_rows(start=0, length=15)" + ] }, { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "This dataset contains two columns: date and value. The date column is right now still a string type with a format like this: \"Year-Month-Day\". We can convert it into a temporal type column like this:" @@ -63,68 +62,52 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": { "collapsed": false, - "ExecuteTime": { - "end_time": "2024-07-12T12:00:54.785458300Z", - "start_time": "2024-07-12T12:00:54.778131900Z" + "jupyter": { + "outputs_hidden": false } }, - "outputs": [ - { - "data": { - "text/plain": "+------------+----------+\n| date | value |\n| --- | --- |\n| date | f64 |\n+=======================+\n| 1947-01-01 | 21.48000 |\n| 1947-02-01 | 21.62000 |\n| 1947-03-01 | 22.00000 |\n| 1947-04-01 | 22.00000 |\n| 1947-05-01 | 21.95000 |\n| … | … |\n| 1947-11-01 | 23.06000 |\n| 1947-12-01 | 23.41000 |\n| 1948-01-01 | 23.68000 |\n| 1948-02-01 | 23.67000 |\n| 1948-03-01 | 23.50000 |\n+------------+----------+", - "text/html": "
\nshape: (15, 2)
datevalue
datef64
1947-01-0121.48
1947-02-0121.62
1947-03-0122.0
1947-04-0122.0
1947-05-0121.95
1947-11-0123.06
1947-12-0123.41
1948-01-0123.68
1948-02-0123.67
1948-03-0123.5
" - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "inflation = inflation.replace_column(\"date\", [inflation.get_column(\"date\").transform(lambda cell:cell.str.to_date())])\n", - "inflation.slice_rows(0,15)" + "inflation = inflation.replace_column(\"date\", [inflation.get_column(\"date\").transform(lambda cell: cell.str.to_date())])\n", + "inflation.slice_rows(start=0, length=15)" ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "Let's have a look on the dataset." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "outputs": [ - { - "data": { - "text/plain": "", - "image/png": "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" - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "inflation.plot.line_plot(x_name=\"date\", y_names=[\"value\"])" - ], + "execution_count": null, "metadata": { "collapsed": false, - "ExecuteTime": { - "end_time": "2024-07-12T12:00:58.744725200Z", - "start_time": "2024-07-12T12:00:54.785458300Z" + "jupyter": { + "outputs_hidden": false } }, - "execution_count": 3 + "outputs": [], + "source": [ + "inflation.plot.line_plot(x_name=\"date\", y_names=[\"value\"])" + ] }, { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "2. In the second step we prepare the data, to train our neural network. For that we need to normalize our data, because neural networks work better on small values. After that, we split the data into a training and test set.\n" @@ -132,12 +115,11 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": { "collapsed": false, - "ExecuteTime": { - "end_time": "2024-07-12T12:00:58.754779700Z", - "start_time": "2024-07-12T12:00:58.741725500Z" + "jupyter": { + "outputs_hidden": false } }, "outputs": [], @@ -152,7 +134,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "3. After the data preparation we can start defining our neural network. We do this by defining the different layers and their output size." @@ -160,18 +145,16 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": { "collapsed": false, - "ExecuteTime": { - "end_time": "2024-07-12T12:00:58.772476400Z", - "start_time": "2024-07-12T12:00:58.748274500Z" + "jupyter": { + "outputs_hidden": false } }, "outputs": [], "source": [ "from safeds.ml.nn import NeuralNetworkRegressor\n", - "\n", "from safeds.ml.nn.converters import (\n", " InputConversionTimeSeries,\n", ")\n", @@ -188,56 +171,65 @@ }, { "cell_type": "markdown", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "4. When working with time series data, the data is segmented into windows prior to being fed into the neural network. Each window consists of a sequence of consecutive data points. Windowing data is beneficial for neural networks as it allows them to more effectively learn the relationships between data points. In Safe-DS, the windowing step is automated; users simply need to classify their dataset as a time series, as demonstrated below." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "outputs": [], - "source": [ - "fitted_neural_network = neural_network.fit(train_set.to_time_series_dataset(\n", - " target_name=\"value\",\n", - " window_size=12,\n", - " forecast_horizon=1,\n", - " continuous=False,\n", - " extra_names= [\"date\"]\n", - "), epoch_count=25)" - ], + "execution_count": null, "metadata": { "collapsed": false, - "ExecuteTime": { - "end_time": "2024-07-12T12:01:25.290091500Z", - "start_time": "2024-07-12T12:00:58.761429700Z" + "jupyter": { + "outputs_hidden": false } }, - "execution_count": 6 + "outputs": [], + "source": [ + "from safeds.data.labeled.containers import TimeSeriesDataset\n", + "\n", + "fitted_neural_network = neural_network.fit(\n", + " TimeSeriesDataset(\n", + " train_set,\n", + " \"value\",\n", + " window_size=12,\n", + " forecast_horizon=1,\n", + " continuous=False,\n", + " extra_names=[\"date\"],\n", + " ),\n", + " epoch_count=25,\n", + ")" + ] }, { "cell_type": "markdown", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "5. Now that we defined and trained our model, we can start making predictions." - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": { "collapsed": false, - "ExecuteTime": { - "end_time": "2024-07-12T12:01:25.326838500Z", - "start_time": "2024-07-12T12:01:25.292089800Z" + "jupyter": { + "outputs_hidden": false } }, "outputs": [], "source": [ - "\n", "prediction = fitted_neural_network.predict(test_set)\n", "prediction = prediction.to_table()" ] @@ -245,7 +237,10 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "source": [ "6. Now we only need to inverse our predictions and we can start visualizing them:\n" @@ -253,12 +248,11 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": { "collapsed": false, - "ExecuteTime": { - "end_time": "2024-07-12T12:01:25.330130300Z", - "start_time": "2024-07-12T12:01:25.326838500Z" + "jupyter": { + "outputs_hidden": false } }, "outputs": [], @@ -269,40 +263,30 @@ }, { "cell_type": "code", - "outputs": [], - "source": [ - "preds_with_test = prediction.add_columns([test_set.slice_rows(13).rename_column(\"value\",\"true_value\").get_column(\"true_value\")])" - ], + "execution_count": null, "metadata": { "collapsed": false, - "ExecuteTime": { - "end_time": "2024-07-12T12:01:25.332992100Z", - "start_time": "2024-07-12T12:01:25.331129700Z" + "jupyter": { + "outputs_hidden": false } }, - "execution_count": 9 + "outputs": [], + "source": [ + "preds_with_test = prediction.add_columns(\n", + " [test_set.slice_rows(start=13).rename_column(\"value\", \"true_value\").get_column(\"true_value\")],\n", + ")" + ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": { "collapsed": false, - "ExecuteTime": { - "end_time": "2024-07-12T12:01:25.408325200Z", - "start_time": "2024-07-12T12:01:25.334999800Z" + "jupyter": { + "outputs_hidden": false } }, - "outputs": [ - { - "data": { - "text/plain": "", - "image/png": "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" - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "preds_with_test.plot.line_plot(\"date\", [\"value\", \"true_value\"])" ] @@ -324,7 +308,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.8" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/poetry.lock b/poetry.lock index f26c6330f..950546ce9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -802,15 +802,15 @@ colorama = ">=0.4" [[package]] name = "huggingface-hub" -version = "0.27.0" +version = "0.27.1" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" groups = ["main"] markers = "python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "huggingface_hub-0.27.0-py3-none-any.whl", hash = "sha256:8f2e834517f1f1ddf1ecc716f91b120d7333011b7485f665a9a412eacb1a2a81"}, - {file = "huggingface_hub-0.27.0.tar.gz", hash = "sha256:902cce1a1be5739f5589e560198a65a8edcfd3b830b1666f36e4b961f0454fac"}, + {file = "huggingface_hub-0.27.1-py3-none-any.whl", hash = "sha256:1c5155ca7d60b60c2e2fc38cbb3ffb7f7c3adf48f824015b219af9061771daec"}, + {file = "huggingface_hub-0.27.1.tar.gz", hash = "sha256:c004463ca870283909d715d20f066ebd6968c2207dae9393fdffb3c1d4d8f98b"}, ] [package.dependencies] @@ -2753,15 +2753,15 @@ files = [ [[package]] name = "pygments" -version = "2.19.0" +version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["docs"] markers = "python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "pygments-2.19.0-py3-none-any.whl", hash = "sha256:4755e6e64d22161d5b61432c0600c923c5927214e7c956e31c23923c89251a9b"}, - {file = "pygments-2.19.0.tar.gz", hash = "sha256:afc4146269910d4bdfabcd27c24923137a74d562a23a320a41a55ad303e19783"}, + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, ] [package.extras] @@ -2769,15 +2769,15 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pymdown-extensions" -version = "10.13" +version = "10.14" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" groups = ["docs"] markers = "python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "pymdown_extensions-10.13-py3-none-any.whl", hash = "sha256:80bc33d715eec68e683e04298946d47d78c7739e79d808203df278ee8ef89428"}, - {file = "pymdown_extensions-10.13.tar.gz", hash = "sha256:e0b351494dc0d8d14a1f52b39b1499a00ef1566b4ba23dc74f1eba75c736f5dd"}, + {file = "pymdown_extensions-10.14-py3-none-any.whl", hash = "sha256:202481f716cc8250e4be8fce997781ebf7917701b59652458ee47f2401f818b5"}, + {file = "pymdown_extensions-10.14.tar.gz", hash = "sha256:741bd7c4ff961ba40b7528d32284c53bc436b8b1645e8e37c3e57770b8700a34"}, ] [package.dependencies] @@ -2785,7 +2785,7 @@ markdown = ">=3.6" pyyaml = "*" [package.extras] -extra = ["pygments (>=2.12)"] +extra = ["pygments (>=2.19.1)"] [[package]] name = "pyparsing" @@ -3367,28 +3367,28 @@ files = [ [[package]] name = "safetensors" -version = "0.5.0" +version = "0.5.2" description = "" optional = false python-versions = ">=3.7" groups = ["main"] markers = "python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "safetensors-0.5.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c683b9b485bee43422ba2855f72777c37647190281e03da4c8d2a69fa5336558"}, - {file = "safetensors-0.5.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:6106aa835deb7263f7014f74c05842ab828d6c11d789f2e7e98f26b1a305e72d"}, - {file = "safetensors-0.5.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1349611f74f55c5ee1c1c144c536a2743c38f7d8bf60b9fc8267e0efc0591a2"}, - {file = "safetensors-0.5.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d936028ac799e18644b08a91fd98b4b62ae3dcd0440b1cfcb56535785589f1"}, - {file = "safetensors-0.5.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f26afada2233576ffea6b80042c2c0a8105c164254af56168ec14299ad3122"}, - {file = "safetensors-0.5.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20067e7a5e63f0cbc88457b2a1161e70ff73af4cc3a24bce90309430cd6f6e7e"}, - {file = "safetensors-0.5.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649d6a4aa34d5174ae87289068ccc2fec2a1a998ecf83425aa5a42c3eff69bcf"}, - {file = "safetensors-0.5.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:debff88f41d569a3e93a955469f83864e432af35bb34b16f65a9ddf378daa3ae"}, - {file = "safetensors-0.5.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bdf6a3e366ea8ba1a0538db6099229e95811194432c684ea28ea7ae28763b8dc"}, - {file = "safetensors-0.5.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0371afd84c200a80eb7103bf715108b0c3846132fb82453ae018609a15551580"}, - {file = "safetensors-0.5.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5ec7fc8c3d2f32ebf1c7011bc886b362e53ee0a1ec6d828c39d531fed8b325d6"}, - {file = "safetensors-0.5.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:53715e4ea0ef23c08f004baae0f609a7773de7d4148727760417c6760cfd6b76"}, - {file = "safetensors-0.5.0-cp38-abi3-win32.whl", hash = "sha256:b85565bc2f0456961a788d2f11d9d892eec46603db0e4923aa9512c2355aa727"}, - {file = "safetensors-0.5.0-cp38-abi3-win_amd64.whl", hash = "sha256:f451941f8aa11e7be5c3fa450e264609a2b1e65fa38ae590a74e55a94d646b76"}, - {file = "safetensors-0.5.0.tar.gz", hash = "sha256:c47b34c549fa1e0c655c4644da31332c61332c732c47c8dd9399347e9aac69d1"}, + {file = "safetensors-0.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:45b6092997ceb8aa3801693781a71a99909ab9cc776fbc3fa9322d29b1d3bef2"}, + {file = "safetensors-0.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:6d0d6a8ee2215a440e1296b843edf44fd377b055ba350eaba74655a2fe2c4bae"}, + {file = "safetensors-0.5.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86016d40bcaa3bcc9a56cd74d97e654b5f4f4abe42b038c71e4f00a089c4526c"}, + {file = "safetensors-0.5.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:990833f70a5f9c7d3fc82c94507f03179930ff7d00941c287f73b6fcbf67f19e"}, + {file = "safetensors-0.5.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dfa7c2f3fe55db34eba90c29df94bcdac4821043fc391cb5d082d9922013869"}, + {file = "safetensors-0.5.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46ff2116150ae70a4e9c490d2ab6b6e1b1b93f25e520e540abe1b81b48560c3a"}, + {file = "safetensors-0.5.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ab696dfdc060caffb61dbe4066b86419107a24c804a4e373ba59be699ebd8d5"}, + {file = "safetensors-0.5.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03c937100f38c9ff4c1507abea9928a6a9b02c9c1c9c3609ed4fb2bf413d4975"}, + {file = "safetensors-0.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a00e737948791b94dad83cf0eafc09a02c4d8c2171a239e8c8572fe04e25960e"}, + {file = "safetensors-0.5.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:d3a06fae62418ec8e5c635b61a8086032c9e281f16c63c3af46a6efbab33156f"}, + {file = "safetensors-0.5.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1506e4c2eda1431099cebe9abf6c76853e95d0b7a95addceaa74c6019c65d8cf"}, + {file = "safetensors-0.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5c5b5d9da594f638a259fca766046f44c97244cc7ab8bef161b3e80d04becc76"}, + {file = "safetensors-0.5.2-cp38-abi3-win32.whl", hash = "sha256:fe55c039d97090d1f85277d402954dd6ad27f63034fa81985a9cc59655ac3ee2"}, + {file = "safetensors-0.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:78abdddd03a406646107f973c7843276e7b64e5e32623529dc17f3d94a20f589"}, + {file = "safetensors-0.5.2.tar.gz", hash = "sha256:cb4a8d98ba12fa016f4241932b1fc5e702e5143f5374bba0bbcf7ddc1c4cf2b8"}, ] [package.extras] @@ -3406,43 +3406,43 @@ torch = ["safetensors[numpy]", "torch (>=1.10)"] [[package]] name = "scikit-learn" -version = "1.6.0" +version = "1.6.1" description = "A set of python modules for machine learning and data mining" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "scikit_learn-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:366fb3fa47dce90afed3d6106183f4978d6f24cfd595c2373424171b915ee718"}, - {file = "scikit_learn-1.6.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:59cd96a8d9f8dfd546f5d6e9787e1b989e981388d7803abbc9efdcde61e47460"}, - {file = "scikit_learn-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efa7a579606c73a0b3d210e33ea410ea9e1af7933fe324cb7e6fbafae4ea5948"}, - {file = "scikit_learn-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a46d3ca0f11a540b8eaddaf5e38172d8cd65a86cb3e3632161ec96c0cffb774c"}, - {file = "scikit_learn-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:5be4577769c5dde6e1b53de8e6520f9b664ab5861dd57acee47ad119fd7405d6"}, - {file = "scikit_learn-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1f50b4f24cf12a81c3c09958ae3b864d7534934ca66ded3822de4996d25d7285"}, - {file = "scikit_learn-1.6.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:eb9ae21f387826da14b0b9cb1034f5048ddb9182da429c689f5f4a87dc96930b"}, - {file = "scikit_learn-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0baa91eeb8c32632628874a5c91885eaedd23b71504d24227925080da075837a"}, - {file = "scikit_learn-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c716d13ba0a2f8762d96ff78d3e0cde90bc9c9b5c13d6ab6bb9b2d6ca6705fd"}, - {file = "scikit_learn-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aafd94bafc841b626681e626be27bf1233d5a0f20f0a6fdb4bee1a1963c6643"}, - {file = "scikit_learn-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:04a5ba45c12a5ff81518aa4f1604e826a45d20e53da47b15871526cda4ff5174"}, - {file = "scikit_learn-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:21fadfc2ad7a1ce8bd1d90f23d17875b84ec765eecbbfc924ff11fb73db582ce"}, - {file = "scikit_learn-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30f34bb5fde90e020653bb84dcb38b6c83f90c70680dbd8c38bd9becbad7a127"}, - {file = "scikit_learn-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1dad624cffe3062276a0881d4e441bc9e3b19d02d17757cd6ae79a9d192a0027"}, - {file = "scikit_learn-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fce7950a3fad85e0a61dc403df0f9345b53432ac0e47c50da210d22c60b6d85"}, - {file = "scikit_learn-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e5453b2e87ef8accedc5a8a4e6709f887ca01896cd7cc8a174fe39bd4bb00aef"}, - {file = "scikit_learn-1.6.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5fe11794236fb83bead2af26a87ced5d26e3370b8487430818b915dafab1724e"}, - {file = "scikit_learn-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61fe3dcec0d82ae280877a818ab652f4988371e32dd5451e75251bece79668b1"}, - {file = "scikit_learn-1.6.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b44e3a51e181933bdf9a4953cc69c6025b40d2b49e238233f149b98849beb4bf"}, - {file = "scikit_learn-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:a17860a562bac54384454d40b3f6155200c1c737c9399e6a97962c63fce503ac"}, - {file = "scikit_learn-1.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:98717d3c152f6842d36a70f21e1468fb2f1a2f8f2624d9a3f382211798516426"}, - {file = "scikit_learn-1.6.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:34e20bfac8ff0ebe0ff20fb16a4d6df5dc4cc9ce383e00c2ab67a526a3c67b18"}, - {file = "scikit_learn-1.6.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eba06d75815406091419e06dd650b91ebd1c5f836392a0d833ff36447c2b1bfa"}, - {file = "scikit_learn-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b6916d1cec1ff163c7d281e699d7a6a709da2f2c5ec7b10547e08cc788ddd3ae"}, - {file = "scikit_learn-1.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:66b1cf721a9f07f518eb545098226796c399c64abdcbf91c2b95d625068363da"}, - {file = "scikit_learn-1.6.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:7b35b60cf4cd6564b636e4a40516b3c61a4fa7a8b1f7a3ce80c38ebe04750bc3"}, - {file = "scikit_learn-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a73b1c2038c93bc7f4bf21f6c9828d5116c5d2268f7a20cfbbd41d3074d52083"}, - {file = "scikit_learn-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c3fa7d3dd5a0ec2d0baba0d644916fa2ab180ee37850c5d536245df916946bd"}, - {file = "scikit_learn-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:df778486a32518cda33818b7e3ce48c78cef1d5f640a6bc9d97c6d2e71449a51"}, - {file = "scikit_learn-1.6.0.tar.gz", hash = "sha256:9d58481f9f7499dff4196927aedd4285a0baec8caa3790efbe205f13de37dd6e"}, + {file = "scikit_learn-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d056391530ccd1e501056160e3c9673b4da4805eb67eb2bdf4e983e1f9c9204e"}, + {file = "scikit_learn-1.6.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0c8d036eb937dbb568c6242fa598d551d88fb4399c0344d95c001980ec1c7d36"}, + {file = "scikit_learn-1.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8634c4bd21a2a813e0a7e3900464e6d593162a29dd35d25bdf0103b3fce60ed5"}, + {file = "scikit_learn-1.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:775da975a471c4f6f467725dff0ced5c7ac7bda5e9316b260225b48475279a1b"}, + {file = "scikit_learn-1.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:8a600c31592bd7dab31e1c61b9bbd6dea1b3433e67d264d17ce1017dbdce8002"}, + {file = "scikit_learn-1.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72abc587c75234935e97d09aa4913a82f7b03ee0b74111dcc2881cba3c5a7b33"}, + {file = "scikit_learn-1.6.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b3b00cdc8f1317b5f33191df1386c0befd16625f49d979fe77a8d44cae82410d"}, + {file = "scikit_learn-1.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc4765af3386811c3ca21638f63b9cf5ecf66261cc4815c1db3f1e7dc7b79db2"}, + {file = "scikit_learn-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25fc636bdaf1cc2f4a124a116312d837148b5e10872147bdaf4887926b8c03d8"}, + {file = "scikit_learn-1.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:fa909b1a36e000a03c382aade0bd2063fd5680ff8b8e501660c0f59f021a6415"}, + {file = "scikit_learn-1.6.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:926f207c804104677af4857b2c609940b743d04c4c35ce0ddc8ff4f053cddc1b"}, + {file = "scikit_learn-1.6.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2c2cae262064e6a9b77eee1c8e768fc46aa0b8338c6a8297b9b6759720ec0ff2"}, + {file = "scikit_learn-1.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1061b7c028a8663fb9a1a1baf9317b64a257fcb036dae5c8752b2abef31d136f"}, + {file = "scikit_learn-1.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e69fab4ebfc9c9b580a7a80111b43d214ab06250f8a7ef590a4edf72464dd86"}, + {file = "scikit_learn-1.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:70b1d7e85b1c96383f872a519b3375f92f14731e279a7b4c6cfd650cf5dffc52"}, + {file = "scikit_learn-1.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ffa1e9e25b3d93990e74a4be2c2fc61ee5af85811562f1288d5d055880c4322"}, + {file = "scikit_learn-1.6.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dc5cf3d68c5a20ad6d571584c0750ec641cc46aeef1c1507be51300e6003a7e1"}, + {file = "scikit_learn-1.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c06beb2e839ecc641366000ca84f3cf6fa9faa1777e29cf0c04be6e4d096a348"}, + {file = "scikit_learn-1.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8ca8cb270fee8f1f76fa9bfd5c3507d60c6438bbee5687f81042e2bb98e5a97"}, + {file = "scikit_learn-1.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:7a1c43c8ec9fde528d664d947dc4c0789be4077a3647f232869f41d9bf50e0fb"}, + {file = "scikit_learn-1.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a17c1dea1d56dcda2fac315712f3651a1fea86565b64b48fa1bc090249cbf236"}, + {file = "scikit_learn-1.6.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6a7aa5f9908f0f28f4edaa6963c0a6183f1911e63a69aa03782f0d924c830a35"}, + {file = "scikit_learn-1.6.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0650e730afb87402baa88afbf31c07b84c98272622aaba002559b614600ca691"}, + {file = "scikit_learn-1.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:3f59fe08dc03ea158605170eb52b22a105f238a5d512c4470ddeca71feae8e5f"}, + {file = "scikit_learn-1.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6849dd3234e87f55dce1db34c89a810b489ead832aaf4d4550b7ea85628be6c1"}, + {file = "scikit_learn-1.6.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e7be3fa5d2eb9be7d77c3734ff1d599151bb523674be9b834e8da6abe132f44e"}, + {file = "scikit_learn-1.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44a17798172df1d3c1065e8fcf9019183f06c87609b49a124ebdf57ae6cb0107"}, + {file = "scikit_learn-1.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b7a3b86e411e4bce21186e1c180d792f3d99223dcfa3b4f597ecc92fa1a422"}, + {file = "scikit_learn-1.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7a73d457070e3318e32bdb3aa79a8d990474f19035464dfd8bede2883ab5dc3b"}, + {file = "scikit_learn-1.6.1.tar.gz", hash = "sha256:b4fc2525eca2c69a59260f583c56a7557c6ccdf8deafdba6e060f94c1c59738e"}, ] [package.dependencies] @@ -3462,53 +3462,53 @@ tests = ["black (>=24.3.0)", "matplotlib (>=3.3.4)", "mypy (>=1.9)", "numpydoc ( [[package]] name = "scipy" -version = "1.15.0" +version = "1.15.1" description = "Fundamental algorithms for scientific computing in Python" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "scipy-1.15.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:aeac60d3562a7bf2f35549bdfdb6b1751c50590f55ce7322b4b2fc821dc27fca"}, - {file = "scipy-1.15.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5abbdc6ede5c5fed7910cf406a948e2c0869231c0db091593a6b2fa78be77e5d"}, - {file = "scipy-1.15.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:eb1533c59f0ec6c55871206f15a5c72d1fae7ad3c0a8ca33ca88f7c309bbbf8c"}, - {file = "scipy-1.15.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:de112c2dae53107cfeaf65101419662ac0a54e9a088c17958b51c95dac5de56d"}, - {file = "scipy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2240e1fd0782e62e1aacdc7234212ee271d810f67e9cd3b8d521003a82603ef8"}, - {file = "scipy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d35aef233b098e4de88b1eac29f0df378278e7e250a915766786b773309137c4"}, - {file = "scipy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b29e4fc02e155a5fd1165f1e6a73edfdd110470736b0f48bcbe48083f0eee37"}, - {file = "scipy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:0e5b34f8894f9904cc578008d1a9467829c1817e9f9cb45e6d6eeb61d2ab7731"}, - {file = "scipy-1.15.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:46e91b5b16909ff79224b56e19cbad65ca500b3afda69225820aa3afbf9ec020"}, - {file = "scipy-1.15.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:82bff2eb01ccf7cea8b6ee5274c2dbeadfdac97919da308ee6d8e5bcbe846443"}, - {file = "scipy-1.15.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:9c8254fe21dd2c6c8f7757035ec0c31daecf3bb3cffd93bc1ca661b731d28136"}, - {file = "scipy-1.15.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:c9624eeae79b18cab1a31944b5ef87aa14b125d6ab69b71db22f0dbd962caf1e"}, - {file = "scipy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d13bbc0658c11f3d19df4138336e4bce2c4fbd78c2755be4bf7b8e235481557f"}, - {file = "scipy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdca4c7bb8dc41307e5f39e9e5d19c707d8e20a29845e7533b3bb20a9d4ccba0"}, - {file = "scipy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f376d7c767731477bac25a85d0118efdc94a572c6b60decb1ee48bf2391a73b"}, - {file = "scipy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:61513b989ee8d5218fbeb178b2d51534ecaddba050db949ae99eeb3d12f6825d"}, - {file = "scipy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5beb0a2200372b7416ec73fdae94fe81a6e85e44eb49c35a11ac356d2b8eccc6"}, - {file = "scipy-1.15.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fde0f3104dfa1dfbc1f230f65506532d0558d43188789eaf68f97e106249a913"}, - {file = "scipy-1.15.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:35c68f7044b4e7ad73a3e68e513dda946989e523df9b062bd3cf401a1a882192"}, - {file = "scipy-1.15.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:52475011be29dfcbecc3dfe3060e471ac5155d72e9233e8d5616b84e2b542054"}, - {file = "scipy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5972e3f96f7dda4fd3bb85906a17338e65eaddfe47f750e240f22b331c08858e"}, - {file = "scipy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe00169cf875bed0b3c40e4da45b57037dc21d7c7bf0c85ed75f210c281488f1"}, - {file = "scipy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:161f80a98047c219c257bf5ce1777c574bde36b9d962a46b20d0d7e531f86863"}, - {file = "scipy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:327163ad73e54541a675240708244644294cb0a65cca420c9c79baeb9648e479"}, - {file = "scipy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0fcb16eb04d84670722ce8d93b05257df471704c913cb0ff9dc5a1c31d1e9422"}, - {file = "scipy-1.15.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:767e8cf6562931f8312f4faa7ddea412cb783d8df49e62c44d00d89f41f9bbe8"}, - {file = "scipy-1.15.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:37ce9394cdcd7c5f437583fc6ef91bd290014993900643fdfc7af9b052d1613b"}, - {file = "scipy-1.15.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6d26f17c64abd6c6c2dfb39920f61518cc9e213d034b45b2380e32ba78fde4c0"}, - {file = "scipy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e2448acd79c6374583581a1ded32ac71a00c2b9c62dfa87a40e1dd2520be111"}, - {file = "scipy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36be480e512d38db67f377add5b759fb117edd987f4791cdf58e59b26962bee4"}, - {file = "scipy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ccb6248a9987193fe74363a2d73b93bc2c546e0728bd786050b7aef6e17db03c"}, - {file = "scipy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:952d2e9eaa787f0a9e95b6e85da3654791b57a156c3e6609e65cc5176ccfe6f2"}, - {file = "scipy-1.15.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b1432102254b6dc7766d081fa92df87832ac25ff0b3d3a940f37276e63eb74ff"}, - {file = "scipy-1.15.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:4e08c6a36f46abaedf765dd2dfcd3698fa4bd7e311a9abb2d80e33d9b2d72c34"}, - {file = "scipy-1.15.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ec915cd26d76f6fc7ae8522f74f5b2accf39546f341c771bb2297f3871934a52"}, - {file = "scipy-1.15.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:351899dd2a801edd3691622172bc8ea01064b1cada794f8641b89a7dc5418db6"}, - {file = "scipy-1.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9baff912ea4f78a543d183ed6f5b3bea9784509b948227daaf6f10727a0e2e5"}, - {file = "scipy-1.15.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cd9d9198a7fd9a77f0eb5105ea9734df26f41faeb2a88a0e62e5245506f7b6df"}, - {file = "scipy-1.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:129f899ed275c0515d553b8d31696924e2ca87d1972421e46c376b9eb87de3d2"}, - {file = "scipy-1.15.0.tar.gz", hash = "sha256:300742e2cc94e36a2880ebe464a1c8b4352a7b0f3e36ec3d2ac006cdbe0219ac"}, + {file = "scipy-1.15.1-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:c64ded12dcab08afff9e805a67ff4480f5e69993310e093434b10e85dc9d43e1"}, + {file = "scipy-1.15.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5b190b935e7db569960b48840e5bef71dc513314cc4e79a1b7d14664f57fd4ff"}, + {file = "scipy-1.15.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:4b17d4220df99bacb63065c76b0d1126d82bbf00167d1730019d2a30d6ae01ea"}, + {file = "scipy-1.15.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:63b9b6cd0333d0eb1a49de6f834e8aeaefe438df8f6372352084535ad095219e"}, + {file = "scipy-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f151e9fb60fbf8e52426132f473221a49362091ce7a5e72f8aa41f8e0da4f25"}, + {file = "scipy-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e10b1dd56ce92fba3e786007322542361984f8463c6d37f6f25935a5a6ef52"}, + {file = "scipy-1.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5dff14e75cdbcf07cdaa1c7707db6017d130f0af9ac41f6ce443a93318d6c6e0"}, + {file = "scipy-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:f82fcf4e5b377f819542fbc8541f7b5fbcf1c0017d0df0bc22c781bf60abc4d8"}, + {file = "scipy-1.15.1-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:5bd8d27d44e2c13d0c1124e6a556454f52cd3f704742985f6b09e75e163d20d2"}, + {file = "scipy-1.15.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:be3deeb32844c27599347faa077b359584ba96664c5c79d71a354b80a0ad0ce0"}, + {file = "scipy-1.15.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:5eb0ca35d4b08e95da99a9f9c400dc9f6c21c424298a0ba876fdc69c7afacedf"}, + {file = "scipy-1.15.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:74bb864ff7640dea310a1377d8567dc2cb7599c26a79ca852fc184cc851954ac"}, + {file = "scipy-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:667f950bf8b7c3a23b4199db24cb9bf7512e27e86d0e3813f015b74ec2c6e3df"}, + {file = "scipy-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395be70220d1189756068b3173853029a013d8c8dd5fd3d1361d505b2aa58fa7"}, + {file = "scipy-1.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce3a000cd28b4430426db2ca44d96636f701ed12e2b3ca1f2b1dd7abdd84b39a"}, + {file = "scipy-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:3fe1d95944f9cf6ba77aa28b82dd6bb2a5b52f2026beb39ecf05304b8392864b"}, + {file = "scipy-1.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c09aa9d90f3500ea4c9b393ee96f96b0ccb27f2f350d09a47f533293c78ea776"}, + {file = "scipy-1.15.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:0ac102ce99934b162914b1e4a6b94ca7da0f4058b6d6fd65b0cef330c0f3346f"}, + {file = "scipy-1.15.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:09c52320c42d7f5c7748b69e9f0389266fd4f82cf34c38485c14ee976cb8cb04"}, + {file = "scipy-1.15.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:cdde8414154054763b42b74fe8ce89d7f3d17a7ac5dd77204f0e142cdc9239e9"}, + {file = "scipy-1.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c9d8fc81d6a3b6844235e6fd175ee1d4c060163905a2becce8e74cb0d7554ce"}, + {file = "scipy-1.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fb57b30f0017d4afa5fe5f5b150b8f807618819287c21cbe51130de7ccdaed2"}, + {file = "scipy-1.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:491d57fe89927fa1aafbe260f4cfa5ffa20ab9f1435025045a5315006a91b8f5"}, + {file = "scipy-1.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:900f3fa3db87257510f011c292a5779eb627043dd89731b9c461cd16ef76ab3d"}, + {file = "scipy-1.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:100193bb72fbff37dbd0bf14322314fc7cbe08b7ff3137f11a34d06dc0ee6b85"}, + {file = "scipy-1.15.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2114a08daec64980e4b4cbdf5bee90935af66d750146b1d2feb0d3ac30613692"}, + {file = "scipy-1.15.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6b3e71893c6687fc5e29208d518900c24ea372a862854c9888368c0b267387ab"}, + {file = "scipy-1.15.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:837299eec3d19b7e042923448d17d95a86e43941104d33f00da7e31a0f715d3c"}, + {file = "scipy-1.15.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82add84e8a9fb12af5c2c1a3a3f1cb51849d27a580cb9e6bd66226195142be6e"}, + {file = "scipy-1.15.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:070d10654f0cb6abd295bc96c12656f948e623ec5f9a4eab0ddb1466c000716e"}, + {file = "scipy-1.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55cc79ce4085c702ac31e49b1e69b27ef41111f22beafb9b49fea67142b696c4"}, + {file = "scipy-1.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:c352c1b6d7cac452534517e022f8f7b8d139cd9f27e6fbd9f3cbd0bfd39f5bef"}, + {file = "scipy-1.15.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0458839c9f873062db69a03de9a9765ae2e694352c76a16be44f93ea45c28d2b"}, + {file = "scipy-1.15.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:af0b61c1de46d0565b4b39c6417373304c1d4f5220004058bdad3061c9fa8a95"}, + {file = "scipy-1.15.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:71ba9a76c2390eca6e359be81a3e879614af3a71dfdabb96d1d7ab33da6f2364"}, + {file = "scipy-1.15.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14eaa373c89eaf553be73c3affb11ec6c37493b7eaaf31cf9ac5dffae700c2e0"}, + {file = "scipy-1.15.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f735bc41bd1c792c96bc426dece66c8723283695f02df61dcc4d0a707a42fc54"}, + {file = "scipy-1.15.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2722a021a7929d21168830790202a75dbb20b468a8133c74a2c0230c72626b6c"}, + {file = "scipy-1.15.1-cp313-cp313t-win_amd64.whl", hash = "sha256:bc7136626261ac1ed988dca56cfc4ab5180f75e0ee52e58f1e6aa74b5f3eacd5"}, + {file = "scipy-1.15.1.tar.gz", hash = "sha256:033a75ddad1463970c96a88063a1df87ccfddd526437136b6ee81ff0312ebdf6"}, ] [package.dependencies] @@ -3521,15 +3521,15 @@ test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis [[package]] name = "setuptools" -version = "75.7.0" +version = "75.8.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["main", "dev"] markers = "python_version >= \"3.12\"" files = [ - {file = "setuptools-75.7.0-py3-none-any.whl", hash = "sha256:84fb203f278ebcf5cd08f97d3fb96d3fbed4b629d500b29ad60d11e00769b183"}, - {file = "setuptools-75.7.0.tar.gz", hash = "sha256:886ff7b16cd342f1d1defc16fc98c9ce3fde69e087a4e1983d7ab634e5f41f4f"}, + {file = "setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3"}, + {file = "setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6"}, ] [package.extras] @@ -3980,15 +3980,15 @@ test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0, [[package]] name = "transformers" -version = "4.47.1" +version = "4.48.0" description = "State-of-the-art Machine Learning for JAX, PyTorch and TensorFlow" optional = false python-versions = ">=3.9.0" groups = ["main"] markers = "python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "transformers-4.47.1-py3-none-any.whl", hash = "sha256:d2f5d19bb6283cd66c893ec7e6d931d6370bbf1cc93633326ff1f41a40046c9c"}, - {file = "transformers-4.47.1.tar.gz", hash = "sha256:6c29c05a5f595e278481166539202bf8641281536df1c42357ee58a45d0a564a"}, + {file = "transformers-4.48.0-py3-none-any.whl", hash = "sha256:6d3de6d71cb5f2a10f9775ccc17abce9620195caaf32ec96542bd2a6937f25b0"}, + {file = "transformers-4.48.0.tar.gz", hash = "sha256:03fdfcbfb8b0367fb6c9fbe9d1c9aa54dfd847618be9b52400b2811d22799cb1"}, ] [package.dependencies] @@ -4005,16 +4005,16 @@ tqdm = ">=4.27" [package.extras] accelerate = ["accelerate (>=0.26.0)"] -agents = ["Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "datasets (!=2.5.0)", "diffusers", "opencv-python", "sentencepiece (>=0.1.91,!=0.1.92)", "torch"] -all = ["Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "av (==9.2.0)", "codecarbon (==1.2.0)", "flax (>=0.4.1,<=0.7.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "librosa", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "phonemizer", "protobuf", "pyctcdecode (>=0.4.0)", "ray[tune] (>=2.7.0)", "scipy (<1.13.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timm (<=1.0.11)", "tokenizers (>=0.21,<0.22)", "torch", "torchaudio", "torchvision"] +agents = ["Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "datasets (!=2.5.0)", "diffusers", "opencv-python", "sentencepiece (>=0.1.91,!=0.1.92)", "torch (>=2.0)"] +all = ["Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "av (==9.2.0)", "codecarbon (>=2.8.1)", "flax (>=0.4.1,<=0.7.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "librosa", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "phonemizer", "protobuf", "pyctcdecode (>=0.4.0)", "ray[tune] (>=2.7.0)", "scipy (<1.13.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timm (<=1.0.11)", "tokenizers (>=0.21,<0.22)", "torch (>=2.0)", "torchaudio", "torchvision"] audio = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] benchmark = ["optimum-benchmark (>=0.3.0)"] -codecarbon = ["codecarbon (==1.2.0)"] +codecarbon = ["codecarbon (>=2.8.1)"] deepspeed = ["accelerate (>=0.26.0)", "deepspeed (>=0.9.3)"] -deepspeed-testing = ["GitPython (<3.1.19)", "accelerate (>=0.26.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "deepspeed (>=0.9.3)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "nltk (<=3.8.1)", "optuna", "parameterized", "protobuf", "psutil", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-rich", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "timeout-decorator"] -dev = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "av (==9.2.0)", "beautifulsoup4", "codecarbon (==1.2.0)", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "flax (>=0.4.1,<=0.7.0)", "fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "isort (>=5.5.4)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "libcst", "librosa", "nltk (<=3.8.1)", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-rich", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "scipy (<1.13.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "timm (<=1.0.11)", "tokenizers (>=0.21,<0.22)", "torch", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] -dev-tensorflow = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "isort (>=5.5.4)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "libcst", "librosa", "nltk (<=3.8.1)", "onnxconverter-common", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-rich", "pytest-timeout", "pytest-xdist", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "tokenizers (>=0.21,<0.22)", "urllib3 (<2.0.0)"] -dev-torch = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "beautifulsoup4", "codecarbon (==1.2.0)", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "isort (>=5.5.4)", "kenlm", "libcst", "librosa", "nltk (<=3.8.1)", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "optuna", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-rich", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "timeout-decorator", "timm (<=1.0.11)", "tokenizers (>=0.21,<0.22)", "torch", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] +deepspeed-testing = ["GitPython (<3.1.19)", "accelerate (>=0.26.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "deepspeed (>=0.9.3)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "nltk (<=3.8.1)", "optuna", "parameterized", "protobuf", "psutil", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-asyncio", "pytest-rich", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "timeout-decorator"] +dev = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "av (==9.2.0)", "beautifulsoup4", "codecarbon (>=2.8.1)", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "flax (>=0.4.1,<=0.7.0)", "fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "isort (>=5.5.4)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "libcst", "librosa", "nltk (<=3.8.1)", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-asyncio", "pytest-rich", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "scipy (<1.13.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "timm (<=1.0.11)", "tokenizers (>=0.21,<0.22)", "torch (>=2.0)", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] +dev-tensorflow = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "isort (>=5.5.4)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "libcst", "librosa", "nltk (<=3.8.1)", "onnxconverter-common", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-asyncio", "pytest-rich", "pytest-timeout", "pytest-xdist", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "tokenizers (>=0.21,<0.22)", "urllib3 (<2.0.0)"] +dev-torch = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "beautifulsoup4", "codecarbon (>=2.8.1)", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "isort (>=5.5.4)", "kenlm", "libcst", "librosa", "nltk (<=3.8.1)", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "optuna", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-asyncio", "pytest-rich", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "timeout-decorator", "timm (<=1.0.11)", "tokenizers (>=0.21,<0.22)", "torch (>=2.0)", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] flax = ["flax (>=0.4.1,<=0.7.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "optax (>=0.0.8,<=0.1.4)", "scipy (<1.13.0)"] flax-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] ftfy = ["ftfy"] @@ -4035,17 +4035,17 @@ serving = ["fastapi", "pydantic", "starlette", "uvicorn"] sigopt = ["sigopt"] sklearn = ["scikit-learn"] speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)", "torchaudio"] -testing = ["GitPython (<3.1.19)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "nltk (<=3.8.1)", "parameterized", "psutil", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-rich", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "timeout-decorator"] +testing = ["GitPython (<3.1.19)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "nltk (<=3.8.1)", "parameterized", "psutil", "pydantic", "pytest (>=7.2.0,<8.0.0)", "pytest-asyncio", "pytest-rich", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.5.1)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "timeout-decorator"] tf = ["keras-nlp (>=0.3.1,<0.14.0)", "onnxconverter-common", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx"] tf-cpu = ["keras (>2.9,<2.16)", "keras-nlp (>=0.3.1,<0.14.0)", "onnxconverter-common", "tensorflow-cpu (>2.9,<2.16)", "tensorflow-probability (<0.24)", "tensorflow-text (<2.16)", "tf2onnx"] tf-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] tiktoken = ["blobfile", "tiktoken"] timm = ["timm (<=1.0.11)"] tokenizers = ["tokenizers (>=0.21,<0.22)"] -torch = ["accelerate (>=0.26.0)", "torch"] +torch = ["accelerate (>=0.26.0)", "torch (>=2.0)"] torch-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)", "torchaudio"] torch-vision = ["Pillow (>=10.0.1,<=15.0)", "torchvision"] -torchhub = ["filelock", "huggingface-hub (>=0.24.0,<1.0)", "importlib-metadata", "numpy (>=1.17)", "packaging (>=20.0)", "protobuf", "regex (!=2019.12.17)", "requests", "sentencepiece (>=0.1.91,!=0.1.92)", "tokenizers (>=0.21,<0.22)", "torch", "tqdm (>=4.27)"] +torchhub = ["filelock", "huggingface-hub (>=0.24.0,<1.0)", "importlib-metadata", "numpy (>=1.17)", "packaging (>=20.0)", "protobuf", "regex (!=2019.12.17)", "requests", "sentencepiece (>=0.1.91,!=0.1.92)", "tokenizers (>=0.21,<0.22)", "torch (>=2.0)", "tqdm (>=4.27)"] video = ["av (==9.2.0)"] vision = ["Pillow (>=10.0.1,<=15.0)"] diff --git a/pyproject.toml b/pyproject.toml index bcad9515b..1b7df81e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,8 +89,6 @@ line-length = 120 target-version = "py311" [tool.ruff.lint] -ignore-init-module-imports = true - select = [ "F", "E", diff --git a/src/safeds/_utils/__init__.py b/src/safeds/_utils/__init__.py index 5bbd93abd..83e31d841 100644 --- a/src/safeds/_utils/__init__.py +++ b/src/safeds/_utils/__init__.py @@ -5,6 +5,7 @@ import apipkg if TYPE_CHECKING: + from ._collections import _compute_duplicates from ._hashing import _structural_hash from ._plotting import _figure_to_image from ._random import _get_random_seed @@ -12,6 +13,7 @@ apipkg.initpkg( __name__, { + "_compute_duplicates": "._collections:_compute_duplicates", "_structural_hash": "._hashing:_structural_hash", "_figure_to_image": "._plotting:_figure_to_image", "_get_random_seed": "._random:_get_random_seed", @@ -19,7 +21,8 @@ ) __all__ = [ - "_structural_hash", + "_compute_duplicates", "_figure_to_image", "_get_random_seed", + "_structural_hash", ] diff --git a/src/safeds/_utils/_collections.py b/src/safeds/_utils/_collections.py new file mode 100644 index 000000000..578efa31b --- /dev/null +++ b/src/safeds/_utils/_collections.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TypeVar + +T = TypeVar("T") + + +def _compute_duplicates(values: list[T], *, forbidden_values: set[T] | None = None) -> list[T]: + """ + Compute the duplicates in a list of values. + + Parameters + ---------- + values: + The values to check for duplicates. + forbidden_values: + Additional values that are considered duplicates if they occur. Defaults to an empty set. + + Returns + ------- + duplicates: + The duplicates in the list of values. + """ + if forbidden_values is None: + forbidden_values = set() + + duplicates = [] + for value in values: + if value in forbidden_values: + duplicates.append(value) + else: + forbidden_values.add(value) + + return duplicates diff --git a/src/safeds/_validation/__init__.py b/src/safeds/_validation/__init__.py index cd5cb49e8..4658b7cd2 100644 --- a/src/safeds/_validation/__init__.py +++ b/src/safeds/_validation/__init__.py @@ -5,25 +5,39 @@ import apipkg if TYPE_CHECKING: - from ._check_bounds import _check_bounds, _ClosedBound, _OpenBound - from ._check_columns_exist import _check_columns_exist - from ._normalize_and_check_file_path import _normalize_and_check_file_path + from ._check_bounds_module import _check_bounds, _ClosedBound, _OpenBound + from ._check_column_is_numeric_module import _check_column_is_numeric, _check_columns_are_numeric + from ._check_columns_dont_exist_module import _check_columns_dont_exist + from ._check_columns_exist_module import _check_columns_exist + from ._check_row_counts_are_equal_module import _check_row_counts_are_equal + from ._check_schema_module import _check_schema + from ._normalize_and_check_file_path_module import _normalize_and_check_file_path apipkg.initpkg( __name__, { - "_check_bounds": "._check_bounds:_check_bounds", - "_ClosedBound": "._check_bounds:_ClosedBound", - "_OpenBound": "._check_bounds:_OpenBound", - "_check_columns_exist": "._check_columns_exist:_check_columns_exist", - "_normalize_and_check_file_path": "._normalize_and_check_file_path:_normalize_and_check_file_path", + "_check_bounds": "._check_bounds_module:_check_bounds", + "_ClosedBound": "._check_bounds_module:_ClosedBound", + "_OpenBound": "._check_bounds_module:_OpenBound", + "_check_column_is_numeric": "._check_column_is_numeric_module:_check_column_is_numeric", + "_check_columns_are_numeric": "._check_column_is_numeric_module:_check_columns_are_numeric", + "_check_columns_dont_exist": "._check_columns_dont_exist_module:_check_columns_dont_exist", + "_check_columns_exist": "._check_columns_exist_module:_check_columns_exist", + "_check_row_counts_are_equal": "._check_row_counts_are_equal_module:_check_row_counts_are_equal", + "_check_schema": "._check_schema_module:_check_schema", + "_normalize_and_check_file_path": "._normalize_and_check_file_path_module:_normalize_and_check_file_path", }, ) __all__ = [ - "_check_bounds", "_ClosedBound", "_OpenBound", + "_check_bounds", + "_check_column_is_numeric", + "_check_columns_are_numeric", + "_check_columns_dont_exist", "_check_columns_exist", + "_check_row_counts_are_equal", + "_check_schema", "_normalize_and_check_file_path", ] diff --git a/src/safeds/_validation/_check_bounds.py b/src/safeds/_validation/_check_bounds_module.py similarity index 94% rename from src/safeds/_validation/_check_bounds.py rename to src/safeds/_validation/_check_bounds_module.py index 6b1392a26..5788e23f8 100644 --- a/src/safeds/_validation/_check_bounds.py +++ b/src/safeds/_validation/_check_bounds_module.py @@ -1,3 +1,5 @@ +"""The module name must differ from the function name, so it can be re-exported properly with apipkg.""" + from __future__ import annotations from abc import ABC, abstractmethod @@ -11,7 +13,7 @@ def _check_bounds( upper_bound: _Bound | None = None, ) -> None: """ - Check that a value is within the expected range and raise an error if it is not. + Check whether a value is within the expected range and raise an error if it is not. Parameters ---------- @@ -41,7 +43,7 @@ def _check_bounds( if not lower_bound._check_as_lower_bound(actual) or not upper_bound._check_as_upper_bound(actual): message = _build_error_message(name, actual, lower_bound, upper_bound) - raise OutOfBoundsError(message) + raise OutOfBoundsError(message) from None def _build_error_message(name: str, actual: float, lower_bound: _Bound, upper_bound: _Bound) -> str: diff --git a/src/safeds/_validation/_check_columns_are_numeric.py b/src/safeds/_validation/_check_column_is_numeric_module.py similarity index 89% rename from src/safeds/_validation/_check_columns_are_numeric.py rename to src/safeds/_validation/_check_column_is_numeric_module.py index 84ef66fec..edc5d8906 100644 --- a/src/safeds/_validation/_check_columns_are_numeric.py +++ b/src/safeds/_validation/_check_column_is_numeric_module.py @@ -1,3 +1,5 @@ +"""The module name must differ from the function name, so it can be re-exported properly with apipkg.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -17,7 +19,7 @@ def _check_column_is_numeric( operation: str = "do a numeric operation", ) -> None: """ - Check if the column is numeric and raise an error if it is not. + Check whether the column is numeric, and raise an error if it is not. Parameters ---------- @@ -33,7 +35,7 @@ def _check_column_is_numeric( """ if not column.type.is_numeric: message = _build_error_message([column.name], operation) - raise ColumnTypeError(message) + raise ColumnTypeError(message) from None def _check_columns_are_numeric( @@ -79,7 +81,7 @@ def _check_columns_are_numeric( ] if non_numeric_names: message = _build_error_message(non_numeric_names, operation) - raise ColumnTypeError(message) + raise ColumnTypeError(message) from None def _build_error_message(non_numeric_names: list[str], operation: str) -> str: diff --git a/src/safeds/_validation/_check_columns_dont_exist.py b/src/safeds/_validation/_check_columns_dont_exist_module.py similarity index 68% rename from src/safeds/_validation/_check_columns_dont_exist.py rename to src/safeds/_validation/_check_columns_dont_exist_module.py index f5ecf7923..d735acef4 100644 --- a/src/safeds/_validation/_check_columns_dont_exist.py +++ b/src/safeds/_validation/_check_columns_dont_exist_module.py @@ -1,12 +1,13 @@ +"""The module name must differ from the function name, so it can be re-exported properly with apipkg.""" + from __future__ import annotations from typing import TYPE_CHECKING +from safeds._utils import _compute_duplicates from safeds.exceptions import DuplicateColumnError if TYPE_CHECKING: - from collections.abc import Container - from safeds.data.tabular.containers import Table from safeds.data.tabular.typing import Schema @@ -18,7 +19,7 @@ def _check_columns_dont_exist( old_name: str | None = None, ) -> None: """ - Check if the specified column names don't exist in the table or schema yet and raise an error if they do. + Check whether the specified new column names do not exist yet and are unique, and raise an error if they do. Parameters ---------- @@ -41,16 +42,14 @@ def _check_columns_dont_exist( if isinstance(new_names, str): new_names = [new_names] - if len(new_names) > 1: - # Create a set for faster containment checks - known_names: Container = set(table_or_schema.column_names) - else: - known_names = table_or_schema.column_names + # Compute the duplicate names + known_names = set(table_or_schema.column_names) - {old_name} + duplicate_names = _compute_duplicates(new_names, forbidden_values=known_names) - duplicate_names = [name for name in new_names if name != old_name and name in known_names] + # Raise an error if duplicate names exist if duplicate_names: message = _build_error_message(duplicate_names) - raise DuplicateColumnError(message) + raise DuplicateColumnError(message) from None def _build_error_message(duplicate_names: list[str]) -> str: diff --git a/src/safeds/_validation/_check_columns_exist.py b/src/safeds/_validation/_check_columns_exist_module.py similarity index 77% rename from src/safeds/_validation/_check_columns_exist.py rename to src/safeds/_validation/_check_columns_exist_module.py index 6d266ca4e..eb6cdb6e3 100644 --- a/src/safeds/_validation/_check_columns_exist.py +++ b/src/safeds/_validation/_check_columns_exist_module.py @@ -1,3 +1,5 @@ +"""The module name must differ from the function name, so it can be re-exported properly with apipkg.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -13,7 +15,7 @@ def _check_columns_exist(table_or_schema: Table | Schema, requested_names: str | list[str]) -> None: """ - Check if the specified column names exist in the table or schema and raise an error if they do not. + Check whether the specified column names exist, and raise an error if they do not. Parameters ---------- @@ -43,7 +45,7 @@ def _check_columns_exist(table_or_schema: Table | Schema, requested_names: str | unknown_names = [name for name in requested_names if name not in known_names] if unknown_names: message = _build_error_message(table_or_schema, unknown_names) - raise ColumnNotFoundError(message) + raise ColumnNotFoundError(message) from None def _build_error_message(schema: Schema, unknown_names: list[str]) -> str: @@ -58,11 +60,12 @@ def _build_error_message(schema: Schema, unknown_names: list[str]) -> str: return message -def _get_similar_column_names(schema: Schema, unknown_name: str) -> list[str]: +def _get_similar_column_names(schema: Schema, name: str) -> list[str]: from difflib import get_close_matches - return get_close_matches( - unknown_name, - schema.column_names, - n=3, - ) + close_matches = get_close_matches(name, schema.column_names, n=3) + + if close_matches and close_matches[0] == name: + return close_matches[0:1] + else: + return close_matches diff --git a/src/safeds/_validation/_check_row_counts_are_equal_module.py b/src/safeds/_validation/_check_row_counts_are_equal_module.py new file mode 100644 index 000000000..7a3013a7b --- /dev/null +++ b/src/safeds/_validation/_check_row_counts_are_equal_module.py @@ -0,0 +1,84 @@ +"""The module name must differ from the function name, so it can be re-exported properly with apipkg.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from safeds.exceptions import LengthMismatchError + +if TYPE_CHECKING: + from collections.abc import Sequence + + from safeds.data.tabular.containers import Column, Table + + +def _check_row_counts_are_equal( + data: Sequence[Column | Table] | Mapping[str, Sequence[Any]], + *, + ignore_entries_without_rows: bool = False, +) -> None: + """ + Check whether all columns or tables have the same row count, and raise an error if they do not. + + Parameters + ---------- + data: + The columns or tables to check. + ignore_entries_without_rows: + Whether to ignore columns or tables that have no rows. + + Raises + ------ + LengthMismatchError + If some columns or tables have different row counts. + """ + if len(data) < 2: + return + + # Compute the mismatched columns + names_and_row_counts = _get_names_and_row_counts(data, ignore_entries_without_rows=ignore_entries_without_rows) + if not names_and_row_counts: + return + + first_name, first_row_count = names_and_row_counts[0] + mismatched_columns: list[tuple[str, int]] = [] + + for entry in names_and_row_counts[1:]: + if entry[1] != first_row_count: + mismatched_columns.append(entry) + + # Raise an error if there are mismatched columns + if mismatched_columns: + message = _build_error_message(names_and_row_counts[0], mismatched_columns) + raise LengthMismatchError(message) from None + + +def _get_names_and_row_counts( + data: Sequence[Column | Table] | Mapping[str, Sequence[Any]], + *, + ignore_entries_without_rows: bool = False, +) -> list[tuple[str, int]]: + from safeds.data.tabular.containers import Column, Table # circular import + + if isinstance(data, Mapping): + return [(f"Column '{name}'", len(column)) for name, column in data.items()] + else: + result = [] + + for i, entry in enumerate(data): + if isinstance(entry, Column) and (not ignore_entries_without_rows or len(entry) > 0): + result.append((f"Column '{entry.name}'", entry.row_count)) + elif isinstance(entry, Table) and (not ignore_entries_without_rows or entry.row_count > 0): + result.append((f"Table {i}", entry.row_count)) + + return result + + +def _build_error_message(first_entry: tuple[str, int], mismatched_entries: list[tuple[str, int]]) -> str: + result = f"{first_entry[0]} has {first_entry[1]} rows, which differs from:" + + for entry in mismatched_entries: + result += f"\n - {entry[0]} ({entry[1]} rows)" + + return result diff --git a/src/safeds/_validation/_check_schema_module.py b/src/safeds/_validation/_check_schema_module.py new file mode 100644 index 000000000..0902deee7 --- /dev/null +++ b/src/safeds/_validation/_check_schema_module.py @@ -0,0 +1,122 @@ +"""The module name must differ from the function name, so it can be re-exported properly with apipkg.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, TypeAlias + +from safeds.exceptions import SchemaError + +if TYPE_CHECKING: + import polars as pl + + from safeds.data.tabular.containers import Table + from safeds.data.tabular.typing import Schema + + +_TypeCheckingMode: TypeAlias = Literal["equality", "off"] + + +def _check_schema( + expected: Table | Schema, + actual: Table | Schema, + *, + check_names_exactly: bool = True, + check_types: _TypeCheckingMode = "equality", +) -> None: + """ + Check whether several schemas match, and raise an error if they do not. + + Parameters + ---------- + expected: + The expected schema. + actual: + The actual schema. + check_names_exactly: + Whether to check that column names of the expected and actual schema are equal (including order). If this is + turned off, the actual column names can be any superset of the expected column names. + check_types: + Whether to check the types of the columns. If "equal", the types must be equal. If "off", the types are not + checked. + + Raises + ------ + SchemaError + If the schemas do not match. + """ + from safeds.data.tabular.containers import Table # circular import + + expected_schema: Schema = expected.schema if isinstance(expected, Table) else expected + actual_schema: Schema = actual.schema if isinstance(actual, Table) else actual + + expected_column_names = expected_schema.column_names + actual_column_names = actual_schema.column_names + + # All columns must exist + missing_columns = set(expected_column_names) - set(actual_column_names) + if missing_columns: + message = _build_error_message_for_missing_columns(sorted(missing_columns)) + raise SchemaError(message) from None + + # There must be no additional columns + if check_names_exactly: + additional_columns = set(actual_column_names) - set(expected_column_names) + if additional_columns: + message = _build_error_message_for_additional_columns(sorted(additional_columns)) + raise SchemaError(message) from None + + # All columns must have the correct order + if check_names_exactly and expected_column_names != actual_column_names: + message = _build_error_message_for_columns_in_wrong_order(expected_column_names, actual_column_names) + raise SchemaError(message) from None + + # All columns must have the correct type + _check_types(expected_schema, actual_schema, check_types=check_types) + + +def _check_types(expected_schema: Schema, actual_schema: Schema, *, check_types: _TypeCheckingMode) -> None: + if check_types == "off": + return + + mismatched_types: list[tuple[str, pl.DataType, pl.DataType]] = [] + + for column_name in expected_schema.column_names: + expected_polars_dtype = _get_polars_dtype(expected_schema, column_name) + actual_polars_dtype = _get_polars_dtype(actual_schema, column_name) + + if expected_polars_dtype is None or actual_polars_dtype is None: # pragma: no cover + continue + + if check_types == "equality" and not actual_polars_dtype.is_(expected_polars_dtype): + mismatched_types.append((column_name, expected_polars_dtype, actual_polars_dtype)) + + if mismatched_types: + message = _build_error_message_for_column_types(mismatched_types) + raise SchemaError(message) + + +def _get_polars_dtype(schema: Schema, column_name: str) -> pl.DataType | None: + return schema.get_column_type(column_name)._polars_data_type + + +def _build_error_message_for_missing_columns(missing_column: list[str]) -> str: + return f"The columns {missing_column} are missing." + + +def _build_error_message_for_additional_columns(additional_columns: list[str]) -> str: + return f"The columns {additional_columns} are not expected." + + +def _build_error_message_for_columns_in_wrong_order(expected: list[str], actual: list[str]) -> str: + message = "The columns are in the wrong order:\n" + message += f" Expected: {expected}\n" + message += f" Actual: {actual}" + return message + + +def _build_error_message_for_column_types(mismatched_types: list[tuple[str, pl.DataType, pl.DataType]]) -> str: + message = "The following columns have the wrong type:" + for column_name, expected_type, actual_type in mismatched_types: + message += f"\n - '{column_name}': Expected '{expected_type}', but got '{actual_type}'." + + return message diff --git a/src/safeds/_validation/_normalize_and_check_file_path.py b/src/safeds/_validation/_normalize_and_check_file_path_module.py similarity index 79% rename from src/safeds/_validation/_normalize_and_check_file_path.py rename to src/safeds/_validation/_normalize_and_check_file_path_module.py index 4c4953c73..2ba054dfd 100644 --- a/src/safeds/_validation/_normalize_and_check_file_path.py +++ b/src/safeds/_validation/_normalize_and_check_file_path_module.py @@ -1,3 +1,5 @@ +"""The module name must differ from the function name, so it can be re-exported properly with apipkg.""" + from __future__ import annotations from pathlib import Path @@ -13,16 +15,16 @@ def _normalize_and_check_file_path( check_if_file_exists: bool = False, ) -> Path: """ - Check if the provided path is a valid file path and normalize it. + Check whether the provided path is a valid file path and normalize it. Parameters ---------- path: Path to check and normalize. canonical_file_extension: - If the path has no extension, this extension will be added. Should include the leading dot. + If the path has no extension, this extension will be added. It should include the leading dot. valid_file_extensions: - If the path has an extension, it must be in this set. Should include the leading dots. + If the path has an extension, it must be in this set. It should include the leading dots. check_if_file_exists: Whether to also check if the path points to an existing file. @@ -33,7 +35,7 @@ def _normalize_and_check_file_path( Raises ------ - ValueError + FileExtensionError If the path has an extension that is not in the `valid_file_extensions` list. FileNotFoundError If `check_if_file_exists` is True and the file does not exist. @@ -45,7 +47,7 @@ def _normalize_and_check_file_path( path = path.with_suffix(canonical_file_extension) elif path.suffix not in valid_file_extensions: message = _build_file_extension_error_message(path.suffix, valid_file_extensions) - raise FileExtensionError(message) + raise FileExtensionError(message) from None # Check if file exists if check_if_file_exists and not path.is_file(): diff --git a/src/safeds/data/image/containers/_multi_size_image_list.py b/src/safeds/data/image/containers/_multi_size_image_list.py index c9772a865..7568e41bb 100644 --- a/src/safeds/data/image/containers/_multi_size_image_list.py +++ b/src/safeds/data/image/containers/_multi_size_image_list.py @@ -112,7 +112,7 @@ def _create_image_list(images: list[Tensor], indices: list[int]) -> ImageList: image_index_dict[size].append(index) max_channel = max(max_channel, image.size(dim=-3)) - for size in image_tensor_dict: + for size in image_tensor_dict: # noqa: PLC0206 image_list._image_list_dict[size] = _SingleSizeImageList._create_image_list( image_tensor_dict[size], image_index_dict[size], @@ -320,7 +320,7 @@ def add_images(self, images: list[Image] | ImageList) -> ImageList: from safeds.data.image.containers._empty_image_list import _EmptyImageList from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList - if isinstance(images, _EmptyImageList) or isinstance(images, list) and len(images) == 0: + if isinstance(images, _EmptyImageList) or (isinstance(images, list) and len(images) == 0): return self indices_for_images_with_size = {} diff --git a/src/safeds/data/image/containers/_single_size_image_list.py b/src/safeds/data/image/containers/_single_size_image_list.py index dcac6a7ad..dacb6128c 100644 --- a/src/safeds/data/image/containers/_single_size_image_list.py +++ b/src/safeds/data/image/containers/_single_size_image_list.py @@ -557,7 +557,7 @@ def add_images(self, images: list[Image] | ImageList) -> ImageList: from safeds.data.image.containers._empty_image_list import _EmptyImageList from safeds.data.image.containers._multi_size_image_list import _MultiSizeImageList - if isinstance(images, _EmptyImageList) or isinstance(images, list) and len(images) == 0: + if isinstance(images, _EmptyImageList) or (isinstance(images, list) and len(images) == 0): return self next_index = max(self._tensor_positions_to_indices) + 1 @@ -593,7 +593,7 @@ def add_images(self, images: list[Image] | ImageList) -> ImageList: new_image_lists[self_size] = self.change_channel(max_channel) else: new_image_lists[self_size] = self - for size in images_with_sizes_with_channel: + for size in images_with_sizes_with_channel: # noqa: PLC0206 if size == self_size: new_tensor = torch.empty( len(self) + images_with_sizes_count[size], diff --git a/src/safeds/data/labeled/containers/_image_dataset.py b/src/safeds/data/labeled/containers/_image_dataset.py index a64bff83a..4434d1c43 100644 --- a/src/safeds/data/labeled/containers/_image_dataset.py +++ b/src/safeds/data/labeled/containers/_image_dataset.py @@ -18,8 +18,8 @@ from safeds.exceptions import ( IndexOutOfBoundsError, NonNumericColumnError, + NotFittedError, OutputLengthMismatchError, - TransformerNotFittedError, ) from ._dataset import Dataset @@ -482,7 +482,7 @@ def _from_tensor(tensor: Tensor, column_name: str, one_hot_encoder: OneHotEncode if tensor.dim() != 2: raise ValueError(f"Tensor has an invalid amount of dimensions. Needed 2 dimensions but got {tensor.dim()}.") if not one_hot_encoder.is_fitted: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") if tensor.size(dim=1) != len(one_hot_encoder._get_names_of_added_columns()): raise ValueError( f"Tensor and one_hot_encoder have different amounts of classes ({tensor.size(dim=1)}!={len(one_hot_encoder._get_names_of_added_columns())}).", diff --git a/src/safeds/data/labeled/containers/_tabular_dataset.py b/src/safeds/data/labeled/containers/_tabular_dataset.py index 5f2c9db19..d1e759c67 100644 --- a/src/safeds/data/labeled/containers/_tabular_dataset.py +++ b/src/safeds/data/labeled/containers/_tabular_dataset.py @@ -25,8 +25,8 @@ class TabularDataset(Dataset[Table, Column]): - The target column is the column that a model should predict. - Feature columns are columns that a model should use to make predictions. - - Extra columns are columns that are neither feature nor target. They can be used to provide additional context, - like an ID column. + - Extra columns are columns that are neither feature nor target. They are ignored by models and can be used to + provide additional context. An ID or name column is a common example. Feature columns are implicitly defined as all columns except the target and extra columns. If no extra columns are specified, all columns except the target column are used as features. @@ -38,17 +38,17 @@ class TabularDataset(Dataset[Table, Column]): target_name: The name of the target column. extra_names: - Names of the columns that are neither features nor target. If None, no extra columns are used, i.e. all but - the target column are used as features. + Names of the columns that are neither feature nor target. If None, no extra columns are used, i.e. all but the + target column are used as features. Raises ------ ColumnNotFoundError - If a column name is not found in the data. + If a target or extra column does not exist. ValueError If the target column is also an extra column. ValueError - If no feature columns remains. + If no feature column remains. Examples -------- @@ -57,10 +57,10 @@ class TabularDataset(Dataset[Table, Column]): ... { ... "id": [1, 2, 3], ... "feature": [4, 5, 6], - ... "target": [1, 2, 3], + ... "target": [7, 8, 9], ... }, ... ) - >>> dataset = table.to_tabular_dataset(target_name="target", extra_names=["id"]) + >>> dataset = table.to_tabular_dataset("target", extra_names="id") """ # ------------------------------------------------------------------------------------------------------------------ @@ -71,8 +71,9 @@ def __init__( self, data: Table | Mapping[str, Sequence[Any]], target_name: str, + /, # If we allow multiple targets in the future, we would rename the parameter to `target_names`. *, - extra_names: list[str] | None = None, + extra_names: str | list[str] | None = None, ): from safeds.data.tabular.containers import Table @@ -81,6 +82,8 @@ def __init__( data = Table(data) if extra_names is None: extra_names = [] + elif isinstance(extra_names, str): + extra_names = [extra_names] # Derive feature names (build the set once, since comprehensions evaluate their condition every iteration) non_feature_names = {target_name, *extra_names} @@ -88,15 +91,15 @@ def __init__( # Validate inputs if target_name in extra_names: - raise ValueError(f"Column '{target_name}' cannot be both target and extra.") + raise ValueError(f"Column '{target_name}' cannot be both target and extra column.") if len(feature_names) == 0: raise ValueError("At least one feature column must remain.") # Set attributes self._table: Table = data - self._features: Table = data.remove_columns_except(feature_names) + self._features: Table = data.select_columns(feature_names) self._target: Column = data.get_column(target_name) - self._extras: Table = data.remove_columns_except(extra_names) + self._extras: Table = data.select_columns(extra_names) def __eq__(self, other: object) -> bool: if not isinstance(other, TabularDataset): diff --git a/src/safeds/data/labeled/containers/_time_series_dataset.py b/src/safeds/data/labeled/containers/_time_series_dataset.py index f13887f09..2d34c4317 100644 --- a/src/safeds/data/labeled/containers/_time_series_dataset.py +++ b/src/safeds/data/labeled/containers/_time_series_dataset.py @@ -55,7 +55,7 @@ class TimeSeriesDataset(Dataset[Table, Column]): >>> from safeds.data.labeled.containers import TabularDataset >>> dataset = TimeSeriesDataset( ... {"id": [1, 2, 3], "feature": [4, 5, 6], "target": [1, 2, 3], "error":[0,0,1]}, - ... target_name="target", + ... "target", ... window_size=1, ... extra_names=["error"], ... ) @@ -94,11 +94,11 @@ def __init__( # Set attributes self._table: Table = data - self._features: Table = data.remove_columns_except(feature_names) + self._features: Table = data.select_columns(feature_names) self._target: Column = data.get_column(target_name) self._window_size: int = window_size self._forecast_horizon: int = forecast_horizon - self._extras: Table = data.remove_columns_except(extra_names) + self._extras: Table = data.select_columns(extra_names) self._continuous: bool = continuous def __eq__(self, other: object) -> bool: diff --git a/src/safeds/data/tabular/containers/__init__.py b/src/safeds/data/tabular/containers/__init__.py index 18cc631cd..5c0499ed0 100644 --- a/src/safeds/data/tabular/containers/__init__.py +++ b/src/safeds/data/tabular/containers/__init__.py @@ -29,6 +29,6 @@ "Column", "Row", "StringCell", - "TemporalCell", "Table", + "TemporalCell", ] diff --git a/src/safeds/data/tabular/containers/_cell.py b/src/safeds/data/tabular/containers/_cell.py index 5c8650f81..877159940 100644 --- a/src/safeds/data/tabular/containers/_cell.py +++ b/src/safeds/data/tabular/containers/_cell.py @@ -1,7 +1,9 @@ from __future__ import annotations +import datetime from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeVar if TYPE_CHECKING: import polars as pl @@ -14,17 +16,43 @@ R_co = TypeVar("R_co", covariant=True) +_NumericLiteral: TypeAlias = int | float | Decimal +_TemporalLiteral: TypeAlias = datetime.date | datetime.time | datetime.datetime | datetime.timedelta +_PythonLiteral: TypeAlias = _NumericLiteral | bool | str | bytes | _TemporalLiteral | None + + class Cell(ABC, Generic[T_co]): """ A single value in a table. - This class cannot be instantiated directly. It is only used for arguments of callbacks. + You only need to interact with this class in callbacks passed to higher-order functions. """ # ------------------------------------------------------------------------------------------------------------------ # Static methods # ------------------------------------------------------------------------------------------------------------------ + @staticmethod + def from_literal(value: _PythonLiteral) -> Cell: + """ + Create a new cell from a literal value. + + Parameters + ---------- + value: + The value to create the cell from. + + Returns + ------- + cell: + The created cell. + """ + import polars as pl + + from ._lazy_cell import _LazyCell # circular import + + return _LazyCell(pl.lit(value)) + @staticmethod def first_not_none(cells: list[Cell]) -> Cell: """ @@ -38,8 +66,8 @@ def first_not_none(cells: list[Cell]) -> Cell: Returns ------- cell: - Returns the contents of the first cell that is not None. - If all cells in the list are None or the list is empty returns None. + Returns the contents of the first cell that is not None. If all cells in the list are None or the list is + empty returns None. """ import polars as pl diff --git a/src/safeds/data/tabular/containers/_column.py b/src/safeds/data/tabular/containers/_column.py index 0f18c8b27..8564176c3 100644 --- a/src/safeds/data/tabular/containers/_column.py +++ b/src/safeds/data/tabular/containers/_column.py @@ -4,12 +4,12 @@ from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload from safeds._utils import _structural_hash -from safeds._validation._check_columns_are_numeric import _check_column_is_numeric +from safeds._validation import _check_column_is_numeric from safeds.data.tabular.plotting import ColumnPlotter -from safeds.data.tabular.typing._polars_data_type import _PolarsDataType +from safeds.data.tabular.typing._polars_column_type import _PolarsColumnType from safeds.exceptions import ( - ColumnLengthMismatchError, IndexOutOfBoundsError, + LengthMismatchError, MissingValuesColumnError, ) @@ -18,7 +18,7 @@ if TYPE_CHECKING: from polars import Series - from safeds.data.tabular.typing import DataType + from safeds.data.tabular.typing import ColumnType from ._cell import Cell from ._table import Table @@ -28,6 +28,9 @@ R_co = TypeVar("R_co", covariant=True) +# TODO: Rethink whether T_co should include None, also affects Cell operations ('<' return Cell[bool | None] etc.) + + class Column(Sequence[T_co]): """ A named, one-dimensional collection of homogeneous values. @@ -127,16 +130,6 @@ def __str__(self) -> str: # Properties # ------------------------------------------------------------------------------------------------------------------ - @property - def is_numeric(self) -> bool: - """Whether the column is numeric.""" - return self._series.dtype.is_numeric() - - @property - def is_temporal(self) -> bool: - """Whether the column is operator.""" - return self._series.dtype.is_temporal() - @property def name(self) -> str: """The name of the column.""" @@ -153,9 +146,9 @@ def plot(self) -> ColumnPlotter: return ColumnPlotter(self) @property - def type(self) -> DataType: + def type(self) -> ColumnType: """The type of the column.""" - return _PolarsDataType(self._series.dtype) + return _PolarsColumnType(self._series.dtype) # ------------------------------------------------------------------------------------------------------------------ # Value operations @@ -262,7 +255,7 @@ def get_value(self, index: int) -> T_co: @overload def all( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: Literal[True] = ..., ) -> bool: ... @@ -270,19 +263,19 @@ def all( @overload def all( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: bool, ) -> bool | None: ... def all( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: bool = True, ) -> bool | None: """ - Return whether all values in the column satisfy the predicate. + Check whether all values in the column satisfy the predicate. The predicate can return one of three values: @@ -337,7 +330,7 @@ def all( @overload def any( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: Literal[True] = ..., ) -> bool: ... @@ -345,19 +338,19 @@ def any( @overload def any( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: bool, ) -> bool | None: ... def any( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: bool = True, ) -> bool | None: """ - Return whether any value in the column satisfies the predicate. + Check whether any value in the column satisfies the predicate. The predicate can return one of three values: @@ -412,7 +405,7 @@ def any( @overload def count_if( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: Literal[True] = ..., ) -> int: ... @@ -420,19 +413,19 @@ def count_if( @overload def count_if( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: bool, ) -> int | None: ... def count_if( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: bool = True, ) -> int | None: """ - Return how many values in the column satisfy the predicate. + Count how many values in the column satisfy the predicate. The predicate can return one of three results: @@ -482,7 +475,7 @@ def count_if( @overload def none( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: Literal[True] = ..., ) -> bool: ... @@ -490,19 +483,19 @@ def none( @overload def none( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: bool, ) -> bool | None: ... def none( self, - predicate: Callable[[Cell[T_co]], Cell[bool | None]], + predicate: Callable[[Cell[T_co]], Cell[bool]], *, ignore_unknown: bool = True, ) -> bool | None: """ - Return whether no value in the column satisfies the predicate. + Check whether no value in the column satisfies the predicate. The predicate can return one of three values: @@ -560,7 +553,7 @@ def none( def rename(self, new_name: str) -> Column[T_co]: """ - Return a new column with a new name. + Rename the column and return the result as a new column. **Note:** The original column is not modified. @@ -596,7 +589,7 @@ def transform( transformer: Callable[[Cell[T_co]], Cell[R_co]], ) -> Column[R_co]: """ - Return a new column with values transformed by the transformer. + Transform the valus in the column and return the result as a new column. **Note:** The original column is not modified. @@ -641,6 +634,11 @@ def summarize_statistics(self) -> Table: """ Return a table with important statistics about the column. + !!! warning "API Stability" + + Do not rely on the exact output of this method. In future versions, we may change the displayed statistics + without prior notice. + Returns ------- statistics: @@ -651,69 +649,24 @@ def summarize_statistics(self) -> Table: >>> from safeds.data.tabular.containers import Column >>> column = Column("a", [1, 3]) >>> column.summarize_statistics() - +----------------------+---------+ - | metric | a | - | --- | --- | - | str | f64 | - +================================+ - | min | 1.00000 | - | max | 3.00000 | - | mean | 2.00000 | - | median | 2.00000 | - | standard deviation | 1.41421 | - | distinct value count | 2.00000 | - | idness | 1.00000 | - | missing value ratio | 0.00000 | - | stability | 0.50000 | - +----------------------+---------+ + +---------------------+---------+ + | statistic | a | + | --- | --- | + | str | f64 | + +===============================+ + | min | 1.00000 | + | max | 3.00000 | + | mean | 2.00000 | + | median | 2.00000 | + | standard deviation | 1.41421 | + | missing value ratio | 0.00000 | + | stability | 0.50000 | + | idness | 1.00000 | + +---------------------+---------+ """ from ._table import Table - # TODO: turn this around (call table method, implement in table; allows parallelization) - if self.is_numeric: - values: list[Any] = [ - self.min(), - self.max(), - self.mean(), - self.median(), - self.standard_deviation(), - self.distinct_value_count(), - self.idness(), - self.missing_value_ratio(), - self.stability(), - ] - else: - min_ = self.min() - max_ = self.max() - - values = [ - str("-" if min_ is None else min_), - str("-" if max_ is None else max_), - "-", - "-", - "-", - str(self.distinct_value_count()), - str(self.idness()), - str(self.missing_value_ratio()), - str(self.stability()), - ] - - return Table( - { - "metric": [ - "min", - "max", - "mean", - "median", - "standard deviation", - "distinct value count", - "idness", - "missing value ratio", - "stability", - ], - self.name: values, - }, - ) + return Table.from_columns(self).summarize_statistics() def correlation_with(self, other: Column) -> float: """ @@ -763,7 +716,7 @@ def correlation_with(self, other: Column) -> float: _check_column_is_numeric(other, operation="calculate the correlation") if self.row_count != other.row_count: - raise ColumnLengthMismatchError("") # TODO: Add column names to error message + raise LengthMismatchError("") # TODO: Add column names to error message if self.missing_value_count() > 0 or other.missing_value_count() > 0: raise MissingValuesColumnError("") # TODO: Add column names to error message @@ -790,9 +743,12 @@ def distinct_value_count( Examples -------- >>> from safeds.data.tabular.containers import Column - >>> column = Column("test", [1, 2, 3, 2]) + >>> column = Column("test", [1, 2, 3, 2, None]) >>> column.distinct_value_count() 3 + + >>> column.distinct_value_count(ignore_missing_values=False) + 4 """ if ignore_missing_values: return self._series.drop_nulls().n_unique() @@ -801,13 +757,14 @@ def distinct_value_count( def idness(self) -> float: """ - Calculate the idness of this column. + Return the idness of this column. We define the idness as the number of distinct values (including missing values) divided by the number of rows. If the column is empty, the idness is 1.0. - A high idness indicates that the column most values in the column are unique. In this case, you must be careful - when using the column for analysis, as a model may learn a mapping from this column to the target. + A high idness indicates that most values in the column are unique. In this case, you must be careful when using + the column for analysis, as a model may learn a mapping from this column to the target, which might not + generalize well. Returns ------- @@ -828,7 +785,7 @@ def idness(self) -> float: if self.row_count == 0: return 1.0 # All values are unique (since there are none) - return self.distinct_value_count(ignore_missing_values=False) / self.row_count + return self._series.n_unique() / self.row_count def max(self) -> T_co | None: """ @@ -904,6 +861,10 @@ def median(self) -> T_co: >>> column = Column("test", [1, 2, 3]) >>> column.median() 2.0 + + >>> column = Column("test", [1, 2, 3, 4]) + >>> column.median() + 2.5 """ _check_column_is_numeric(self, operation="calculate the median") @@ -1064,7 +1025,7 @@ def stability(self) -> float: if non_missing.len() == 0: return 1.0 # All non-null values are the same (since there is are none) - # `unique_counts` crashes in polars for boolean columns + # `unique_counts` crashes in polars for boolean columns (https://github.com/pola-rs/polars/issues/16356) mode_count = non_missing.value_counts().get_column("count").max() return mode_count / non_missing.len() @@ -1078,8 +1039,7 @@ def standard_deviation(self) -> float: Returns ------- standard_deviation: - The standard deviation of the values in the column. If no standard deviation can be calculated due to the - type of the column, None is returned. + The standard deviation of the values in the column. Raises ------ @@ -1111,8 +1071,7 @@ def variance(self) -> float: Returns ------- variance: - The variance of the values in the column. If no variance can be calculated due to the type of the column, - None is returned. + The variance of the values in the column. Examples -------- diff --git a/src/safeds/data/tabular/containers/_lazy_vectorized_row.py b/src/safeds/data/tabular/containers/_lazy_vectorized_row.py index b54b60ae6..1a53da529 100644 --- a/src/safeds/data/tabular/containers/_lazy_vectorized_row.py +++ b/src/safeds/data/tabular/containers/_lazy_vectorized_row.py @@ -8,7 +8,7 @@ from ._row import Row if TYPE_CHECKING: - from safeds.data.tabular.typing import DataType, Schema + from safeds.data.tabular.typing import ColumnType, Schema from ._table import Table @@ -71,7 +71,7 @@ def get_cell(self, name: str) -> _LazyCell: return _LazyCell(pl.col(name)) - def get_column_type(self, name: str) -> DataType: + def get_column_type(self, name: str) -> ColumnType: return self._table.get_column_type(name) def has_column(self, name: str) -> bool: diff --git a/src/safeds/data/tabular/containers/_row.py b/src/safeds/data/tabular/containers/_row.py index ec3a5d3a3..6d43fb570 100644 --- a/src/safeds/data/tabular/containers/_row.py +++ b/src/safeds/data/tabular/containers/_row.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from safeds.data.tabular.typing import DataType, Schema + from safeds.data.tabular.typing import ColumnType, Schema from ._cell import Cell @@ -110,7 +110,7 @@ def get_cell(self, name: str) -> Cell: """ @abstractmethod - def get_column_type(self, name: str) -> DataType: + def get_column_type(self, name: str) -> ColumnType: """ Get the type of the specified column. diff --git a/src/safeds/data/tabular/containers/_table.py b/src/safeds/data/tabular/containers/_table.py index 5ca59a53c..c39f96c6e 100644 --- a/src/safeds/data/tabular/containers/_table.py +++ b/src/safeds/data/tabular/containers/_table.py @@ -4,15 +4,21 @@ from safeds._config import _get_device, _init_default_device from safeds._config._polars import _get_polars_config -from safeds._utils import _structural_hash -from safeds._utils._random import _get_random_seed -from safeds._validation import _check_bounds, _check_columns_exist, _ClosedBound, _normalize_and_check_file_path -from safeds._validation._check_columns_dont_exist import _check_columns_dont_exist +from safeds._utils import _compute_duplicates, _structural_hash +from safeds._validation import ( + _check_bounds, + _check_columns_dont_exist, + _check_columns_exist, + _check_row_counts_are_equal, + _check_schema, + _ClosedBound, + _normalize_and_check_file_path, +) from safeds.data.tabular.plotting import TablePlotter -from safeds.data.tabular.typing._polars_schema import _PolarsSchema +from safeds.data.tabular.typing import Schema from safeds.exceptions import ( - ColumnLengthMismatchError, DuplicateColumnError, + LengthMismatchError, ) from ._column import Column @@ -25,15 +31,24 @@ import polars as pl import torch + from polars.interchange.protocol import DataFrame from torch import Tensor from torch.utils.data import DataLoader, Dataset - from safeds.data.labeled.containers import TabularDataset, TimeSeriesDataset + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.transformation import ( InvertibleTableTransformer, TableTransformer, ) - from safeds.data.tabular.typing import DataType, Schema + from safeds.data.tabular.typing import ColumnType + from safeds.exceptions import ( # noqa: F401 + ColumnNotFoundError, + ColumnTypeError, + FileExtensionError, + NotFittedError, + NotInvertibleError, + OutOfBoundsError, + ) from ._cell import Cell from ._row import Row @@ -66,7 +81,16 @@ class Table: Examples -------- >>> from safeds.data.tabular.containers import Table - >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) + >>> Table({"a": [1, 2, 3], "b": [4, 5, 6]}) + +-----+-----+ + | a | b | + | --- | --- | + | i64 | i64 | + +===========+ + | 1 | 4 | + | 2 | 5 | + | 3 | 6 | + +-----+-----+ """ # ------------------------------------------------------------------------------------------------------------------ @@ -76,7 +100,7 @@ class Table: @staticmethod def from_columns(columns: Column | list[Column]) -> Table: """ - Create a table from a list of columns. + Create a table from columns. Parameters ---------- @@ -88,6 +112,13 @@ def from_columns(columns: Column | list[Column]) -> Table: table: The created table. + Raises + ------ + LengthMismatchError + If some columns have different lengths. + DuplicateColumnError + If multiple columns have the same name. + Examples -------- >>> from safeds.data.tabular.containers import Column, Table @@ -114,10 +145,13 @@ def from_columns(columns: Column | list[Column]) -> Table: return Table._from_polars_lazy_frame( pl.LazyFrame([column._series for column in columns]), ) + # polars already validates this, so we don't do it upfront (performance) except DuplicateError: - raise DuplicateColumnError("") from None # TODO: message + _check_columns_dont_exist(Table({}), [column.name for column in columns]) + return Table({}) # pragma: no cover except ShapeError: - raise ColumnLengthMismatchError("") from None # TODO: message + _check_row_counts_are_equal(columns) + return Table({}) # pragma: no cover @staticmethod def from_csv_file(path: str | Path, *, separator: str = ",") -> Table: @@ -138,10 +172,10 @@ def from_csv_file(path: str | Path, *, separator: str = ",") -> Table: Raises ------ + FileExtensionError + If the path has an extension that is not ".csv". FileNotFoundError If no file exists at the given path. - ValueError - If the path has an extension that is not ".csv". Examples -------- @@ -155,6 +189,11 @@ def from_csv_file(path: str | Path, *, separator: str = ",") -> Table: | 1 | 2 | 1 | | 0 | 0 | 7 | +-----+-----+-----+ + + Related + ------- + - [from_json_file][safeds.data.tabular.containers._table.Table.from_json_file] + - [from_parquet_file][safeds.data.tabular.containers._table.Table.from_parquet_file] """ import polars as pl @@ -179,8 +218,8 @@ def from_dict(data: dict[str, list[Any]]) -> Table: Raises ------ - ValueError - If columns have different lengths. + LengthMismatchError + If columns have different row counts. Examples -------- @@ -216,10 +255,10 @@ def from_json_file(path: str | Path) -> Table: Raises ------ + FileExtensionError + If the path has an extension that is not ".json". FileNotFoundError If no file exists at the given path. - ValueError - If the path has an extension that is not ".json". Examples -------- @@ -234,16 +273,17 @@ def from_json_file(path: str | Path) -> Table: | 2 | 5 | | 3 | 6 | +-----+-----+ + + Related + ------- + - [from_csv_file][safeds.data.tabular.containers._table.Table.from_csv_file] + - [from_parquet_file][safeds.data.tabular.containers._table.Table.from_parquet_file] """ import polars as pl path = _normalize_and_check_file_path(path, ".json", [".json"], check_if_file_exists=True) - try: - return Table._from_polars_data_frame(pl.read_json(path)) - except (pl.exceptions.PanicException, pl.exceptions.ComputeError): - # Can happen if the JSON file is empty (https://github.com/pola-rs/polars/issues/10234) - return Table({}) + return Table._from_polars_data_frame(pl.read_json(path)) @staticmethod def from_parquet_file(path: str | Path) -> Table: @@ -262,10 +302,10 @@ def from_parquet_file(path: str | Path) -> Table: Raises ------ + FileExtensionError + If the path has an extension that is not ".parquet". FileNotFoundError If no file exists at the given path. - ValueError - If the path has an extension that is not ".parquet". Examples -------- @@ -280,6 +320,11 @@ def from_parquet_file(path: str | Path) -> Table: | 2 | 5 | | 3 | 6 | +-----+-----+ + + Related + ------- + - [from_csv_file][safeds.data.tabular.containers._table.Table.from_csv_file] + - [from_json_file][safeds.data.tabular.containers._table.Table.from_json_file] """ import polars as pl @@ -304,18 +349,11 @@ def _from_polars_lazy_frame(data: pl.LazyFrame) -> Table: # Dunder methods # ------------------------------------------------------------------------------------------------------------------ - def __init__(self, data: Mapping[str, Sequence[Any]]) -> None: + def __init__(self, data: Mapping[str, Sequence[object]]) -> None: import polars as pl # Validation - expected_length: int | None = None - for column_values in data.values(): - if expected_length is None: - expected_length = len(column_values) - elif len(column_values) != expected_length: - raise ColumnLengthMismatchError( - "\n".join(f"{column_name}: {len(column_values)}" for column_name, column_values in data.items()), - ) + _check_row_counts_are_equal(data) # Implementation self._lazy_frame: pl.LazyFrame = pl.LazyFrame(data, strict=False) @@ -349,14 +387,8 @@ def __str__(self) -> str: @property def _data_frame(self) -> pl.DataFrame: - import polars as pl - if self.__data_frame_cache is None: - try: - self.__data_frame_cache = self._lazy_frame.collect() - except (pl.NoDataError, pl.PolarsPanicError): - # Can happen for some operations on empty tables (e.g. https://github.com/pola-rs/polars/issues/16202) - return pl.DataFrame() + self.__data_frame_cache = self._lazy_frame.collect() return self.__data_frame_cache @@ -365,6 +397,8 @@ def column_names(self) -> list[str]: """ The names of the columns in the table. + **Note:** This operation must compute the schema of the table, which can be expensive. + Examples -------- >>> from safeds.data.tabular.containers import Table @@ -379,6 +413,8 @@ def column_count(self) -> int: """ The number of columns in the table. + **Note:** This operation must compute the schema of the table, which can be expensive. + Examples -------- >>> from safeds.data.tabular.containers import Table @@ -406,19 +442,35 @@ def row_count(self) -> int: @property def plot(self) -> TablePlotter: - """The plotter for the table.""" + """ + The plotter for the table. + + Call methods of the plotter to create various plots for the table. + + Examples + -------- + >>> from safeds.data.tabular.containers import Table + >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) + >>> plot = table.plot.box_plots() + """ return TablePlotter(self) @property def schema(self) -> Schema: - """The schema of the table.""" - import polars as pl + """ + The schema of the table. - try: - return _PolarsSchema(self._lazy_frame.collect_schema()) - except (pl.exceptions.NoDataError, pl.exceptions.PanicException): - # Can happen for some operations on empty tables (e.g. https://github.com/pola-rs/polars/issues/16202) - return _PolarsSchema(pl.Schema({})) + Examples + -------- + >>> from safeds.data.tabular.containers import Table + >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) + >>> table.schema + Schema({ + 'a': int64, + 'b': int64 + }) + """ + return Schema._from_polars_schema(self._lazy_frame.collect_schema()) # ------------------------------------------------------------------------------------------------------------------ # Column operations @@ -426,10 +478,10 @@ def schema(self) -> Schema: def add_columns( self, - columns: Column | list[Column], + columns: Column | list[Column] | Table, ) -> Table: """ - Return a new table with additional columns. + Add columns to the table and return the result as a new table. **Notes:** @@ -448,10 +500,10 @@ def add_columns( Raises ------ - ValueError - If a column name already exists. - ValueError - If the columns have incompatible lengths. + DuplicateColumnError + If a column name exists already. This can also happen if the new columns have duplicate names. + LengthMismatchError + If the columns have different row counts. Examples -------- @@ -468,12 +520,20 @@ def add_columns( | 2 | 5 | | 3 | 6 | +-----+-----+ + + Related + ------- + - [add_computed_column][safeds.data.tabular.containers._table.Table.add_computed_column]: + Add a column with values computed from other columns. + - [add_index_column][safeds.data.tabular.containers._table.Table.add_index_column] """ - from polars.exceptions import DuplicateError + from polars.exceptions import DuplicateError, ShapeError + + if isinstance(columns, Table): + return self.add_tables_as_columns(columns) if isinstance(columns, Column): columns = [columns] - if len(columns) == 0: return self @@ -481,10 +541,13 @@ def add_columns( return Table._from_polars_data_frame( self._data_frame.hstack([column._series for column in columns]), ) + # polars already validates this, so we don't do it upfront (performance) except DuplicateError: - # polars already validates this, so we don't need to do it again upfront (performance) _check_columns_dont_exist(self, [column.name for column in columns]) return Table({}) # pragma: no cover + except ShapeError: + _check_row_counts_are_equal([self, *columns]) + return Table({}) # pragma: no cover def add_computed_column( self, @@ -492,7 +555,7 @@ def add_computed_column( computer: Callable[[Row], Cell], ) -> Table: """ - Return a new table with an additional computed column. + Add a computed column to the table and return the result as a new table. **Note:** The original table is not modified. @@ -510,8 +573,8 @@ def add_computed_column( Raises ------ - ValueError - If the column name already exists. + DuplicateColumnError + If the column name exists already. Examples -------- @@ -527,9 +590,20 @@ def add_computed_column( | 2 | 5 | 7 | | 3 | 6 | 9 | +-----+-----+-----+ + + Related + ------- + - [add_columns][safeds.data.tabular.containers._table.Table.add_columns]: + Add column objects to the table. + - [add_index_column][safeds.data.tabular.containers._table.Table.add_index_column] + - [transform_column][safeds.data.tabular.containers._table.Table.transform_column]: + Transform an existing column with a custom function. """ - if self.has_column(name): - raise DuplicateColumnError(name) + _check_columns_dont_exist(self, name) + + # When called on a frame without columns, a pl.lit expression adds a single column with a single row + if self.column_count == 0: + return self.add_columns(Column(name, [])) computed_column = computer(_LazyVectorizedRow(self)) @@ -537,6 +611,75 @@ def add_computed_column( self._lazy_frame.with_columns(computed_column._polars_expression.alias(name)), ) + def add_index_column(self, name: str, *, first_index: int = 0) -> Table: + """ + Add an index column to the table and return the result as a new table. + + **Note:** The original table is not modified. + + Parameters + ---------- + name: + The name of the new column. + first_index: + The index to assign to the first row. Must be greater or equal to 0. + + Returns + ------- + new_table: + The table with the index column. + + Raises + ------ + DuplicateColumnError + If the column name exists already. + OutOfBoundsError + If `first_index` is negative. + + Examples + -------- + >>> from safeds.data.tabular.containers import Table + >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) + >>> table.add_index_column("id") + +-----+-----+-----+ + | id | a | b | + | --- | --- | --- | + | u32 | i64 | i64 | + +=================+ + | 0 | 1 | 4 | + | 1 | 2 | 5 | + | 2 | 3 | 6 | + +-----+-----+-----+ + + >>> table.add_index_column("id", first_index=10) + +-----+-----+-----+ + | id | a | b | + | --- | --- | --- | + | u32 | i64 | i64 | + +=================+ + | 10 | 1 | 4 | + | 11 | 2 | 5 | + | 12 | 3 | 6 | + +-----+-----+-----+ + + Related + ------- + - [add_columns][safeds.data.tabular.containers._table.Table.add_columns]: + Add column objects to the table. + - [add_computed_column][safeds.data.tabular.containers._table.Table.add_computed_column]: + Add a column with values computed from other columns. + """ + _check_columns_dont_exist(self, name) + _check_bounds( + "first_index", + first_index, + lower_bound=_ClosedBound(0), + ) + + return Table._from_polars_lazy_frame( + self._lazy_frame.with_row_index(name, offset=first_index), + ) + def get_column(self, name: str) -> Column: """ Get a column from the table. @@ -576,9 +719,9 @@ def get_column(self, name: str) -> Column: self._lazy_frame.select(name).collect().get_column(name), ) - def get_column_type(self, name: str) -> DataType: + def get_column_type(self, name: str) -> ColumnType: """ - Get the data type of a column. + Get the type of a column. Parameters ---------- @@ -588,7 +731,7 @@ def get_column_type(self, name: str) -> DataType: Returns ------- type: - The data type of the column. + The type of the column. Raises ------ @@ -600,7 +743,7 @@ def get_column_type(self, name: str) -> DataType: >>> from safeds.data.tabular.containers import Table >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) >>> table.get_column_type("a") - Int64 + int64 """ return self.schema.get_column_type(name) @@ -624,22 +767,22 @@ def has_column(self, name: str) -> bool: >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) >>> table.has_column("a") True + + >>> table.has_column("c") + False """ return self.schema.has_column(name) def remove_columns( self, names: str | list[str], - /, *, ignore_unknown_names: bool = False, ) -> Table: """ - Return a new table without the specified columns. + Remove the specified columns from the table and return the result as a new table. - **Notes:** - - - The original table is not modified. + **Note:** The original table is not modified. Parameters ---------- @@ -654,6 +797,11 @@ def remove_columns( new_table: The table with the columns removed. + Raises + ------ + ColumnNotFoundError + If a column does not exist and unknown names are not ignored. + Examples -------- >>> from safeds.data.tabular.containers import Table @@ -679,6 +827,13 @@ def remove_columns( | 2 | 5 | | 3 | 6 | +-----+-----+ + + Related + ------- + - [select_columns][safeds.data.tabular.containers._table.Table.select_columns]: + Keep only a subset of the columns. This method accepts either column names, or a predicate. + - [remove_columns_with_missing_values][safeds.data.tabular.containers._table.Table.remove_columns_with_missing_values] + - [remove_non_numeric_columns][safeds.data.tabular.containers._table.Table.remove_non_numeric_columns] """ if isinstance(names, str): names = [names] @@ -690,34 +845,43 @@ def remove_columns( self._lazy_frame.drop(names, strict=not ignore_unknown_names), ) - def remove_columns_except( + def remove_columns_with_missing_values( self, - names: str | list[str], - /, + *, + missing_value_ratio_threshold: float = 0, ) -> Table: """ - Return a new table with only the specified columns. + Remove columns with too many missing values and return the result as a new table. + + How many missing values are allowed is determined by the `missing_value_ratio_threshold` parameter. A column is + removed if its missing value ratio is greater than the threshold. By default, a column is removed if it contains + any missing values. + + **Notes:** + + - The original table is not modified. + - This operation must fully load the data into memory, which can be expensive. Parameters ---------- - names: - The names of the columns to keep. + missing_value_ratio_threshold: + The maximum missing value ratio a column can have to be kept (inclusive). Must be between 0 and 1. Returns ------- new_table: - The table with only the specified columns. + The table without columns that contain too many missing values. Raises ------ - ColumnNotFoundError - If a column does not exist. + OutOfBoundsError + If the `missing_value_ratio_threshold` is not between 0 and 1. Examples -------- >>> from safeds.data.tabular.containers import Table - >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) - >>> table.remove_columns_except("a") + >>> table = Table({"a": [1, 2, 3], "b": [4, 5, None]}) + >>> table.remove_columns_with_missing_values() +-----+ | a | | --- | @@ -727,56 +891,44 @@ def remove_columns_except( | 2 | | 3 | +-----+ - """ - if isinstance(names, str): - names = [names] - - _check_columns_exist(self, names) - - return Table._from_polars_lazy_frame( - self._lazy_frame.select(names), - ) - def remove_columns_with_missing_values(self) -> Table: + Related + ------- + - [remove_rows_with_missing_values][safeds.data.tabular.containers._table.Table.remove_rows_with_missing_values] + - [SimpleImputer][safeds.data.tabular.transformation._simple_imputer.SimpleImputer]: + Replace missing values with a constant value or a statistic of the column. + - [KNearestNeighborsImputer][safeds.data.tabular.transformation._k_nearest_neighbors_imputer.KNearestNeighborsImputer]: + Replace missing values with a value computed from the nearest neighbors. + - [select_columns][safeds.data.tabular.containers._table.Table.select_columns]: + Keep only a subset of the columns. This method accepts either column names, or a predicate. + - [remove_columns][safeds.data.tabular.containers._table.Table.remove_columns]: + Remove columns from the table by name. + - [remove_non_numeric_columns][safeds.data.tabular.containers._table.Table.remove_non_numeric_columns] """ - Return a new table without columns that contain missing values. - - **Notes:** + import polars as pl - - The original table is not modified. - - This operation must fully load the data into memory, which can be expensive. + _check_bounds( + "max_missing_value_ratio", + missing_value_ratio_threshold, + lower_bound=_ClosedBound(0), + upper_bound=_ClosedBound(1), + ) - Returns - ------- - new_table: - The table without columns containing missing values. + # Collect the data here, since we need it again later + mask = self._data_frame.select( + (pl.all().null_count() / pl.len() <= missing_value_ratio_threshold), + ) - Examples - -------- - >>> from safeds.data.tabular.containers import Table - >>> table = Table({"a": [1, 2, 3], "b": [4, 5, None]}) - >>> table.remove_columns_with_missing_values() - +-----+ - | a | - | --- | - | i64 | - +=====+ - | 1 | - | 2 | - | 3 | - +-----+ - """ - import polars as pl + if mask.is_empty(): + return Table({}) - return Table._from_polars_lazy_frame( - pl.LazyFrame( - [series for series in self._data_frame.get_columns() if series.null_count() == 0], - ), + return Table._from_polars_data_frame( + self._data_frame[:, mask.row(0)], ) def remove_non_numeric_columns(self) -> Table: """ - Return a new table without non-numeric columns. + Remove non-numeric columns and return the result as a new table. **Note:** The original table is not modified. @@ -799,6 +951,14 @@ def remove_non_numeric_columns(self) -> Table: | 2 | | 3 | +-----+ + + Related + ------- + - [select_columns][safeds.data.tabular.containers._table.Table.select_columns]: + Keep only a subset of the columns. This method accepts either column names, or a predicate. + - [remove_columns][safeds.data.tabular.containers._table.Table.remove_columns]: + Remove columns from the table by name. + - [remove_columns_with_missing_values][safeds.data.tabular.containers._table.Table.remove_columns_with_missing_values] """ import polars.selectors as cs @@ -808,7 +968,7 @@ def remove_non_numeric_columns(self) -> Table: def rename_column(self, old_name: str, new_name: str) -> Table: """ - Return a new table with a column renamed. + Rename a column and return the result as a new table. **Note:** The original table is not modified. @@ -857,7 +1017,7 @@ def replace_column( new_columns: Column | list[Column] | Table, ) -> Table: """ - Return a new table with a column replaced by zero or more columns. + Replace a column with zero or more columns and return the result as a new table. **Note:** The original table is not modified. @@ -877,6 +1037,10 @@ def replace_column( ------ ColumnNotFoundError If no column with the old name exists. + DuplicateColumnError + If a column name exists already. This can also happen if the new columns have duplicate names. + LengthMismatchError + If the columns have different row counts. Examples -------- @@ -924,6 +1088,7 @@ def replace_column( _check_columns_exist(self, old_name) _check_columns_dont_exist(self, [column.name for column in new_columns], old_name=old_name) + _check_row_counts_are_equal([self, *new_columns]) if len(new_columns) == 0: return self.remove_columns(old_name, ignore_unknown_names=True) @@ -946,13 +1111,81 @@ def replace_column( ), ) + def select_columns( + self, + selector: str | list[str] | Callable[[Column], bool], + ) -> Table: + """ + Select a subset of the columns and return the result as a new table. + + **Notes:** + + - The original table is not modified. + - If the `selector` is a custom function, this operation must fully load the data into memory, which can be + expensive. + + Parameters + ---------- + selector: + The names of the columns to keep, or a predicate that decides whether to keep a column. + + Returns + ------- + new_table: + The table with only a subset of the columns. + + Raises + ------ + ColumnNotFoundError + If a column does not exist. + + Examples + -------- + >>> from safeds.data.tabular.containers import Table + >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) + >>> table.select_columns("a") + +-----+ + | a | + | --- | + | i64 | + +=====+ + | 1 | + | 2 | + | 3 | + +-----+ + + Related + ------- + - [remove_columns][safeds.data.tabular.containers._table.Table.remove_columns]: + Remove columns from the table by name. + - [remove_columns_with_missing_values][safeds.data.tabular.containers._table.Table.remove_columns_with_missing_values] + - [remove_non_numeric_columns][safeds.data.tabular.containers._table.Table.remove_non_numeric_columns] + """ + import polars as pl + + # Select by predicate + if callable(selector): + return Table._from_polars_lazy_frame( + pl.LazyFrame( + [column._series for column in self.to_columns() if selector(column)], + ), + ) + + # Select by column names + else: + _check_columns_exist(self, selector) + + return Table._from_polars_lazy_frame( + self._lazy_frame.select(selector), + ) + def transform_column( self, name: str, transformer: Callable[[Cell], Cell], ) -> Table: """ - Return a new table with a column transformed. + Transform a column with a custom function and return the result as a new table. **Note:** The original table is not modified. @@ -960,9 +1193,8 @@ def transform_column( ---------- name: The name of the column to transform. - transformer: - The function that transforms the column. + The function that computes the new values of the column. Returns ------- @@ -988,6 +1220,13 @@ def transform_column( | 3 | 5 | | 4 | 6 | +-----+-----+ + + Related + ------- + - [add_computed_column][safeds.data.tabular.containers._table.Table.add_computed_column]: + Add a new column that is computed from other columns. + - [transform_table][safeds.data.tabular.containers._table.Table.transform_table]: + Transform the entire table with a fitted transformer. """ _check_columns_exist(self, name) @@ -996,7 +1235,7 @@ def transform_column( expression = transformer(_LazyCell(pl.col(name))) return Table._from_polars_lazy_frame( - self._lazy_frame.with_columns(expression._polars_expression), + self._lazy_frame.with_columns(expression._polars_expression.alias(name)), ) # ------------------------------------------------------------------------------------------------------------------ @@ -1006,7 +1245,7 @@ def transform_column( @overload def count_rows_if( self, - predicate: Callable[[Row], Cell[bool | None]], + predicate: Callable[[Row], Cell[bool]], *, ignore_unknown: Literal[True] = ..., ) -> int: ... @@ -1014,19 +1253,19 @@ def count_rows_if( @overload def count_rows_if( self, - predicate: Callable[[Row], Cell[bool | None]], + predicate: Callable[[Row], Cell[bool]], *, ignore_unknown: bool, ) -> int | None: ... def count_rows_if( self, - predicate: Callable[[Row], Cell[bool | None]], + predicate: Callable[[Row], Cell[bool]], *, ignore_unknown: bool = True, ) -> int | None: """ - Return how many rows in the table satisfy the predicate. + Count how many rows in the table satisfy the predicate. The predicate can return one of three results: @@ -1055,12 +1294,11 @@ def count_rows_if( Examples -------- >>> from safeds.data.tabular.containers import Table - >>> table = Table({"col1": [1, 2, 3], "col2": [1, 3, 3]}) - >>> table.count_rows_if(lambda row: row["col1"] == row["col2"]) - 2 + >>> table = Table({"col1": [1, 2, 3], "col2": [1, 3, None]}) + >>> table.count_rows_if(lambda row: row["col1"] < row["col2"]) + 1 - >>> table.count_rows_if(lambda row: row["col1"] > row["col2"]) - 0 + >>> table.count_rows_if(lambda row: row["col1"] < row["col2"], ignore_unknown=False) """ expression = predicate(_LazyVectorizedRow(self))._polars_expression series = self._lazy_frame.select(expression.alias("count")).collect().get_column("count") @@ -1070,11 +1308,113 @@ def count_rows_if( else: return None - # TODO: Rethink group_rows/group_rows_by_column. They should not return a dict. + def filter_rows( + self, + predicate: Callable[[Row], Cell[bool]], + ) -> Table: + """ + Keep only rows that satisfy a condition and return the result as a new table. + + **Note:** The original table is not modified. + + Parameters + ---------- + predicate: + The function that determines which rows to keep. + + Returns + ------- + new_table: + The table containing only the specified rows. + + Examples + -------- + >>> from safeds.data.tabular.containers import Table + >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) + >>> table.filter_rows(lambda row: row["a"] == 2) + +-----+-----+ + | a | b | + | --- | --- | + | i64 | i64 | + +===========+ + | 2 | 5 | + +-----+-----+ + + Related + ------- + - [filter_rows_by_column][safeds.data.tabular.containers._table.Table.filter_rows_by_column]: + Keep only rows that satisfy a condition on a specific column. + - [remove_duplicate_rows][safeds.data.tabular.containers._table.Table.remove_duplicate_rows] + - [remove_rows_with_missing_values][safeds.data.tabular.containers._table.Table.remove_rows_with_missing_values] + - [remove_rows_with_outliers][safeds.data.tabular.containers._table.Table.remove_rows_with_outliers] + """ + mask = predicate(_LazyVectorizedRow(self)) + + return Table._from_polars_lazy_frame( + self._lazy_frame.filter(mask._polars_expression), + ) + + def filter_rows_by_column( + self, + name: str, + predicate: Callable[[Cell], Cell[bool]], + ) -> Table: + """ + Keep only rows that satisfy a condition on a specific column and return the result as a new table. + + **Note:** The original table is not modified. + + Parameters + ---------- + name: + The name of the column. + predicate: + The function that determines which rows to keep. + + Returns + ------- + new_table: + The table containing only the specified rows. + + Raises + ------ + ColumnNotFoundError + If the column does not exist. + + Examples + -------- + >>> from safeds.data.tabular.containers import Table + >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) + >>> table.filter_rows_by_column("a", lambda cell: cell == 2) + +-----+-----+ + | a | b | + | --- | --- | + | i64 | i64 | + +===========+ + | 2 | 5 | + +-----+-----+ + + Related + ------- + - [filter_rows][safeds.data.tabular.containers._table.Table.filter_rows]: + Keep only rows that satisfy a condition. + - [remove_duplicate_rows][safeds.data.tabular.containers._table.Table.remove_duplicate_rows] + - [remove_rows_with_missing_values][safeds.data.tabular.containers._table.Table.remove_rows_with_missing_values] + - [remove_rows_with_outliers][safeds.data.tabular.containers._table.Table.remove_rows_with_outliers] + """ + _check_columns_exist(self, name) + + import polars as pl + + mask = predicate(_LazyCell(pl.col(name))) + + return Table._from_polars_lazy_frame( + self._lazy_frame.filter(mask._polars_expression), + ) def remove_duplicate_rows(self) -> Table: """ - Return a new table without duplicate rows. + Remove duplicate rows and return the result as a new table. **Note:** The original table is not modified. @@ -1096,6 +1436,15 @@ def remove_duplicate_rows(self) -> Table: | 1 | 4 | | 2 | 5 | +-----+-----+ + + Related + ------- + - [filter_rows][safeds.data.tabular.containers._table.Table.filter_rows]: + Keep only rows that satisfy a condition. + - [filter_rows_by_column][safeds.data.tabular.containers._table.Table.filter_rows_by_column]: + Keep only rows that satisfy a condition on a specific column. + - [remove_rows_with_missing_values][safeds.data.tabular.containers._table.Table.remove_rows_with_missing_values] + - [remove_rows_with_outliers][safeds.data.tabular.containers._table.Table.remove_rows_with_outliers] """ return Table._from_polars_lazy_frame( self._lazy_frame.unique(maintain_order=True), @@ -1103,16 +1452,16 @@ def remove_duplicate_rows(self) -> Table: def remove_rows( self, - query: Callable[[Row], Cell[bool]], + predicate: Callable[[Row], Cell[bool]], ) -> Table: """ - Return a new table without rows that satisfy a condition. + Remove rows that satisfy a condition and return the result as a new table. **Note:** The original table is not modified. Parameters ---------- - query: + predicate: The function that determines which rows to remove. Returns @@ -1133,8 +1482,20 @@ def remove_rows( | 1 | 4 | | 3 | 6 | +-----+-----+ - """ - mask = query(_LazyVectorizedRow(self)) + + Related + ------- + - [filter_rows][safeds.data.tabular.containers._table.Table.filter_rows]: + Keep only rows that satisfy a condition. + - [filter_rows_by_column][safeds.data.tabular.containers._table.Table.filter_rows_by_column]: + Keep only rows that satisfy a condition on a specific column. + - [remove_rows_by_column][safeds.data.tabular.containers._table.Table.filter_rows_by_column]: + Remove rows that satisfy a condition on a specific column. + - [remove_duplicate_rows][safeds.data.tabular.containers._table.Table.remove_duplicate_rows] + - [remove_rows_with_missing_values][safeds.data.tabular.containers._table.Table.remove_rows_with_missing_values] + - [remove_rows_with_outliers][safeds.data.tabular.containers._table.Table.remove_rows_with_outliers] + """ + mask = predicate(_LazyVectorizedRow(self)) return Table._from_polars_lazy_frame( self._lazy_frame.filter(~mask._polars_expression), @@ -1143,10 +1504,10 @@ def remove_rows( def remove_rows_by_column( self, name: str, - query: Callable[[Cell], Cell[bool]], + predicate: Callable[[Cell], Cell[bool]], ) -> Table: """ - Return a new table without rows that satisfy a condition on a specific column. + Remove rows that satisfy a condition on a specific column and return the result as a new table. **Note:** The original table is not modified. @@ -1154,7 +1515,7 @@ def remove_rows_by_column( ---------- name: The name of the column. - query: + predicate: The function that determines which rows to remove. Returns @@ -1180,12 +1541,24 @@ def remove_rows_by_column( | 1 | 4 | | 3 | 6 | +-----+-----+ + + Related + ------- + - [filter_rows][safeds.data.tabular.containers._table.Table.filter_rows]: + Keep only rows that satisfy a condition. + - [filter_rows_by_column][safeds.data.tabular.containers._table.Table.filter_rows_by_column]: + Keep only rows that satisfy a condition on a specific column. + - [remove_rows][safeds.data.tabular.containers._table.Table.remove_rows]: + Remove rows that satisfy a condition. + - [remove_duplicate_rows][safeds.data.tabular.containers._table.Table.remove_duplicate_rows] + - [remove_rows_with_missing_values][safeds.data.tabular.containers._table.Table.remove_rows_with_missing_values] + - [remove_rows_with_outliers][safeds.data.tabular.containers._table.Table.remove_rows_with_outliers] """ _check_columns_exist(self, name) import polars as pl - mask = query(_LazyCell(pl.col(name))) + mask = predicate(_LazyCell(pl.col(name))) return Table._from_polars_lazy_frame( self._lazy_frame.filter(~mask._polars_expression), @@ -1193,22 +1566,27 @@ def remove_rows_by_column( def remove_rows_with_missing_values( self, - column_names: list[str] | None = None, + *, + column_names: str | list[str] | None = None, ) -> Table: """ - Return a new table without rows containing missing values in the specified columns. + Remove rows that contain missing values in the specified columns and return the result as a new table. + + The resulting table no longer has missing values in the specified columns. Be aware that this method can discard + a lot of data. Consider first removing columns with many missing values, or using one of the imputation methods + (see "Related" section). **Note:** The original table is not modified. Parameters ---------- column_names: - Names of the columns to consider. If None, all columns are considered. + The names of the columns to check. If None, all columns are checked. Returns ------- new_table: - The table without rows containing missing values in the specified columns. + The table without rows that contain missing values in the specified columns. Examples -------- @@ -1222,24 +1600,52 @@ def remove_rows_with_missing_values( +===========+ | 1 | 4 | +-----+-----+ - """ + + >>> table.remove_rows_with_missing_values(column_names=["b"]) + +------+-----+ + | a | b | + | --- | --- | + | i64 | i64 | + +============+ + | 1 | 4 | + | null | 5 | + +------+-----+ + + Related + ------- + - [remove_columns_with_missing_values][safeds.data.tabular.containers._table.Table.remove_columns_with_missing_values] + - [SimpleImputer][safeds.data.tabular.transformation._simple_imputer.SimpleImputer]: + Replace missing values with a constant value or a statistic of the column. + - [KNearestNeighborsImputer][safeds.data.tabular.transformation._k_nearest_neighbors_imputer.KNearestNeighborsImputer]: + Replace missing values with a value computed from the nearest neighbors. + - [filter_rows][safeds.data.tabular.containers._table.Table.filter_rows]: + Keep only rows that satisfy a condition. + - [filter_rows_by_column][safeds.data.tabular.containers._table.Table.filter_rows_by_column]: + Keep only rows that satisfy a condition on a specific column. + - [remove_duplicate_rows][safeds.data.tabular.containers._table.Table.remove_duplicate_rows] + - [remove_rows_with_outliers][safeds.data.tabular.containers._table.Table.remove_rows_with_outliers] + """ + if isinstance(column_names, list) and not column_names: + # polars panics in this case + return self + return Table._from_polars_lazy_frame( self._lazy_frame.drop_nulls(subset=column_names), ) def remove_rows_with_outliers( self, - column_names: list[str] | None = None, *, + column_names: str | list[str] | None = None, z_score_threshold: float = 3, ) -> Table: """ - Return a new table without rows containing outliers in the specified columns. + Remove rows that contain outliers in the specified columns and return the result as a new table. - Whether a data point is an outlier in a column is determined by its z-score. The z-score the distance of the - data point from the mean of the column divided by the standard deviation of the column. If the z-score is - greater than the given threshold, the data point is considered an outlier. Missing values are ignored during the - calculation of the z-score. + Whether a value is an outlier in a column is determined by its z-score. The z-score the distance of the value + from the mean of the column divided by the standard deviation of the column. If the z-score is greater than the + given threshold, the value is considered an outlier. Missing values are ignored during the calculation of the + z-score. The z-score is only defined for numeric columns. Non-numeric columns are ignored, even if they are specified in `column_names`. @@ -1254,12 +1660,17 @@ def remove_rows_with_outliers( column_names: Names of the columns to consider. If None, all numeric columns are considered. z_score_threshold: - The z-score threshold for detecting outliers. + The z-score threshold for detecting outliers. Must be greater than or equal to 0. Returns ------- new_table: - The table without rows containing outliers in the specified columns. + The table without rows that contain outliers in the specified columns. + + Raises + ------ + OutOfBoundsError + If the `z_score_threshold` is less than 0. Examples -------- @@ -1284,30 +1695,57 @@ def remove_rows_with_outliers( | 6 | 6 | | null | 8 | +------+-----+ + + Related + ------- + - [filter_rows][safeds.data.tabular.containers._table.Table.filter_rows]: + Keep only rows that satisfy a condition. + - [filter_rows_by_column][safeds.data.tabular.containers._table.Table.filter_rows_by_column]: + Keep only rows that satisfy a condition on a specific column. + - [remove_duplicate_rows][safeds.data.tabular.containers._table.Table.remove_duplicate_rows] + - [remove_rows_with_missing_values][safeds.data.tabular.containers._table.Table.remove_rows_with_missing_values] """ - if self.row_count == 0: - return self # polars raises a ComputeError for tables without rows + _check_bounds( + "z_score_threshold", + z_score_threshold, + lower_bound=_ClosedBound(0), + ) + if column_names is None: column_names = self.column_names import polars as pl import polars.selectors as cs + # polar's `all_horizontal` raises a `ComputeError` if there are no columns + selected = self._lazy_frame.select(cs.numeric() & cs.by_name(column_names)) + if not selected.collect_schema().names(): + return self + + # Multiply z-score by standard deviation instead of dividing the distance by it, to avoid division by zero non_outlier_mask = pl.all_horizontal( - self._data_frame.select(cs.numeric() & cs.by_name(column_names)).select( - pl.all().is_null() | (((pl.all() - pl.all().mean()) / pl.all().std()).abs() <= z_score_threshold), - ), + selected.select( + pl.all().is_null() | ((pl.all() - pl.all().mean()).abs() <= (z_score_threshold * pl.all().std())), + ).collect(), ) return Table._from_polars_lazy_frame( self._lazy_frame.filter(non_outlier_mask), ) - def shuffle_rows(self) -> Table: + def shuffle_rows(self, *, random_seed: int = 0) -> Table: """ - Return a new table with the rows shuffled. + Shuffle the rows and return the result as a new table. - **Note:** The original table is not modified. + **Notes:** + + - The original table is not modified. + - This operation must fully load the data into memory, which can be expensive. + + Parameters + ---------- + random_seed: + The seed for the pseudorandom number generator. Returns ------- @@ -1324,29 +1762,30 @@ def shuffle_rows(self) -> Table: | --- | --- | | i64 | i64 | +===========+ + | 1 | 4 | | 3 | 6 | | 2 | 5 | - | 1 | 4 | +-----+-----+ """ return Table._from_polars_data_frame( self._data_frame.sample( fraction=1, shuffle=True, - seed=_get_random_seed(), + seed=random_seed, ), ) - def slice_rows(self, start: int = 0, length: int | None = None) -> Table: + def slice_rows(self, *, start: int = 0, length: int | None = None) -> Table: """ - Return a new table with a slice of rows. + Slice the rows and return the result as a new table. **Note:** The original table is not modified. Parameters ---------- start: - The start index of the slice. + The start index of the slice. Non-negative indices count forward from the first row (index 0). Negative + indices count backward from the last row (index -1). length: The length of the slice. If None, the slice contains all rows starting from `start`. Must greater than or equal to 0. @@ -1397,7 +1836,7 @@ def sort_rows( descending: bool = False, ) -> Table: """ - Return a new table with the rows sorted. + Sort the rows by a custom function and return the result as a new table. **Note:** The original table is not modified. @@ -1427,10 +1866,12 @@ def sort_rows( | 2 | 1 | | 3 | 2 | +-----+-----+ - """ - if self.row_count == 0: - return self + Related + ------- + - [sort_rows_by_column][safeds.data.tabular.containers._table.Table.sort_rows_by_column]: + Sort the rows by a specific column. + """ key = key_selector(_LazyVectorizedRow(self)) return Table._from_polars_lazy_frame( @@ -1448,7 +1889,7 @@ def sort_rows_by_column( descending: bool = False, ) -> Table: """ - Return a new table with the rows sorted by a specific column. + Sort the rows by a specific column and return the result as a new table. **Note:** The original table is not modified. @@ -1483,6 +1924,11 @@ def sort_rows_by_column( | 2 | 1 | | 3 | 2 | +-----+-----+ + + Related + ------- + - [sort_rows][safeds.data.tabular.containers._table.Table.sort_rows]: + Sort the rows by a value computed from an entire row. """ _check_columns_exist(self, name) @@ -1499,17 +1945,19 @@ def split_rows( percentage_in_first: float, *, shuffle: bool = True, + random_seed: int = 0, ) -> tuple[Table, Table]: """ Create two tables by splitting the rows of the current table. The first table contains a percentage of the rows specified by `percentage_in_first`, and the second table - contains the remaining rows. + contains the remaining rows. By default, the rows are shuffled before splitting. You can disable this by setting + `shuffle` to False. **Notes:** - The original table is not modified. - - By default, the rows are shuffled before splitting. You can disable this by setting `shuffle` to False. + - This operation must fully load the data into memory, which can be expensive. Parameters ---------- @@ -1517,6 +1965,8 @@ def split_rows( The percentage of rows to include in the first table. Must be between 0 and 1. shuffle: Whether to shuffle the rows before splitting. + random_seed: + The seed for the pseudorandom number generator used for shuffling. Returns ------- @@ -1541,9 +1991,9 @@ def split_rows( | --- | --- | | i64 | i64 | +===========+ - | 1 | 6 | | 4 | 9 | - | 3 | 8 | + | 1 | 6 | + | 2 | 7 | +-----+-----+ >>> second_table +-----+-----+ @@ -1552,7 +2002,7 @@ def split_rows( | i64 | i64 | +===========+ | 5 | 10 | - | 2 | 7 | + | 3 | 8 | +-----+-----+ """ _check_bounds( @@ -1562,7 +2012,7 @@ def split_rows( upper_bound=_ClosedBound(1), ) - input_table = self.shuffle_rows() if shuffle else self + input_table = self.shuffle_rows(random_seed=random_seed) if shuffle else self row_count_in_first = round(percentage_in_first * input_table.row_count) return ( @@ -1574,31 +2024,35 @@ def split_rows( # Table operations # ------------------------------------------------------------------------------------------------------------------ - def add_table_as_columns(self, other: Table) -> Table: + def add_tables_as_columns(self, others: Table | list[Table]) -> Table: """ - Return a new table with the columns of another table added. - - **Notes:** + Add the columns of other tables and return the result as a new table. - - The original tables are not modified. - - This operation must fully load the data into memory, which can be expensive. + **Note:** The original tables are not modified. Parameters ---------- - other: - The table to add as columns. + others: + The tables to add as columns. Returns ------- new_table: The table with the columns added. + Raises + ------ + DuplicateColumnError + If a column name exists already. + LengthMismatchError + If the tables have different row counts. + Examples -------- >>> from safeds.data.tabular.containers import Table >>> table1 = Table({"a": [1, 2, 3]}) >>> table2 = Table({"b": [4, 5, 6]}) - >>> table1.add_table_as_columns(table2) + >>> table1.add_tables_as_columns(table2) +-----+-----+ | a | b | | --- | --- | @@ -1608,26 +2062,39 @@ def add_table_as_columns(self, other: Table) -> Table: | 2 | 5 | | 3 | 6 | +-----+-----+ + + Related + ------- + - [add_tables_as_rows][safeds.data.tabular.containers._table.Table.add_tables_as_rows] """ - # TODO: raises? + import polars as pl - return Table._from_polars_data_frame( - self._data_frame.hstack(other._data_frame), + if isinstance(others, Table): + others = [others] + + _check_columns_dont_exist(self, [name for other in others for name in other.column_names]) + _check_row_counts_are_equal([self, *others], ignore_entries_without_rows=True) + + return Table._from_polars_lazy_frame( + pl.concat( + [ + self._lazy_frame, + *[other._lazy_frame for other in others], + ], + how="horizontal", + ), ) - def add_table_as_rows(self, other: Table) -> Table: + def add_tables_as_rows(self, others: Table | list[Table]) -> Table: """ - Return a new table with the rows of another table added. + Add the rows of other tables and return the result as a new table. - **Notes:** - - - The original tables are not modified. - - This operation must fully load the data into memory, which can be expensive. + **Note:** The original tables are not modified. Parameters ---------- - other: - The table to add as rows. + others: + The tables to add as rows. Returns ------- @@ -1639,7 +2106,7 @@ def add_table_as_rows(self, other: Table) -> Table: >>> from safeds.data.tabular.containers import Table >>> table1 = Table({"a": [1, 2, 3]}) >>> table2 = Table({"a": [4, 5, 6]}) - >>> table1.add_table_as_rows(table2) + >>> table1.add_tables_as_rows(table2) +-----+ | a | | --- | @@ -1652,16 +2119,32 @@ def add_table_as_rows(self, other: Table) -> Table: | 5 | | 6 | +-----+ + + Related + ------- + - [add_tables_as_columns][safeds.data.tabular.containers._table.Table.add_tables_as_columns] """ - # TODO: raises? + import polars as pl - return Table._from_polars_data_frame( - self._data_frame.vstack(other._data_frame), + if isinstance(others, Table): + others = [others] + + for other in others: + _check_schema(self, other) + + return Table._from_polars_lazy_frame( + pl.concat( + [ + self._lazy_frame, + *[other._lazy_frame for other in others], + ], + how="vertical", + ), ) def inverse_transform_table(self, fitted_transformer: InvertibleTableTransformer) -> Table: """ - Return a new table inverse-transformed by a **fitted, invertible** transformer. + Inverse-transform the table by a **fitted, invertible** transformer and return the result as a new table. **Notes:** @@ -1678,12 +2161,17 @@ def inverse_transform_table(self, fitted_transformer: InvertibleTableTransformer new_table: The inverse-transformed table. + Raises + ------ + NotFittedError + If the transformer has not been fitted yet. + Examples -------- >>> from safeds.data.tabular.containers import Table >>> from safeds.data.tabular.transformation import RangeScaler >>> table = Table({"a": [1, 2, 3]}) - >>> transformer, transformed_table = RangeScaler(min_=0, max_=1, column_names="a").fit_and_transform(table) + >>> transformer, transformed_table = RangeScaler(min_=0, max_=1).fit_and_transform(table) >>> transformed_table.inverse_transform_table(transformer) +---------+ | a | @@ -1694,6 +2182,11 @@ def inverse_transform_table(self, fitted_transformer: InvertibleTableTransformer | 2.00000 | | 3.00000 | +---------+ + + Related + ------- + - [transform_table][safeds.data.tabular.containers._table.Table.transform_table]: + Transform the table with a fitted transformer. """ return fitted_transformer.inverse_transform(self) @@ -1703,19 +2196,37 @@ def join( left_names: str | list[str], right_names: str | list[str], *, - mode: Literal["inner", "left", "right", "outer"] = "inner", + mode: Literal["inner", "left", "right", "full"] = "inner", ) -> Table: """ - Join a table with the current table and return the result. + Join the current table (left table) with another table (right table) and return the result as a new table. + + Rows are matched if the values in the specified columns are equal. The parameter `left_names` controls which + columns are used for the left table, and `right_names` does the same for the right table. + + There are various types of joins, specified by the `mode` parameter: + + - `"inner"`: + Keep only rows that have matching values in both tables. + - `"left"`: + Keep all rows from the left table and the matching rows from the right table. Cells with no match are + marked as missing values. + - `"right"`: + Keep all rows from the right table and the matching rows from the left table. Cells with no match are + marked as missing values. + - `"full"`: + Keep all rows from both tables. Cells with no match are marked as missing values. + + **Note:** The original tables are not modified. Parameters ---------- right_table: - The other table which is to be joined to the current table. + The table to join with the left table. left_names: - Name or list of names of columns from the current table on which to join right_table. + Name or list of names of columns to join on in the left table. right_names: - Name or list of names of columns from right_table on which to join the current table. + Name or list of names of columns to join on in the right table. mode: Specify which type of join you want to use. @@ -1724,46 +2235,112 @@ def join( new_table: The table with the joined table. + Raises + ------ + ColumnNotFoundError + If a column does not exist in one of the tables. + DuplicateColumnError + If a column is used multiple times in the join. + LengthMismatchError + If the number of columns to join on is different in the two tables. + ValueError + If `left_names` or `right_names` are an empty list. + Examples -------- >>> from safeds.data.tabular.containers import Table - >>> table1 = Table({"a": [1, 2], "b": [3, 4]}) - >>> table2 = Table({"d": [1, 5], "e": [5, 6]}) - >>> table1.join(table2, "a", "d", mode="left") - +-----+-----+------+ - | a | b | e | - | --- | --- | --- | - | i64 | i64 | i64 | + >>> table1 = Table({"a": [1, 2], "b": [True, False]}) + >>> table2 = Table({"c": [1, 3], "d": ["a", "b"]}) + >>> table1.join(table2, "a", "c", mode="inner") + +-----+------+-----+ + | a | b | d | + | --- | --- | --- | + | i64 | bool | str | +==================+ - | 1 | 3 | 5 | - | 2 | 4 | null | - +-----+-----+------+ - """ + | 1 | true | a | + +-----+------+-----+ + + >>> table1.join(table2, "a", "c", mode="left") + +-----+-------+------+ + | a | b | d | + | --- | --- | --- | + | i64 | bool | str | + +====================+ + | 1 | true | a | + | 2 | false | null | + +-----+-------+------+ + + >>> table1.join(table2, "a", "c", mode="right") + +------+-----+-----+ + | b | c | d | + | --- | --- | --- | + | bool | i64 | str | + +==================+ + | true | 1 | a | + | null | 3 | b | + +------+-----+-----+ + + >>> table1.join(table2, "a", "c", mode="full") + +-----+-------+------+ + | a | b | d | + | --- | --- | --- | + | i64 | bool | str | + +====================+ + | 1 | true | a | + | 2 | false | null | + | 3 | null | b | + +-----+-------+------+ + """ + # Preprocessing + if isinstance(left_names, str): + left_names = [left_names] + if isinstance(right_names, str): + right_names = [right_names] + # Validation _check_columns_exist(self, left_names) _check_columns_exist(right_table, right_names) + duplicate_left_names = _compute_duplicates(left_names) + if duplicate_left_names: + raise DuplicateColumnError( + f"Columns to join on must be unique, but left names {duplicate_left_names} are duplicated.", + ) + + duplicate_right_names = _compute_duplicates(right_names) + if duplicate_right_names: + raise DuplicateColumnError( + f"Columns to join on must be unique, but right names {duplicate_right_names} are duplicated.", + ) + if len(left_names) != len(right_names): - raise ValueError("The number of columns to join on must be the same in both tables.") + raise LengthMismatchError("The number of columns to join on must be the same in both tables.") + if not left_names or not right_names: + # Here both are empty, due to the previous check + raise ValueError("The columns to join on must not be empty.") # Implementation - if mode == "outer": - polars_mode = "full" - else: - polars_mode = mode + result = self._lazy_frame.join( + right_table._lazy_frame, + left_on=left_names, + right_on=right_names, + how=mode, + maintain_order="left_right", + coalesce=True, + ) + + # Can be removed once https://github.com/pola-rs/polars/issues/20670 is fixed + if mode == "right" and len(left_names) > 1: + # We must collect because of https://github.com/pola-rs/polars/issues/20671 + result = result.collect().drop(left_names).lazy() return self._from_polars_lazy_frame( - self._lazy_frame.join( - right_table._lazy_frame, - left_on=left_names, - right_on=right_names, - how=polars_mode, - ), + result, ) def transform_table(self, fitted_transformer: TableTransformer) -> Table: """ - Return a new table transformed by a **fitted** transformer. + Transform the table with a **fitted** transformer and return the result as a new table. **Notes:** @@ -1780,12 +2357,17 @@ def transform_table(self, fitted_transformer: TableTransformer) -> Table: new_table: The transformed table. + Raises + ------ + NotFittedError + If the transformer has not been fitted yet. + Examples -------- >>> from safeds.data.tabular.containers import Table >>> from safeds.data.tabular.transformation import RangeScaler >>> table = Table({"a": [1, 2, 3]}) - >>> transformer = RangeScaler(min_=0, max_=1, column_names="a").fit(table) + >>> transformer = RangeScaler(min_=0, max_=1).fit(table) >>> table.transform_table(transformer) +---------+ | a | @@ -1796,6 +2378,13 @@ def transform_table(self, fitted_transformer: TableTransformer) -> Table: | 0.50000 | | 1.00000 | +---------+ + + Related + ------- + - [inverse_transform_table][safeds.data.tabular.containers._table.Table.inverse_transform_table]: + Inverse-transform the table with a fitted, invertible transformer. + - [transform_column][safeds.data.tabular.containers._table.Table.transform_column]: + Transform a single column with a custom function. """ return fitted_transformer.transform(self) @@ -1807,6 +2396,11 @@ def summarize_statistics(self) -> Table: """ Return a table with important statistics about this table. + !!! warning "API Stability" + + Do not rely on the exact output of this method. In future versions, we may change the displayed statistics + without prior notice. + Returns ------- statistics: @@ -1817,30 +2411,86 @@ def summarize_statistics(self) -> Table: >>> from safeds.data.tabular.containers import Table >>> table = Table({"a": [1, 3]}) >>> table.summarize_statistics() - +----------------------+---------+ - | metric | a | - | --- | --- | - | str | f64 | - +================================+ - | min | 1.00000 | - | max | 3.00000 | - | mean | 2.00000 | - | median | 2.00000 | - | standard deviation | 1.41421 | - | distinct value count | 2.00000 | - | idness | 1.00000 | - | missing value ratio | 0.00000 | - | stability | 0.50000 | - +----------------------+---------+ + +---------------------+---------+ + | statistic | a | + | --- | --- | + | str | f64 | + +===============================+ + | min | 1.00000 | + | max | 3.00000 | + | mean | 2.00000 | + | median | 2.00000 | + | standard deviation | 1.41421 | + | missing value ratio | 0.00000 | + | stability | 0.50000 | + | idness | 1.00000 | + +---------------------+---------+ """ + import polars as pl + import polars.selectors as cs + if self.column_count == 0: + # polars raises an error in this case return Table({}) - head = self.get_column(self.column_names[0]).summarize_statistics() - tail = [self.get_column(name).summarize_statistics().get_column(name)._series for name in self.column_names[1:]] - - return Table._from_polars_data_frame( - head._lazy_frame.collect().hstack(tail, in_place=True), + # Find suitable name for the statistic column + statistic_column_name = "statistic" + while statistic_column_name in self.column_names: + statistic_column_name += "_" + + # Build the expressions to compute the statistics + non_null_columns = cs.exclude(cs.by_dtype(pl.Null)) + non_boolean_columns = cs.exclude(cs.by_dtype(pl.Boolean)) + boolean_columns = cs.by_dtype(pl.Boolean) + true_count = boolean_columns.filter(boolean_columns == True).count() # noqa: E712 + false_count = boolean_columns.filter(boolean_columns == False).count() # noqa: E712 + + named_statistics: dict[str, list[pl.Expr]] = { + "min": [non_null_columns.min()], + "max": [non_null_columns.max()], + "mean": [cs.numeric().mean()], + "median": [cs.numeric().median()], + "standard deviation": [cs.numeric().std()], + # NaN occurs for tables without rows + "missing value ratio": [(cs.all().null_count() / pl.len()).fill_nan(1.0)], + # null occurs for columns without non-null values + # `unique_counts` crashes in polars for boolean columns (https://github.com/pola-rs/polars/issues/16356) + "stability": [ + (non_boolean_columns.drop_nulls().unique_counts().max() / non_boolean_columns.count()).fill_null(1.0), + ( + pl.when(true_count >= false_count).then(true_count).otherwise(false_count) / boolean_columns.count() + ).fill_null(1.0), + ], + # NaN occurs for tables without rows + "idness": [(cs.all().n_unique() / pl.len()).fill_nan(1.0)], + } + + # Compute suitable types for the output columns + frame = self._lazy_frame + schema = frame.collect_schema() + for name, type_ in schema.items(): + # polars fails to determine supertype of temporal types and u32 + if not type_.is_numeric() and not type_.is_(pl.Null): + schema[name] = pl.String + + # Combine everything into a single table + return Table._from_polars_lazy_frame( + pl.concat( + [ + # Ensure the columns are in the correct order + pl.LazyFrame({statistic_column_name: []}), + schema.to_frame(eager=False), + # Add the statistics + *[ + frame.select( + pl.lit(name).alias(statistic_column_name), + *expressions, + ) + for name, expressions in named_statistics.items() + ], + ], + how="diagonal_relaxed", + ), ) # ------------------------------------------------------------------------------------------------------------------ @@ -1854,7 +2504,7 @@ def to_columns(self) -> list[Column]: Returns ------- columns: - List of columns. + The columns of the table. Examples -------- @@ -1878,7 +2528,7 @@ def to_csv_file(self, path: str | Path) -> None: Raises ------ - ValueError + FileExtensionError If the path has an extension that is not ".csv". Examples @@ -1886,6 +2536,11 @@ def to_csv_file(self, path: str | Path) -> None: >>> from safeds.data.tabular.containers import Table >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) >>> table.to_csv_file("./src/resources/to_csv_file.csv") + + Related + ------- + - [to_json_file][safeds.data.tabular.containers._table.Table.to_json_file] + - [to_parquet_file][safeds.data.tabular.containers._table.Table.to_parquet_file] """ path = _normalize_and_check_file_path(path, ".csv", [".csv"]) path.parent.mkdir(parents=True, exist_ok=True) @@ -1899,7 +2554,7 @@ def to_dict(self) -> dict[str, list[Any]]: Returns ------- dict_: - Dictionary representation of the table. + The dictionary representation of the table. Examples -------- @@ -1929,7 +2584,7 @@ def to_json_file( Raises ------ - ValueError + FileExtensionError If the path has an extension that is not ".json". Examples @@ -1937,6 +2592,11 @@ def to_json_file( >>> from safeds.data.tabular.containers import Table >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) >>> table.to_json_file("./src/resources/to_json_file.json") + + Related + ------- + - [to_csv_file][safeds.data.tabular.containers._table.Table.to_csv_file] + - [to_parquet_file][safeds.data.tabular.containers._table.Table.to_parquet_file] """ path = _normalize_and_check_file_path(path, ".json", [".json"]) path.parent.mkdir(parents=True, exist_ok=True) @@ -1958,7 +2618,7 @@ def to_parquet_file(self, path: str | Path) -> None: Raises ------ - ValueError + FileExtensionError If the path has an extension that is not ".parquet". Examples @@ -1966,20 +2626,31 @@ def to_parquet_file(self, path: str | Path) -> None: >>> from safeds.data.tabular.containers import Table >>> table = Table({"a": [1, 2, 3], "b": [4, 5, 6]}) >>> table.to_parquet_file("./src/resources/to_parquet_file.parquet") + + Related + ------- + - [to_csv_file][safeds.data.tabular.containers._table.Table.to_csv_file] + - [to_json_file][safeds.data.tabular.containers._table.Table.to_json_file] """ path = _normalize_and_check_file_path(path, ".parquet", [".parquet"]) path.parent.mkdir(parents=True, exist_ok=True) self._lazy_frame.sink_parquet(path) - def to_tabular_dataset(self, target_name: str, *, extra_names: list[str] | None = None) -> TabularDataset: + def to_tabular_dataset( + self, + target_name: str, + /, # If we allow multiple targets in the future, we would rename the parameter to `target_names`. + *, + extra_names: str | list[str] | None = None, + ) -> TabularDataset: """ Return a new `TabularDataset` with columns marked as a target, feature, or extra. - The target column is the column that a model should predict. - Feature columns are columns that a model should use to make predictions. - - Extra columns are columns that are neither feature nor target. They can be used to provide additional context, - like an ID column. + - Extra columns are columns that are neither feature nor target. They are ignored by models and can be used to + provide additional context. An ID or name column is a common example. Feature columns are implicitly defined as all columns except the target and extra columns. If no extra columns are specified, all columns except the target column are used as features. @@ -1989,101 +2660,99 @@ def to_tabular_dataset(self, target_name: str, *, extra_names: list[str] | None target_name: The name of the target column. extra_names: - Names of the columns that are neither feature nor target. If None, no extra columns are used, i.e. all but + Names of the columns that are neither features nor target. If None, no extra columns are used, i.e. all but the target column are used as features. - Returns - ------- - dataset: - A new tabular dataset with the given target and feature names. - Raises ------ + ColumnNotFoundError + If a target or extra column does not exist. ValueError - If the target column is also a feature column. + If the target column is also an extra column. ValueError - If no feature columns are specified. + If no feature column remains. Examples -------- >>> from safeds.data.tabular.containers import Table >>> table = Table( ... { - ... "item": ["apple", "milk", "beer"], - ... "price": [1.10, 1.19, 1.79], - ... "amount_bought": [74, 72, 51], - ... } + ... "extra": [1, 2, 3], + ... "feature": [4, 5, 6], + ... "target": [7, 8, 9], + ... }, ... ) - >>> dataset = table.to_tabular_dataset(target_name="amount_bought", extra_names=["item"]) + >>> dataset = table.to_tabular_dataset("target", extra_names="extra") """ from safeds.data.labeled.containers import TabularDataset # circular import return TabularDataset( self, - target_name=target_name, + target_name, extra_names=extra_names, ) - def to_time_series_dataset( - self, - target_name: str, - window_size: int, - *, - extra_names: list[str] | None = None, - forecast_horizon: int = 1, - continuous: bool = False, - ) -> TimeSeriesDataset: - """ - Return a new `TimeSeriesDataset` with columns marked as a target column, time or feature columns. - - The original table is not modified. - - Parameters - ---------- - target_name: - The name of the target column. - window_size: - The number of consecutive sample to use as input for prediction. - extra_names: - Names of the columns that are neither features nor target. If None, no extra columns are used, i.e. all but - the target column are used as features. - forecast_horizon: - The number of time steps to predict into the future. - - Returns - ------- - dataset: - A new time series dataset with the given target and feature names. - - Raises - ------ - ValueError - If the target column is also a feature column. - ValueError - If the time column is also a feature column. - - Examples - -------- - >>> from safeds.data.tabular.containers import Table - >>> table = Table({"day": [0, 1, 2], "price": [1.10, 1.19, 1.79], "amount_bought": [74, 72, 51]}) - >>> dataset = table.to_time_series_dataset(target_name="amount_bought", window_size=2) - """ - from safeds.data.labeled.containers import TimeSeriesDataset # circular import - - return TimeSeriesDataset( - self, - target_name=target_name, - window_size=window_size, - extra_names=extra_names, - forecast_horizon=forecast_horizon, - continuous=continuous, - ) + # TODO: check design, test, and add the method back (here we should definitely allow multiple targets) + # def to_time_series_dataset( + # self, + # target_name: str, + # window_size: int, + # *, + # extra_names: list[str] | None = None, + # forecast_horizon: int = 1, + # continuous: bool = False, + # ) -> TimeSeriesDataset: + # """ + # Return a new `TimeSeriesDataset` with columns marked as a target column, time or feature columns. + # + # The original table is not modified. + # + # Parameters + # ---------- + # target_name: + # The name of the target column. + # window_size: + # The number of consecutive sample to use as input for prediction. + # extra_names: + # Names of the columns that are neither features nor target. If None, no extra columns are used, i.e. all but + # the target column are used as features. + # forecast_horizon: + # The number of time steps to predict into the future. + # + # Returns + # ------- + # dataset: + # A new time series dataset with the given target and feature names. + # + # Raises + # ------ + # ValueError + # If the target column is also a feature column. + # ValueError + # If the time column is also a feature column. + # + # Examples + # -------- + # >>> from safeds.data.tabular.containers import Table + # >>> table = Table({"day": [0, 1, 2], "price": [1.10, 1.19, 1.79], "amount_bought": [74, 72, 51]}) + # >>> dataset = table.to_time_series_dataset("amount_bought", window_size=2) + # """ + # from safeds.data.labeled.containers import TimeSeriesDataset # circular import + # + # return TimeSeriesDataset( + # self, + # target_name, + # window_size=window_size, + # extra_names=extra_names, + # forecast_horizon=forecast_horizon, + # continuous=continuous, + # ) # ------------------------------------------------------------------------------------------------------------------ # Dataframe interchange protocol # ------------------------------------------------------------------------------------------------------------------ - def __dataframe__(self, nan_as_null: bool = False, allow_copy: bool = True): # type: ignore[no-untyped-def] + def __dataframe__(self, allow_copy: bool = True) -> DataFrame: """ Return a dataframe object that conforms to the dataframe interchange protocol. @@ -2099,9 +2768,6 @@ def __dataframe__(self, nan_as_null: bool = False, allow_copy: bool = True): # Parameters ---------- - nan_as_null: - This parameter is deprecated and will be removed in a later revision of the dataframe interchange protocol. - Setting it has no effect. allow_copy: Whether memory may be copied to create the dataframe object. @@ -2133,7 +2799,7 @@ def _repr_html_(self) -> str: # Internal # ------------------------------------------------------------------------------------------------------------------ - # TODO + # TODO: check and potentially rework this def _into_dataloader(self, batch_size: int) -> DataLoader: """ Return a Dataloader for the data stored in this table, used for predicting with neural networks. @@ -2164,7 +2830,7 @@ def _into_dataloader(self, batch_size: int) -> DataLoader: ) -# TODO +# TODO: check and potentially rework this def _create_dataset(features: Tensor) -> Dataset: from torch.utils.data import Dataset diff --git a/src/safeds/data/tabular/plotting/_column_plotter.py b/src/safeds/data/tabular/plotting/_column_plotter.py index 3fd159f25..451664b41 100644 --- a/src/safeds/data/tabular/plotting/_column_plotter.py +++ b/src/safeds/data/tabular/plotting/_column_plotter.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Literal from safeds._utils import _figure_to_image -from safeds._validation._check_columns_are_numeric import _check_column_is_numeric +from safeds._validation import _check_column_is_numeric if TYPE_CHECKING: from safeds.data.image.containers import Image diff --git a/src/safeds/data/tabular/plotting/_table_plotter.py b/src/safeds/data/tabular/plotting/_table_plotter.py index 5a9c43c54..68eb3c7f9 100644 --- a/src/safeds/data/tabular/plotting/_table_plotter.py +++ b/src/safeds/data/tabular/plotting/_table_plotter.py @@ -1,11 +1,10 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING from safeds._utils import _figure_to_image -from safeds._validation import _check_bounds, _check_columns_exist, _ClosedBound -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_bounds, _check_columns_are_numeric, _check_columns_exist, _ClosedBound from safeds.exceptions import ColumnTypeError, NonNumericColumnError if TYPE_CHECKING: @@ -345,7 +344,7 @@ def histograms(self, *, max_bin_count: int = 10, theme: Literal["dark", "light"] ax.set_xlabel("") ax.set_ylabel("") - if column.is_numeric and len(distinct_values) > max_bin_count: + if column.type.is_numeric and len(distinct_values) > max_bin_count: min_val = (column.min() or 0) - 1e-6 # Otherwise the minimum value is not included in the first bin max_val = column.max() or 0 bin_count = min(max_bin_count, len(distinct_values)) @@ -656,7 +655,7 @@ def moving_average_plot( ylabel=y_name, ) ax.legend() - if self._table.get_column(x_name).is_temporal and self._table.get_column(x_name).row_count < 9: + if self._table.get_column_type(x_name).is_temporal and self._table.get_column(x_name).row_count < 9: ax.set_xticks(x_data) # Set x-ticks to the x data points ax.set_xticks(ax.get_xticks()) ax.set_xticklabels( @@ -755,5 +754,5 @@ def _plot_validation(table: Table, x_name: str, y_names: list[str]) -> None: y_names.remove(x_name) _check_columns_are_numeric(table, y_names) - if not table.get_column(x_name).is_numeric and not table.get_column(x_name).is_temporal: + if not table.get_column_type(x_name).is_numeric and not table.get_column_type(x_name).is_temporal: raise ColumnTypeError(x_name) diff --git a/src/safeds/data/tabular/transformation/__init__.py b/src/safeds/data/tabular/transformation/__init__.py index 536927ee4..fdf7a0961 100644 --- a/src/safeds/data/tabular/transformation/__init__.py +++ b/src/safeds/data/tabular/transformation/__init__.py @@ -41,13 +41,13 @@ "Discretizer", "FunctionalTableTransformer", "InvertibleTableTransformer", + "KNearestNeighborsImputer", "LabelEncoder", "OneHotEncoder", "RangeScaler", - "SequentialTableTransformer", "RobustScaler", + "SequentialTableTransformer", "SimpleImputer", "StandardScaler", "TableTransformer", - "KNearestNeighborsImputer", ] diff --git a/src/safeds/data/tabular/transformation/_discretizer.py b/src/safeds/data/tabular/transformation/_discretizer.py index a40a7f306..e55b66d77 100644 --- a/src/safeds/data/tabular/transformation/_discretizer.py +++ b/src/safeds/data/tabular/transformation/_discretizer.py @@ -3,12 +3,11 @@ from typing import TYPE_CHECKING from safeds._utils import _structural_hash -from safeds._validation import _check_bounds, _check_columns_exist, _ClosedBound -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_bounds, _check_columns_are_numeric, _check_columns_exist, _ClosedBound from safeds.data.tabular.containers import Table from safeds.exceptions import ( NonNumericColumnError, - TransformerNotFittedError, + NotFittedError, ) from ._table_transformer import TableTransformer @@ -115,7 +114,7 @@ def fit(self, table: Table) -> Discretizer: wrapped_transformer = sk_KBinsDiscretizer(n_bins=self._bin_count, encode="ordinal") wrapped_transformer.set_output(transform="polars") wrapped_transformer.fit( - table.remove_columns_except(column_names)._data_frame, + table.select_columns(column_names)._data_frame, ) result = Discretizer(self._bin_count, column_names=column_names) @@ -141,7 +140,7 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ValueError If the table is empty. @@ -152,7 +151,7 @@ def transform(self, table: Table) -> Table: """ # Transformer has not been fitted yet if self._wrapped_transformer is None or self._column_names is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") if table.row_count == 0: raise ValueError("The table cannot be transformed because it contains 0 rows") @@ -165,7 +164,7 @@ def transform(self, table: Table) -> Table: raise NonNumericColumnError(f"{column} is of type {table.get_column(column).type}.") new_data = self._wrapped_transformer.transform( - table.remove_columns_except(self._column_names)._data_frame, + table.select_columns(self._column_names)._data_frame, ) return Table._from_polars_lazy_frame( table._lazy_frame.update(new_data.lazy()), diff --git a/src/safeds/data/tabular/transformation/_invertible_table_transformer.py b/src/safeds/data/tabular/transformation/_invertible_table_transformer.py index 7b0caefc8..47d4681ee 100644 --- a/src/safeds/data/tabular/transformation/_invertible_table_transformer.py +++ b/src/safeds/data/tabular/transformation/_invertible_table_transformer.py @@ -33,6 +33,6 @@ def inverse_transform(self, transformed_table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. """ diff --git a/src/safeds/data/tabular/transformation/_k_nearest_neighbors_imputer.py b/src/safeds/data/tabular/transformation/_k_nearest_neighbors_imputer.py index 890749ba4..1b0b19ab7 100644 --- a/src/safeds/data/tabular/transformation/_k_nearest_neighbors_imputer.py +++ b/src/safeds/data/tabular/transformation/_k_nearest_neighbors_imputer.py @@ -5,7 +5,7 @@ from safeds._utils import _structural_hash from safeds._validation import _check_bounds, _check_columns_exist, _ClosedBound from safeds.data.tabular.containers import Table -from safeds.exceptions import TransformerNotFittedError +from safeds.exceptions import NotFittedError from ._table_transformer import TableTransformer @@ -122,7 +122,7 @@ def fit(self, table: Table) -> KNearestNeighborsImputer: wrapped_transformer = sk_KNNImputer(n_neighbors=self._neighbor_count, missing_values=value_to_replace) wrapped_transformer.set_output(transform="polars") wrapped_transformer.fit( - table.remove_columns_except(column_names)._data_frame, + table.select_columns(column_names)._data_frame, ) result = KNearestNeighborsImputer(self._neighbor_count, column_names=column_names) @@ -148,18 +148,18 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer is not fitted. ColumnNotFoundError If one of the columns, that should be transformed is not in the table. """ if self._column_names is None or self._wrapped_transformer is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(table, self._column_names) new_data = self._wrapped_transformer.transform( - table.remove_columns_except(self._column_names)._data_frame, + table.select_columns(self._column_names)._data_frame, ) return Table._from_polars_lazy_frame( diff --git a/src/safeds/data/tabular/transformation/_label_encoder.py b/src/safeds/data/tabular/transformation/_label_encoder.py index 30b7ff85f..cf1dfcbd5 100644 --- a/src/safeds/data/tabular/transformation/_label_encoder.py +++ b/src/safeds/data/tabular/transformation/_label_encoder.py @@ -4,10 +4,9 @@ from typing import Any from safeds._utils import _structural_hash -from safeds._validation import _check_columns_exist -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_columns_are_numeric, _check_columns_exist from safeds.data.tabular.containers import Table -from safeds.exceptions import TransformerNotFittedError +from safeds.exceptions import NotFittedError from ._invertible_table_transformer import InvertibleTableTransformer @@ -146,7 +145,7 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -157,7 +156,7 @@ def transform(self, table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._mapping is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(table, self._column_names) @@ -188,7 +187,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -199,7 +198,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._inverse_mapping is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(transformed_table, self._column_names) _check_columns_are_numeric( @@ -218,7 +217,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: def _warn_if_columns_are_numeric(table: Table, column_names: list[str]) -> None: - numeric_columns = table.remove_columns_except(column_names).remove_non_numeric_columns().column_names + numeric_columns = table.select_columns(column_names).remove_non_numeric_columns().column_names if numeric_columns: warnings.warn( f"The columns {numeric_columns} contain numerical data. " diff --git a/src/safeds/data/tabular/transformation/_one_hot_encoder.py b/src/safeds/data/tabular/transformation/_one_hot_encoder.py index 0df5be8c9..5b4005244 100644 --- a/src/safeds/data/tabular/transformation/_one_hot_encoder.py +++ b/src/safeds/data/tabular/transformation/_one_hot_encoder.py @@ -4,11 +4,10 @@ from typing import Any from safeds._utils import _structural_hash -from safeds._validation import _check_columns_exist -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_columns_are_numeric, _check_columns_exist from safeds.data.tabular.containers import Table from safeds.exceptions import ( - TransformerNotFittedError, + NotFittedError, ) from ._invertible_table_transformer import InvertibleTableTransformer @@ -200,7 +199,7 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -209,8 +208,9 @@ def transform(self, table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._mapping is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") + # TODO: raise schema error instead _check_columns_exist(table, self._column_names) expressions = [ @@ -242,7 +242,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -253,7 +253,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._new_column_names is None or self._mapping is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(transformed_table, self._new_column_names) _check_columns_are_numeric( @@ -280,12 +280,12 @@ def inverse_transform(self, transformed_table: Table) -> Table: # TODO: remove / replace with consistent introspection methods across all transformers def _get_names_of_added_columns(self) -> list[str]: if self._new_column_names is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") return list(self._new_column_names) # defensive copy def _warn_if_columns_are_numeric(table: Table, column_names: list[str]) -> None: - numeric_columns = table.remove_columns_except(column_names).remove_non_numeric_columns().column_names + numeric_columns = table.select_columns(column_names).remove_non_numeric_columns().column_names if numeric_columns: warnings.warn( f"The columns {numeric_columns} contain numerical data. " diff --git a/src/safeds/data/tabular/transformation/_range_scaler.py b/src/safeds/data/tabular/transformation/_range_scaler.py index 3a93be88b..5d1d320c0 100644 --- a/src/safeds/data/tabular/transformation/_range_scaler.py +++ b/src/safeds/data/tabular/transformation/_range_scaler.py @@ -3,10 +3,9 @@ from typing import TYPE_CHECKING from safeds._utils import _structural_hash -from safeds._validation import _check_columns_exist -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_columns_are_numeric, _check_columns_exist from safeds.data.tabular.containers import Table -from safeds.exceptions import TransformerNotFittedError +from safeds.exceptions import NotFittedError from ._invertible_table_transformer import InvertibleTableTransformer @@ -146,7 +145,7 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -157,7 +156,7 @@ def transform(self, table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._data_min is None or self._data_max is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(table, self._column_names) _check_columns_are_numeric(table, self._column_names, operation="transform with a RangeScaler") @@ -194,7 +193,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -205,7 +204,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._data_min is None or self._data_max is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(transformed_table, self._column_names) _check_columns_are_numeric( diff --git a/src/safeds/data/tabular/transformation/_robust_scaler.py b/src/safeds/data/tabular/transformation/_robust_scaler.py index 970b32d6c..aaed50711 100644 --- a/src/safeds/data/tabular/transformation/_robust_scaler.py +++ b/src/safeds/data/tabular/transformation/_robust_scaler.py @@ -2,10 +2,9 @@ from typing import TYPE_CHECKING -from safeds._validation import _check_columns_exist -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_columns_are_numeric, _check_columns_exist from safeds.data.tabular.containers import Table -from safeds.exceptions import TransformerNotFittedError +from safeds.exceptions import NotFittedError from ._invertible_table_transformer import InvertibleTableTransformer @@ -17,7 +16,8 @@ class RobustScaler(InvertibleTableTransformer): """ The RobustScaler transforms column values to a range by removing the median and scaling to the interquartile range. - Currently, for columns with high stability (IQR == 0), it will only substract the median and not scale to avoid dividing by zero. + Currently, for columns with high stability (IQR == 0), it will only subtract the median and not scale to avoid + dividing by zero. Parameters ---------- @@ -126,7 +126,7 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -137,7 +137,7 @@ def transform(self, table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._data_median is None or self._data_scale is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(table, self._column_names) _check_columns_are_numeric(table, self._column_names, operation="transform with a RobustScaler") @@ -169,7 +169,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -180,7 +180,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._data_median is None or self._data_scale is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(transformed_table, self._column_names) _check_columns_are_numeric( diff --git a/src/safeds/data/tabular/transformation/_sequential_table_transformer.py b/src/safeds/data/tabular/transformation/_sequential_table_transformer.py index 5d26c2101..c7e2e8d3c 100644 --- a/src/safeds/data/tabular/transformation/_sequential_table_transformer.py +++ b/src/safeds/data/tabular/transformation/_sequential_table_transformer.py @@ -4,7 +4,7 @@ from warnings import warn from safeds._utils import _structural_hash -from safeds.exceptions import TransformerNotFittedError, TransformerNotInvertibleError +from safeds.exceptions import NotFittedError, NotInvertibleError from ._invertible_table_transformer import InvertibleTableTransformer @@ -72,7 +72,7 @@ def fit(self, table: Table) -> SequentialTableTransformer: Raises ------ - ValueError: + ValueError Raises a ValueError if the table has no rows. """ if table.row_count == 0: @@ -97,7 +97,7 @@ def transform(self, table: Table) -> Table: """ Transform the table using all the transformers sequentially. - Might change the order and type of columns base on the transformers used. + Might change the order and type of columns based on the transformers used. Parameters ---------- @@ -111,11 +111,11 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError: - Raises a TransformerNotFittedError if the transformer isn't fitted. + NotFittedError + If the transformer has not been fitted yet. """ if not self._is_fitted: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") current_table: Table = table for transformer in self._transformers: @@ -141,20 +141,20 @@ def inverse_transform(self, transformed_table: Table) -> Table: Raises ------ - TransformerNotFittedError: - Raises a TransformerNotFittedError if the transformer isn't fitted. - TransformerNotInvertibleError: - Raises a TransformerNotInvertibleError if one of the transformers isn't invertible. + NotFittedError + If the transformer has not been fitted yet. + NotInvertibleError + If the transformer is not invertible. """ if not self._is_fitted: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") - # sequentially inverse transform the table with all transformers, working from the back of the list forwards. + # sequentially inverse-transform the table with all transformers, working from the back of the list forwards. current_table: Table = transformed_table for transformer in reversed(self._transformers): # check if transformer is invertible if not (isinstance(transformer, InvertibleTableTransformer)): - raise TransformerNotInvertibleError(str(type(transformer))) + raise NotInvertibleError(transformer) current_table = transformer.inverse_transform(current_table) return current_table diff --git a/src/safeds/data/tabular/transformation/_simple_imputer.py b/src/safeds/data/tabular/transformation/_simple_imputer.py index 89d078f7d..22b2d76b3 100644 --- a/src/safeds/data/tabular/transformation/_simple_imputer.py +++ b/src/safeds/data/tabular/transformation/_simple_imputer.py @@ -5,10 +5,9 @@ from typing import Any from safeds._utils import _structural_hash -from safeds._validation import _check_columns_exist -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_columns_are_numeric, _check_columns_exist from safeds.data.tabular.containers import Table -from safeds.exceptions import TransformerNotFittedError +from safeds.exceptions import NotFittedError from ._table_transformer import TableTransformer @@ -209,7 +208,7 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -218,7 +217,7 @@ def transform(self, table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._replacement is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(table, self._column_names) diff --git a/src/safeds/data/tabular/transformation/_standard_scaler.py b/src/safeds/data/tabular/transformation/_standard_scaler.py index eacaf30ec..322dd4dbc 100644 --- a/src/safeds/data/tabular/transformation/_standard_scaler.py +++ b/src/safeds/data/tabular/transformation/_standard_scaler.py @@ -2,10 +2,9 @@ from typing import TYPE_CHECKING -from safeds._validation import _check_columns_exist -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_columns_are_numeric, _check_columns_exist from safeds.data.tabular.containers import Table -from safeds.exceptions import TransformerNotFittedError +from safeds.exceptions import NotFittedError from ._invertible_table_transformer import InvertibleTableTransformer @@ -115,7 +114,7 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -126,7 +125,7 @@ def transform(self, table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._data_mean is None or self._data_standard_deviation is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(table, self._column_names) _check_columns_are_numeric(table, self._column_names, operation="transform with a StandardScaler") @@ -158,7 +157,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. ColumnNotFoundError If the input table does not contain all columns used to fit the transformer. @@ -169,7 +168,7 @@ def inverse_transform(self, transformed_table: Table) -> Table: # Used in favor of is_fitted, so the type checker is happy if self._column_names is None or self._data_mean is None or self._data_standard_deviation is None: - raise TransformerNotFittedError + raise NotFittedError(kind="transformer") _check_columns_exist(transformed_table, self._column_names) _check_columns_are_numeric( diff --git a/src/safeds/data/tabular/transformation/_table_transformer.py b/src/safeds/data/tabular/transformation/_table_transformer.py index 5e61616c2..bebdbfd42 100644 --- a/src/safeds/data/tabular/transformation/_table_transformer.py +++ b/src/safeds/data/tabular/transformation/_table_transformer.py @@ -90,7 +90,7 @@ def transform(self, table: Table) -> Table: Raises ------ - TransformerNotFittedError + NotFittedError If the transformer has not been fitted yet. """ diff --git a/src/safeds/data/tabular/typing/__init__.py b/src/safeds/data/tabular/typing/__init__.py index 92b66a5b6..6076ed595 100644 --- a/src/safeds/data/tabular/typing/__init__.py +++ b/src/safeds/data/tabular/typing/__init__.py @@ -5,18 +5,18 @@ import apipkg if TYPE_CHECKING: - from ._data_type import DataType + from ._column_type import ColumnType from ._schema import Schema apipkg.initpkg( __name__, { - "DataType": "._data_type:DataType", + "ColumnType": "._column_type:ColumnType", "Schema": "._schema:Schema", }, ) __all__ = [ - "DataType", + "ColumnType", "Schema", ] diff --git a/src/safeds/data/tabular/typing/_column_type.py b/src/safeds/data/tabular/typing/_column_type.py new file mode 100644 index 000000000..532a3448e --- /dev/null +++ b/src/safeds/data/tabular/typing/_column_type.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import polars as pl + + +class ColumnType(ABC): + """ + The type of a column in a table. + + Use the static factory methods to create instances of this class. + """ + + # ------------------------------------------------------------------------------------------------------------------ + # Factory methods + # ------------------------------------------------------------------------------------------------------------------ + + # Float -------------------------------------------------------------------- + + @staticmethod + def float32() -> ColumnType: + """Create a `float32` column type (32-bit floating point number).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Float32()) + + @staticmethod + def float64() -> ColumnType: + """Create a `float64` column type (64-bit floating point number).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Float64()) + + # Signed int --------------------------------------------------------------- + + @staticmethod + def int8() -> ColumnType: + """Create an `int8` column type (8-bit signed integer).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Int8()) + + @staticmethod + def int16() -> ColumnType: + """Create an `int16` column type (16-bit signed integer).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Int16()) + + @staticmethod + def int32() -> ColumnType: + """Create an `int32` column type (32-bit signed integer).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Int32()) + + @staticmethod + def int64() -> ColumnType: + """Create an `int64` column type (64-bit signed integer).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Int64()) + + # Unsigned int ------------------------------------------------------------- + + @staticmethod + def uint8() -> ColumnType: + """Create a `uint8` column type (8-bit unsigned integer).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.UInt8()) + + @staticmethod + def uint16() -> ColumnType: + """Create a `uint16` column type (16-bit unsigned integer).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.UInt16()) + + @staticmethod + def uint32() -> ColumnType: + """Create a `uint32` column type (32-bit unsigned integer).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.UInt32()) + + @staticmethod + def uint64() -> ColumnType: + """Create a `uint64` column type (64-bit unsigned integer).""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.UInt64()) + + # Temporal ----------------------------------------------------------------- + + @staticmethod + def date() -> ColumnType: + """Create a `date` column type.""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Date()) + + @staticmethod + def datetime() -> ColumnType: + """Create a `datetime` column type.""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Datetime()) + + @staticmethod + def duration() -> ColumnType: + """Create a `duration` column type.""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Duration()) + + @staticmethod + def time() -> ColumnType: + """Create a `time` column type.""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Time()) + + # String ------------------------------------------------------------------- + + @staticmethod + def string() -> ColumnType: + """Create a `string` column type.""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.String()) + + # Other -------------------------------------------------------------------- + + @staticmethod + def binary() -> ColumnType: + """Create a `binary` column type.""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Binary()) + + @staticmethod + def boolean() -> ColumnType: + """Create a `boolean` column type.""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Boolean()) + + @staticmethod + def null() -> ColumnType: + """Create a `null` column type.""" + import polars as pl + + from ._polars_column_type import _PolarsColumnType # circular import + + return _PolarsColumnType(pl.Null()) + + # ------------------------------------------------------------------------------------------------------------------ + # Dunder methods + # ------------------------------------------------------------------------------------------------------------------ + + @abstractmethod + def __eq__(self, other: object) -> bool: ... + + @abstractmethod + def __hash__(self) -> int: ... + + @abstractmethod + def __repr__(self) -> str: ... + + @abstractmethod + def __sizeof__(self) -> int: ... + + @abstractmethod + def __str__(self) -> str: ... + + # ------------------------------------------------------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------------------------------------------------------ + + @property + @abstractmethod + def is_float(self) -> bool: + """ + Whether the column type is a floating point type. + + Examples + -------- + >>> from safeds.data.tabular.typing import ColumnType + >>> ColumnType.float32().is_float + True + + >>> ColumnType.int8().is_float + False + """ + + @property + @abstractmethod + def is_int(self) -> bool: + """ + Whether the column type is an integer type (signed or unsigned). + + Examples + -------- + >>> from safeds.data.tabular.typing import ColumnType + >>> ColumnType.int8().is_int + True + + >>> ColumnType.float32().is_int + False + """ + + @property + @abstractmethod + def is_numeric(self) -> bool: + """ + Whether the column type is a numeric type. + + Examples + -------- + >>> from safeds.data.tabular.typing import ColumnType + >>> ColumnType.float32().is_numeric + True + + >>> ColumnType.string().is_numeric + False + """ + + @property + @abstractmethod + def is_signed_int(self) -> bool: + """ + Whether the column type is a signed integer type. + + Examples + -------- + >>> from safeds.data.tabular.typing import ColumnType + >>> ColumnType.int8().is_signed_int + True + + >>> ColumnType.uint8().is_signed_int + False + """ + + @property + @abstractmethod + def is_temporal(self) -> bool: + """ + Whether the column type is a temporal type. + + Examples + -------- + >>> from safeds.data.tabular.typing import ColumnType + >>> ColumnType.date().is_temporal + True + + >>> ColumnType.string().is_temporal + False + """ + + @property + @abstractmethod + def is_unsigned_int(self) -> bool: + """ + Whether the column type is an unsigned integer type. + + Examples + -------- + >>> from safeds.data.tabular.typing import ColumnType + >>> ColumnType.uint8().is_unsigned_int + True + + >>> ColumnType.int8().is_unsigned_int + False + """ + + # ------------------------------------------------------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------------------------------------------------------ + + @property + @abstractmethod + def _polars_data_type(self) -> pl.DataType: + """The Polars expression that corresponds to this cell.""" diff --git a/src/safeds/data/tabular/typing/_data_type.py b/src/safeds/data/tabular/typing/_data_type.py deleted file mode 100644 index 40f992728..000000000 --- a/src/safeds/data/tabular/typing/_data_type.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class DataType(ABC): - """The type of a column or cell in a table.""" - - # ------------------------------------------------------------------------------------------------------------------ - # Dunder methods - # ------------------------------------------------------------------------------------------------------------------ - - @abstractmethod - def __eq__(self, other: object) -> bool: ... - - @abstractmethod - def __hash__(self) -> int: ... - - @abstractmethod - def __repr__(self) -> str: ... - - @abstractmethod - def __sizeof__(self) -> int: ... - - @abstractmethod - def __str__(self) -> str: ... - - # ------------------------------------------------------------------------------------------------------------------ - # Properties - # ------------------------------------------------------------------------------------------------------------------ - - @property - @abstractmethod - def is_numeric(self) -> bool: - """ - Whether the column type is numeric. - - Examples - -------- - >>> from safeds.data.tabular.containers import Table - >>> table = Table( - ... { - ... "A": [1, 2, 3], - ... "B": ["a", "b", "c"] - ... } - ... ) - >>> table.get_column_type("A").is_numeric - True - - >>> table.get_column_type("B").is_numeric - False - """ - - @property - @abstractmethod - def is_temporal(self) -> bool: - """ - Whether the column type is operator. - - Examples - -------- - >>> from datetime import datetime - >>> from safeds.data.tabular.containers import Table - >>> table = Table( - ... { - ... "A": [datetime.now(), datetime.now(), datetime.now()], - ... "B": ["a", "b", "c"] - ... } - ... ) - >>> table.get_column_type("A").is_temporal - True - - >>> table.get_column_type("B").is_temporal - False - """ diff --git a/src/safeds/data/tabular/typing/_polars_data_type.py b/src/safeds/data/tabular/typing/_polars_column_type.py similarity index 52% rename from src/safeds/data/tabular/typing/_polars_data_type.py rename to src/safeds/data/tabular/typing/_polars_column_type.py index 724998e5d..5a5111e4c 100644 --- a/src/safeds/data/tabular/typing/_polars_data_type.py +++ b/src/safeds/data/tabular/typing/_polars_column_type.py @@ -2,17 +2,19 @@ from typing import TYPE_CHECKING -from ._data_type import DataType +from safeds._utils import _structural_hash + +from ._column_type import ColumnType if TYPE_CHECKING: import polars as pl -class _PolarsDataType(DataType): +class _PolarsColumnType(ColumnType): """ - The type of a column or cell in a table. + The type of a column in a table. - This implementation is based on Polars' data types. + This implementation is based on the data types of polars. """ # ------------------------------------------------------------------------------------------------------------------ @@ -23,32 +25,56 @@ def __init__(self, dtype: pl.DataType): self._dtype: pl.DataType = dtype def __eq__(self, other: object) -> bool: - if not isinstance(other, _PolarsDataType): + if not isinstance(other, _PolarsColumnType): return NotImplemented if self is other: return True return self._dtype.is_(other._dtype) def __hash__(self) -> int: - return self._dtype.__hash__() + return _structural_hash(self._dtype.__class__.__name__) def __repr__(self) -> str: - return self._dtype.__repr__() + return str(self) def __sizeof__(self) -> int: return self._dtype.__sizeof__() def __str__(self) -> str: - return self._dtype.__str__() + return self._dtype.__str__().split("(", maxsplit=1)[0].lower() # ------------------------------------------------------------------------------------------------------------------ # Properties # ------------------------------------------------------------------------------------------------------------------ + @property + def is_float(self) -> bool: + return self._dtype.is_float() + + @property + def is_int(self) -> bool: + return self._dtype.is_integer() + @property def is_numeric(self) -> bool: return self._dtype.is_numeric() + @property + def is_signed_int(self) -> bool: + return self._dtype.is_signed_integer() + @property def is_temporal(self) -> bool: return self._dtype.is_temporal() + + @property + def is_unsigned_int(self) -> bool: + return self._dtype.is_unsigned_integer() + + # ------------------------------------------------------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------------------------------------------------------ + + @property + def _polars_data_type(self) -> pl.DataType: + return self._dtype diff --git a/src/safeds/data/tabular/typing/_polars_schema.py b/src/safeds/data/tabular/typing/_polars_schema.py deleted file mode 100644 index 5c619064e..000000000 --- a/src/safeds/data/tabular/typing/_polars_schema.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -import sys -from typing import TYPE_CHECKING - -from safeds._utils import _structural_hash -from safeds._validation import _check_columns_exist - -from ._polars_data_type import _PolarsDataType -from ._schema import Schema - -if TYPE_CHECKING: - import polars as pl - - from safeds.data.tabular.typing import DataType - - -class _PolarsSchema(Schema): - """ - The schema of a row or table. - - This implementation is based on Polars' data types. - """ - - # ------------------------------------------------------------------------------------------------------------------ - # Dunder methods - # ------------------------------------------------------------------------------------------------------------------ - - def __init__(self, schema: pl.Schema): - self._schema: pl.Schema = schema - - def __eq__(self, other: object) -> bool: - if not isinstance(other, _PolarsSchema): - return NotImplemented - if self is other: - return True - return self._schema == other._schema - - def __hash__(self) -> int: - return _structural_hash(tuple(self._schema.keys()), [str(type_) for type_ in self._schema.values()]) - - def __repr__(self) -> str: - return f"Schema({self!s})" - - def __sizeof__(self) -> int: - return ( - sum(map(sys.getsizeof, self._schema.keys())) - + sum(map(sys.getsizeof, self._schema.values())) - + sys.getsizeof(self._schema) - ) - - def __str__(self) -> str: - match len(self._schema): - case 0: - return "{}" - case 1: - return str(self._schema) - case _: - lines = (f" {name!r}: {type_}" for name, type_ in self._schema.items()) - joined = ",\n".join(lines) - return f"{{\n{joined}\n}}" - - # ------------------------------------------------------------------------------------------------------------------ - # Properties - # ------------------------------------------------------------------------------------------------------------------ - - @property - def column_names(self) -> list[str]: - return list(self._schema.names()) - - # ------------------------------------------------------------------------------------------------------------------ - # Getters - # ------------------------------------------------------------------------------------------------------------------ - - def get_column_type(self, name: str) -> DataType: - _check_columns_exist(self, name) - - return _PolarsDataType(self._schema[name]) - - def has_column(self, name: str) -> bool: - return name in self._schema - - # ------------------------------------------------------------------------------------------------------------------ - # Conversion - # ------------------------------------------------------------------------------------------------------------------ - - def to_dict(self) -> dict[str, DataType]: - return {name: _PolarsDataType(type_) for name, type_ in self._schema.items()} - - # ------------------------------------------------------------------------------------------------------------------ - # IPython integration - # ------------------------------------------------------------------------------------------------------------------ - - def _repr_markdown_(self) -> str: - if len(self._schema) == 0: - return "Empty Schema" - - lines = (f"| {name} | {type_} |" for name, type_ in self._schema.items()) - joined = "\n".join(lines) - return f"| Column Name | Column Type |\n| --- | --- |\n{joined}" diff --git a/src/safeds/data/tabular/typing/_schema.py b/src/safeds/data/tabular/typing/_schema.py index 688f6c63e..f1ce98698 100644 --- a/src/safeds/data/tabular/typing/_schema.py +++ b/src/safeds/data/tabular/typing/_schema.py @@ -1,65 +1,114 @@ from __future__ import annotations -from abc import ABC, abstractmethod +import sys from typing import TYPE_CHECKING +from safeds._utils import _structural_hash +from safeds._validation import _check_columns_exist + +from ._polars_column_type import _PolarsColumnType + if TYPE_CHECKING: - from ._data_type import DataType + from collections.abc import Mapping + + import polars as pl + + from ._column_type import ColumnType -class Schema(ABC): +class Schema: """The schema of a row or table.""" # ------------------------------------------------------------------------------------------------------------------ - # Dunder methods + # Static methods # ------------------------------------------------------------------------------------------------------------------ - @abstractmethod - def __eq__(self, other: object) -> bool: ... - - @abstractmethod - def __hash__(self) -> int: ... - - @abstractmethod - def __repr__(self) -> str: ... + @staticmethod + def _from_polars_schema(schema: pl.Schema) -> Schema: + result = object.__new__(Schema) + result._schema = schema + return result - @abstractmethod - def __sizeof__(self) -> int: ... + # ------------------------------------------------------------------------------------------------------------------ + # Dunder methods + # ------------------------------------------------------------------------------------------------------------------ - @abstractmethod - def __str__(self) -> str: ... + def __init__(self, schema: Mapping[str, ColumnType]) -> None: + import polars as pl + + self._schema: pl.Schema = pl.Schema( + [(name, type_._polars_data_type) for name, type_ in schema.items()], + check_dtypes=False, + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Schema): + return NotImplemented + if self is other: + return True + return self._schema == other._schema + + def __hash__(self) -> int: + return _structural_hash(tuple(self._schema.keys()), [str(type_) for type_ in self._schema.values()]) + + def __repr__(self) -> str: + return f"Schema({self!s})" + + def __sizeof__(self) -> int: + return sys.getsizeof(self._schema) + + def __str__(self) -> str: + match self._schema.len(): + case 0: + return "{}" + case 1: + name, type_ = next(iter(self._schema.items())) + return f"{{{name!r}: {_PolarsColumnType(type_)}}}" + case _: + lines = (f" {name!r}: {_PolarsColumnType(type_)}" for name, type_ in self._schema.items()) + joined = ",\n".join(lines) + return f"{{\n{joined}\n}}" # ------------------------------------------------------------------------------------------------------------------ # Properties # ------------------------------------------------------------------------------------------------------------------ @property - @abstractmethod - def column_names(self) -> list[str]: + def column_count(self) -> int: """ - Return a list of all column names contained in this schema. + The number of columns. - Returns - ------- - column_names: - The column names. + Examples + -------- + >>> from safeds.data.tabular.typing import ColumnType, Schema + >>> schema = Schema({"a": ColumnType.int64(), "b": ColumnType.float32()}) + >>> schema.column_count + 2 + """ + return self._schema.len() + + @property + def column_names(self) -> list[str]: + """ + The names of the columns. Examples -------- - >>> from safeds.data.tabular.containers import Table - >>> table = Table({"A": [1, 2, 3], "B": ["a", "b", "c"]}) - >>> table.schema.column_names - ['A', 'B'] + >>> from safeds.data.tabular.typing import ColumnType, Schema + >>> schema = Schema({"a": ColumnType.int64(), "b": ColumnType.float32()}) + >>> schema.column_names + ['a', 'b'] """ + # polars already creates a defensive copy + return self._schema.names() # ------------------------------------------------------------------------------------------------------------------ # Getters # ------------------------------------------------------------------------------------------------------------------ - @abstractmethod - def get_column_type(self, name: str) -> DataType: + def get_column_type(self, name: str) -> ColumnType: """ - Return the type of the given column. + Get the type of a column. Parameters ---------- @@ -74,19 +123,22 @@ def get_column_type(self, name: str) -> DataType: Raises ------ ColumnNotFoundError - If the specified column name does not exist. + If the column does not exist. Examples -------- - >>> from safeds.data.tabular.containers import Table - >>> table = Table({"A": [1, 2, 3], "B": ["a", "b", "c"]}) - >>> type_ = table.schema.get_column_type("A") + >>> from safeds.data.tabular.typing import ColumnType, Schema + >>> schema = Schema({"a": ColumnType.int64(), "b": ColumnType.float32()}) + >>> schema.get_column_type("a") + int64 """ + _check_columns_exist(self, name) + + return _PolarsColumnType(self._schema[name]) - @abstractmethod def has_column(self, name: str) -> bool: """ - Return whether the schema contains a given column. + Check if the table has a column with a specific name. Parameters ---------- @@ -95,46 +147,47 @@ def has_column(self, name: str) -> bool: Returns ------- - contains: - True if the schema contains the column. + has_column: + Whether the table has a column with the specified name. Examples -------- - >>> from safeds.data.tabular.containers import Table - >>> table = Table({"A": [1, 2, 3], "B": ["a", "b", "c"]}) - >>> table.schema.has_column("A") + >>> from safeds.data.tabular.typing import ColumnType, Schema + >>> schema = Schema({"a": ColumnType.int64(), "b": ColumnType.float32()}) + >>> schema.has_column("a") True - >>> table.schema.has_column("C") + >>> schema.has_column("c") False """ + return name in self._schema # ------------------------------------------------------------------------------------------------------------------ # Conversion # ------------------------------------------------------------------------------------------------------------------ - @abstractmethod - def to_dict(self) -> dict[str, DataType]: + def to_dict(self) -> dict[str, ColumnType]: """ Return a dictionary that maps column names to column types. Returns ------- data: - Dictionary representation of the schema. + The dictionary representation of the schema. Examples -------- >>> from safeds.data.tabular.containers import Table >>> table = Table({"A": [1, 2, 3], "B": ["a", "b", "c"]}) - >>> dict_ = table.schema.to_dict() + >>> table.schema.to_dict() + {'A': int64, 'B': string} """ + return {name: _PolarsColumnType(type_) for name, type_ in self._schema.items()} # ------------------------------------------------------------------------------------------------------------------ # IPython integration # ------------------------------------------------------------------------------------------------------------------ - @abstractmethod def _repr_markdown_(self) -> str: """ Return a Markdown representation of the schema for IPython. @@ -144,3 +197,9 @@ def _repr_markdown_(self) -> str: markdown: The generated Markdown. """ + if self._schema.len() == 0: + return "Empty schema" + + lines = (f"| {name} | {type_} |" for name, type_ in self._schema.items()) + joined = "\n".join(lines) + return f"| Column Name | Column Type |\n| --- | --- |\n{joined}" diff --git a/src/safeds/exceptions/__init__.py b/src/safeds/exceptions/__init__.py index 802afdb57..408b8a8db 100644 --- a/src/safeds/exceptions/__init__.py +++ b/src/safeds/exceptions/__init__.py @@ -1,17 +1,16 @@ """Custom exceptions that can be raised by Safe-DS.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from ._data import ( - ColumnLengthMismatchError, - ColumnSizeError, - DuplicateColumnError, DuplicateIndexError, IllegalFormatError, IndexOutOfBoundsError, MissingValuesColumnError, NonNumericColumnError, OutputLengthMismatchError, - TransformerNotFittedError, - TransformerNotInvertibleError, ValueNotPresentWhenFittedError, ) from ._ml import ( @@ -25,52 +24,80 @@ InvalidFitDataError, InvalidModelStructureError, LearningError, - ModelNotFittedError, PlainTableError, PredictionError, TargetDataMismatchError, ) +if TYPE_CHECKING: + from safeds.data.tabular.transformation import TableTransformer + class SafeDsError(Exception): """Base class for all exceptions defined by Safe-DS.""" -class ColumnNotFoundError(SafeDsError): - """Exception raised when trying to access an invalid column name.""" +class ColumnNotFoundError(SafeDsError, IndexError): + """Raised when trying to access an invalid column name.""" + + +class ColumnTypeError(SafeDsError, TypeError): + """Raised when a column has the wrong type.""" + + +class DuplicateColumnError(SafeDsError, ValueError): + """Raised when a table has duplicate column names.""" + + +class FileExtensionError(SafeDsError, ValueError): + """Raised when a path has the wrong file extension.""" -class ColumnTypeError(SafeDsError): - """Exception raised when a column has the wrong type.""" +class LengthMismatchError(SafeDsError, ValueError): + """Raised when objects have different lengths.""" -class FileExtensionError(SafeDsError): - """Exception raised when a path has the wrong file extension.""" +class NotFittedError(SafeDsError, RuntimeError): + """Raised when an object (e.g. a transformer or model) is not fitted.""" + def __init__(self, *, kind: str = "object") -> None: + super().__init__(f"This {kind} has not been fitted yet.") -class OutOfBoundsError(SafeDsError): - """Exception raised when a value is outside its expected range.""" +class NotInvertibleError(SafeDsError, TypeError): + """Raised when inverting a non-invertible transformation.""" -__all__ = [ + def __init__(self, transformer: TableTransformer) -> None: + super().__init__(f"A {transformer.__class__.__name__} is not invertible.") + + +class OutOfBoundsError(SafeDsError, ValueError): + """Raised when a value is outside its expected range.""" + + +class SchemaError(SafeDsError, TypeError): + """Raised when tables have incompatible schemas.""" + + +__all__ = [ # noqa: RUF022 "SafeDsError", "ColumnNotFoundError", "ColumnTypeError", + "DuplicateColumnError", "FileExtensionError", + "LengthMismatchError", + "NotFittedError", + "NotInvertibleError", "OutOfBoundsError", + "SchemaError", # TODO # Data exceptions - "ColumnLengthMismatchError", - "ColumnSizeError", - "DuplicateColumnError", "DuplicateIndexError", "IllegalFormatError", "IndexOutOfBoundsError", "MissingValuesColumnError", "NonNumericColumnError", "OutputLengthMismatchError", - "TransformerNotFittedError", - "TransformerNotInvertibleError", "ValueNotPresentWhenFittedError", # ML exceptions "DatasetMissesDataError", @@ -83,7 +110,6 @@ class OutOfBoundsError(SafeDsError): "InputSizeError", "InvalidModelStructureError", "LearningError", - "ModelNotFittedError", "PlainTableError", "PredictionError", "TargetDataMismatchError", diff --git a/src/safeds/exceptions/_data.py b/src/safeds/exceptions/_data.py index 775cc1847..dbae1a853 100644 --- a/src/safeds/exceptions/_data.py +++ b/src/safeds/exceptions/_data.py @@ -23,20 +23,6 @@ def __init__(self, column_info: str, help_msg: str | None = None) -> None: ) -class DuplicateColumnError(ValueError): - """ - Exception raised for trying to modify a table resulting in a duplicate column name. - - Parameters - ---------- - column_name: - The name of the column that resulted in a duplicate. - """ - - def __init__(self, column_name: str): - super().__init__(f"Column '{column_name}' already exists.") - - class IndexOutOfBoundsError(IndexError): """ Exception raised for trying to access an element by an index that does not exist in the underlying data. @@ -74,29 +60,6 @@ def __init__(self, index: int): super().__init__(f"The index '{index}' is already in use.") -class ColumnSizeError(Exception): - """ - Exception raised for trying to use a column of unsupported size. - - Parameters - ---------- - expected_size: - The expected size of the column as an expression (e.g. 2, >0, !=0). - actual_size: - The actual size of the column as an expression (e.g. 2, >0, !=0). - """ - - def __init__(self, expected_size: str, actual_size: str): - super().__init__(f"Expected a column of size {expected_size} but got column of size {actual_size}.") - - -class ColumnLengthMismatchError(ValueError): - """Exception raised when the lengths of two or more columns do not match.""" - - def __init__(self, column_info: str): - super().__init__(f"The length of at least one column differs: \n{column_info}") - - class OutputLengthMismatchError(Exception): """Exception raised when the lengths of the input and output container does not match.""" @@ -104,20 +67,6 @@ def __init__(self, output_info: str): super().__init__(f"The length of the output container differs: \n{output_info}") -class TransformerNotFittedError(Exception): - """Raised when a transformer is used before fitting it.""" - - def __init__(self) -> None: - super().__init__("The transformer has not been fitted yet.") - - -class TransformerNotInvertibleError(Exception): - """Raised when a function tries to invert a non-invertible transformer.""" - - def __init__(self, transformer_type: str) -> None: - super().__init__(f"{transformer_type} is not invertible.") - - class ValueNotPresentWhenFittedError(Exception): """Exception raised when attempting to one-hot-encode a table containing values not present in the fitting phase.""" diff --git a/src/safeds/exceptions/_ml.py b/src/safeds/exceptions/_ml.py index 4f0462188..c9f98b0de 100644 --- a/src/safeds/exceptions/_ml.py +++ b/src/safeds/exceptions/_ml.py @@ -90,13 +90,6 @@ def __init__(self, reason: str): super().__init__(f"Error occurred while learning: {reason}") -class ModelNotFittedError(RuntimeError): - """Raised when a model is used before fitting it.""" - - def __init__(self) -> None: - super().__init__("The model has not been fitted yet.") - - class InvalidModelStructureError(Exception): """Raised when the structure of the model is invalid.""" diff --git a/src/safeds/ml/classical/_bases/_support_vector_machine_base.py b/src/safeds/ml/classical/_bases/_support_vector_machine_base.py index 05047531d..cc7f598f0 100644 --- a/src/safeds/ml/classical/_bases/_support_vector_machine_base.py +++ b/src/safeds/ml/classical/_bases/_support_vector_machine_base.py @@ -78,7 +78,7 @@ def sigmoid() -> _SupportVectorMachineBase.Kernel: def __init__( self, c: float | Choice[float], - kernel: _SupportVectorMachineBase.Kernel | None | Choice[_SupportVectorMachineBase.Kernel | None], + kernel: _SupportVectorMachineBase.Kernel | Choice[_SupportVectorMachineBase.Kernel] | None, ) -> None: if kernel is None: kernel = _SupportVectorMachineBase.Kernel.radial_basis_function() @@ -92,7 +92,7 @@ def __init__( # Hyperparameters self._c: float | Choice[float] = c - self._kernel: _SupportVectorMachineBase.Kernel | Choice[_SupportVectorMachineBase.Kernel | None] = kernel + self._kernel: _SupportVectorMachineBase.Kernel | Choice[_SupportVectorMachineBase.Kernel] = kernel def __hash__(self) -> int: return _structural_hash( @@ -112,7 +112,7 @@ def c(self) -> float | Choice[float]: # This property is abstract, so subclasses must declare a public return type. @property @abstractmethod - def kernel(self) -> _SupportVectorMachineBase.Kernel | Choice[_SupportVectorMachineBase.Kernel | None]: + def kernel(self) -> _SupportVectorMachineBase.Kernel | Choice[_SupportVectorMachineBase.Kernel]: """The type of kernel used.""" diff --git a/src/safeds/ml/classical/_supervised_model.py b/src/safeds/ml/classical/_supervised_model.py index 927db02bd..2a8d83676 100644 --- a/src/safeds/ml/classical/_supervised_model.py +++ b/src/safeds/ml/classical/_supervised_model.py @@ -12,8 +12,8 @@ DatasetMissesFeaturesError, LearningError, MissingValuesColumnError, - ModelNotFittedError, NonNumericColumnError, + NotFittedError, PlainTableError, PredictionError, ) @@ -21,7 +21,7 @@ if TYPE_CHECKING: from sklearn.base import ClassifierMixin, RegressorMixin - from safeds.data.tabular.typing import DataType, Schema + from safeds.data.tabular.typing import ColumnType, Schema class SupervisedModel(ABC): @@ -36,7 +36,7 @@ class SupervisedModel(ABC): def __init__(self) -> None: self._feature_schema: Schema | None = None self._target_name: str | None = None - self._target_type: DataType | None = None + self._target_type: ColumnType | None = None self._wrapped_model: ClassifierMixin | RegressorMixin | None = None # The decorator ensures that the method is overridden in all subclasses @@ -129,7 +129,7 @@ def predict( Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet. DatasetMissesFeaturesError If the dataset misses feature columns. @@ -162,12 +162,12 @@ def get_feature_names(self) -> list[str]: Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet. """ # Used in favor of is_fitted, so the type checker is happy if self._feature_schema is None: - raise ModelNotFittedError + raise NotFittedError(kind="model") return self._feature_schema.column_names @@ -184,12 +184,12 @@ def get_features_schema(self) -> Schema: Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet. """ # Used in favor of is_fitted, so the type checker is happy if self._feature_schema is None: - raise ModelNotFittedError + raise NotFittedError(kind="model") return self._feature_schema @@ -206,16 +206,16 @@ def get_target_name(self) -> str: Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet. """ # Used in favor of is_fitted, so the type checker is happy if self._target_name is None: - raise ModelNotFittedError + raise NotFittedError(kind="model") return self._target_name - def get_target_type(self) -> DataType: + def get_target_type(self) -> ColumnType: """ Return the type of the target column. @@ -228,12 +228,12 @@ def get_target_type(self) -> DataType: Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet. """ # Used in favor of is_fitted, so the type checker is happy if self._target_type is None: - raise ModelNotFittedError + raise NotFittedError(kind="model") return self._target_type @@ -368,7 +368,7 @@ def _predict_with_sklearn_model( Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet. DatasetMissesFeaturesError If the dataset misses feature columns. @@ -383,7 +383,7 @@ def _predict_with_sklearn_model( """ # Validation if model is None or target_name is None or feature_names is None: - raise ModelNotFittedError + raise NotFittedError(kind="model") if isinstance(dataset, TabularDataset): # pragma: no cover dataset = dataset.features @@ -394,7 +394,7 @@ def _predict_with_sklearn_model( if dataset.row_count == 0: raise DatasetMissesDataError - features = dataset.remove_columns_except(feature_names) + features = dataset.select_columns(feature_names) non_numerical_column_names = set(features.column_names) - set( features.remove_non_numeric_columns().column_names, @@ -433,7 +433,7 @@ def _predict_with_sklearn_model( return TabularDataset( output, - target_name=target_name, + target_name, extra_names=extra_names, ) except ValueError as exception: diff --git a/src/safeds/ml/classical/classification/_ada_boost_classifier.py b/src/safeds/ml/classical/classification/_ada_boost_classifier.py index 3dfb32b60..1db4081dd 100644 --- a/src/safeds/ml/classical/classification/_ada_boost_classifier.py +++ b/src/safeds/ml/classical/classification/_ada_boost_classifier.py @@ -23,15 +23,17 @@ class AdaBoostClassifier(Classifier, _AdaBoostBase): The learner from which the boosted ensemble is built. max_learner_count: The maximum number of learners at which boosting is terminated. In case of perfect fit, the learning procedure - is stopped early. Has to be greater than 0. + is stopped early. Must be greater than 0. learning_rate: Weight applied to each classifier at each boosting iteration. A higher learning rate increases the contribution - of each classifier. Has to be greater than 0. + of each classifier. Must be greater than 0. Raises ------ OutOfBoundsError - If `max_learner_count` or `learning_rate` are less than or equal to 0. + If `max_learner_count` is less than or equal to 0. + OutOfBoundsError + If `learning_rate` is less than or equal to 0. """ # ------------------------------------------------------------------------------------------------------------------ diff --git a/src/safeds/ml/classical/classification/_baseline_classifier.py b/src/safeds/ml/classical/classification/_baseline_classifier.py index d58c85962..de6e72811 100644 --- a/src/safeds/ml/classical/classification/_baseline_classifier.py +++ b/src/safeds/ml/classical/classification/_baseline_classifier.py @@ -3,12 +3,12 @@ from concurrent.futures import ALL_COMPLETED, wait from typing import Self -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_columns_are_numeric from safeds.data.labeled.containers import TabularDataset from safeds.exceptions import ( DatasetMissesDataError, FeatureDataMismatchError, - ModelNotFittedError, + NotFittedError, TargetDataMismatchError, ) from safeds.ml.classical.classification import ( @@ -125,7 +125,7 @@ def predict(self, test_data: TabularDataset) -> dict[str, float]: Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet FeatureDataMismatchError If the features of the test data do not match with the features of the trained Classifier. @@ -141,7 +141,7 @@ def predict(self, test_data: TabularDataset) -> dict[str, float]: from safeds.ml.metrics import ClassificationMetrics if not self._is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") # Validate data if not self._feature_names == test_data.features.column_names: diff --git a/src/safeds/ml/classical/classification/_classifier.py b/src/safeds/ml/classical/classification/_classifier.py index b87c5a5af..580b2cbd2 100644 --- a/src/safeds/ml/classical/classification/_classifier.py +++ b/src/safeds/ml/classical/classification/_classifier.py @@ -7,7 +7,7 @@ from joblib._multiprocessing_helpers import mp from safeds.data.labeled.containers import TabularDataset -from safeds.exceptions import DatasetMissesDataError, LearningError, ModelNotFittedError +from safeds.exceptions import DatasetMissesDataError, LearningError, NotFittedError from safeds.ml.classical import SupervisedModel from safeds.ml.metrics import ClassificationMetrics, ClassifierMetric @@ -34,6 +34,11 @@ def summarize_metrics( **Note:** The model must be fitted. + !!! warning "API Stability" + + Do not rely on the exact output of this method. In future versions, we may change the displayed metrics + without prior notice. + Parameters ---------- validation_or_test_set: @@ -48,11 +53,11 @@ def summarize_metrics( Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -83,11 +88,11 @@ def accuracy(self, validation_or_test_set: Table | TabularDataset) -> float: Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -123,11 +128,11 @@ def f1_score( Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -164,11 +169,11 @@ def precision( Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -201,11 +206,11 @@ def recall(self, validation_or_test_set: Table | TabularDataset, positive_class: Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -263,11 +268,11 @@ def fit_by_exhaustive_search( [train_split, test_split] = training_set.to_table().split_rows(0.75) train_data = train_split.to_tabular_dataset( - target_name=training_set.target.name, + training_set.target.name, extra_names=training_set.extras.column_names, ) test_data = test_split.to_tabular_dataset( - target_name=training_set.target.name, + training_set.target.name, extra_names=training_set.extras.column_names, ) diff --git a/src/safeds/ml/classical/classification/_decision_tree_classifier.py b/src/safeds/ml/classical/classification/_decision_tree_classifier.py index 90fb3e8c2..18261a475 100644 --- a/src/safeds/ml/classical/classification/_decision_tree_classifier.py +++ b/src/safeds/ml/classical/classification/_decision_tree_classifier.py @@ -4,8 +4,7 @@ from safeds._utils import _structural_hash from safeds.data.image.containers import Image -from safeds.exceptions import FittingWithChoiceError, FittingWithoutChoiceError -from safeds.exceptions._ml import ModelNotFittedError +from safeds.exceptions import FittingWithChoiceError, FittingWithoutChoiceError, NotFittedError from safeds.ml.classical._bases import _DecisionTreeBase from safeds.ml.hyperparameters import Choice @@ -113,11 +112,11 @@ def plot(self) -> Image: Raises ------ - ModelNotFittedError: + NotFittedError: If model is not fitted. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") from io import BytesIO diff --git a/src/safeds/ml/classical/classification/_support_vector_classifier.py b/src/safeds/ml/classical/classification/_support_vector_classifier.py index 03895ce87..fb9e326eb 100644 --- a/src/safeds/ml/classical/classification/_support_vector_classifier.py +++ b/src/safeds/ml/classical/classification/_support_vector_classifier.py @@ -37,7 +37,7 @@ def __init__( self, *, c: float | Choice[float] = 1.0, - kernel: SupportVectorClassifier.Kernel | None | Choice[SupportVectorClassifier.Kernel | None] = None, + kernel: SupportVectorClassifier.Kernel | Choice[SupportVectorClassifier.Kernel] | None = None, ) -> None: # Initialize superclasses Classifier.__init__(self) @@ -58,7 +58,7 @@ def __hash__(self) -> int: # ------------------------------------------------------------------------------------------------------------------ @property - def kernel(self) -> SupportVectorClassifier.Kernel | Choice[SupportVectorClassifier.Kernel | None]: + def kernel(self) -> SupportVectorClassifier.Kernel | Choice[SupportVectorClassifier.Kernel]: """The type of kernel used.""" return self._kernel diff --git a/src/safeds/ml/classical/regression/_arima.py b/src/safeds/ml/classical/regression/_arima.py index 01163250d..d5fbd0e51 100644 --- a/src/safeds/ml/classical/regression/_arima.py +++ b/src/safeds/ml/classical/regression/_arima.py @@ -10,8 +10,8 @@ from safeds.exceptions import ( DatasetMissesDataError, MissingValuesColumnError, - ModelNotFittedError, NonNumericColumnError, + NotFittedError, ) if TYPE_CHECKING: @@ -126,7 +126,7 @@ def predict(self, time_series: TimeSeriesDataset) -> Table: Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet. IndexError If the forecast horizon is not greater than zero. @@ -139,7 +139,7 @@ def predict(self, time_series: TimeSeriesDataset) -> Table: result_table = result_table.remove_columns([time_series.target.name], ignore_unknown_names=True) # Validation if not self.is_fitted or self._arima is None: - raise ModelNotFittedError + raise NotFittedError(kind="model") # forecast # couldn't invoke prediction error, will be added when found @@ -165,7 +165,7 @@ def plot_predictions(self, test_series: TimeSeriesDataset) -> Image: Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet. PredictionError If predicting with the given dataset failed. @@ -174,7 +174,7 @@ def plot_predictions(self, test_series: TimeSeriesDataset) -> Image: import matplotlib.pyplot as plt if not self.is_fitted or self._arima is None: - raise ModelNotFittedError + raise NotFittedError(kind="model") test_data = test_series.target._series.to_numpy() n_steps = len(test_data) forecast_results = self._arima.forecast(steps=n_steps) diff --git a/src/safeds/ml/classical/regression/_baseline_regressor.py b/src/safeds/ml/classical/regression/_baseline_regressor.py index 79046c3cc..b8b352ae8 100644 --- a/src/safeds/ml/classical/regression/_baseline_regressor.py +++ b/src/safeds/ml/classical/regression/_baseline_regressor.py @@ -3,12 +3,12 @@ from concurrent.futures import ALL_COMPLETED, wait from typing import Self -from safeds._validation._check_columns_are_numeric import _check_columns_are_numeric +from safeds._validation import _check_columns_are_numeric from safeds.data.labeled.containers import TabularDataset from safeds.exceptions import ( DatasetMissesDataError, FeatureDataMismatchError, - ModelNotFittedError, + NotFittedError, TargetDataMismatchError, ) from safeds.ml.classical.regression import ( @@ -135,7 +135,7 @@ def predict(self, test_data: TabularDataset) -> dict[str, float]: Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet FeatureDataMismatchError If the features of the test data do not match with the features of the trained Regressor. @@ -151,7 +151,7 @@ def predict(self, test_data: TabularDataset) -> dict[str, float]: from safeds.ml.metrics import RegressionMetrics if not self._is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") # Validate data if not self._feature_names == test_data.features.column_names: diff --git a/src/safeds/ml/classical/regression/_decision_tree_regressor.py b/src/safeds/ml/classical/regression/_decision_tree_regressor.py index 96d3a7735..a13189c56 100644 --- a/src/safeds/ml/classical/regression/_decision_tree_regressor.py +++ b/src/safeds/ml/classical/regression/_decision_tree_regressor.py @@ -4,8 +4,7 @@ from safeds._utils import _structural_hash from safeds.data.image.containers import Image -from safeds.exceptions import FittingWithChoiceError, FittingWithoutChoiceError -from safeds.exceptions._ml import ModelNotFittedError +from safeds.exceptions import FittingWithChoiceError, FittingWithoutChoiceError, NotFittedError from safeds.ml.classical._bases import _DecisionTreeBase from safeds.ml.hyperparameters import Choice @@ -113,11 +112,11 @@ def plot(self) -> Image: Raises ------ - ModelNotFittedError: + NotFittedError: If model is not fitted. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") from io import BytesIO diff --git a/src/safeds/ml/classical/regression/_regressor.py b/src/safeds/ml/classical/regression/_regressor.py index b74795002..ee2c9999c 100644 --- a/src/safeds/ml/classical/regression/_regressor.py +++ b/src/safeds/ml/classical/regression/_regressor.py @@ -8,9 +8,9 @@ from safeds.data.labeled.containers import TabularDataset from safeds.exceptions import ( - ColumnLengthMismatchError, DatasetMissesDataError, - ModelNotFittedError, + LengthMismatchError, + NotFittedError, ) from safeds.ml.classical import SupervisedModel from safeds.ml.metrics import RegressionMetrics, RegressorMetric @@ -32,6 +32,11 @@ def summarize_metrics(self, validation_or_test_set: Table | TabularDataset) -> T **Note:** The model must be fitted. + !!! warning "API Stability" + + Do not rely on the exact output of this method. In future versions, we may change the displayed metrics + without prior notice. + Parameters ---------- validation_or_test_set: @@ -44,11 +49,11 @@ def summarize_metrics(self, validation_or_test_set: Table | TabularDataset) -> T Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -91,11 +96,11 @@ def coefficient_of_determination(self, validation_or_test_set: Table | TabularDa Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -126,11 +131,11 @@ def mean_absolute_error(self, validation_or_test_set: Table | TabularDataset) -> Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -165,11 +170,11 @@ def mean_directional_accuracy(self, validation_or_test_set: Table | TabularDatas Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -203,11 +208,11 @@ def mean_squared_error(self, validation_or_test_set: Table | TabularDataset) -> Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -238,11 +243,11 @@ def median_absolute_deviation(self, validation_or_test_set: Table | TabularDatas Raises ------ - ModelNotFittedError + NotFittedError If the classifier has not been fitted yet. """ if not self.is_fitted: - raise ModelNotFittedError + raise NotFittedError(kind="model") validation_or_test_set = _extract_table(validation_or_test_set) @@ -287,11 +292,11 @@ def fit_by_exhaustive_search(self, training_set: TabularDataset, optimization_me [train_split, test_split] = training_set.to_table().split_rows(0.75) train_data = train_split.to_tabular_dataset( - target_name=training_set.target.name, + training_set.target.name, extra_names=training_set.extras.column_names, ) test_data = test_split.to_tabular_dataset( - target_name=training_set.target.name, + training_set.target.name, extra_names=training_set.extras.column_names, ) @@ -354,7 +359,7 @@ def _check_metrics_preconditions(actual: Column, expected: Column) -> None: # p raise TypeError(f"Column 'expected' is not numerical but {expected.type}.") if actual.row_count != expected.row_count: - raise ColumnLengthMismatchError( + raise LengthMismatchError( "\n".join( [ f"{actual.name}: {actual.row_count}", diff --git a/src/safeds/ml/classical/regression/_support_vector_regressor.py b/src/safeds/ml/classical/regression/_support_vector_regressor.py index 24ab4196b..ebc61d5a2 100644 --- a/src/safeds/ml/classical/regression/_support_vector_regressor.py +++ b/src/safeds/ml/classical/regression/_support_vector_regressor.py @@ -37,7 +37,7 @@ def __init__( self, *, c: float | Choice[float] = 1.0, - kernel: SupportVectorRegressor.Kernel | None | Choice[SupportVectorRegressor.Kernel | None] = None, + kernel: SupportVectorRegressor.Kernel | Choice[SupportVectorRegressor.Kernel] | None = None, ) -> None: # Initialize superclasses Regressor.__init__(self) @@ -58,7 +58,7 @@ def __hash__(self) -> int: # ------------------------------------------------------------------------------------------------------------------ @property - def kernel(self) -> SupportVectorRegressor.Kernel | Choice[SupportVectorRegressor.Kernel | None]: + def kernel(self) -> SupportVectorRegressor.Kernel | Choice[SupportVectorRegressor.Kernel]: """The type of kernel used.""" return self._kernel diff --git a/src/safeds/ml/metrics/__init__.py b/src/safeds/ml/metrics/__init__.py index e430ca7c2..18422e1c0 100644 --- a/src/safeds/ml/metrics/__init__.py +++ b/src/safeds/ml/metrics/__init__.py @@ -21,8 +21,8 @@ ) __all__ = [ - "ClassifierMetric", "ClassificationMetrics", - "RegressorMetric", + "ClassifierMetric", "RegressionMetrics", + "RegressorMetric", ] diff --git a/src/safeds/ml/metrics/_classification_metrics.py b/src/safeds/ml/metrics/_classification_metrics.py index 817eddcf1..a95f060fb 100644 --- a/src/safeds/ml/metrics/_classification_metrics.py +++ b/src/safeds/ml/metrics/_classification_metrics.py @@ -5,7 +5,7 @@ from safeds.data.labeled.containers import TabularDataset, TimeSeriesDataset from safeds.data.tabular.containers import Column, Table -from safeds.exceptions import ColumnLengthMismatchError +from safeds.exceptions import LengthMismatchError class ClassificationMetrics(ABC): @@ -232,4 +232,4 @@ def _check_equal_length(column1: Column, column2: Column) -> None: If the columns have different lengths. """ if column1.row_count != column2.row_count: - ColumnLengthMismatchError("") # TODO: pass list of columns to exception, let it handle the formatting + LengthMismatchError("") # TODO: pass list of columns to exception, let it handle the formatting diff --git a/src/safeds/ml/metrics/_regression_metrics.py b/src/safeds/ml/metrics/_regression_metrics.py index b652206ae..5a76b77d4 100644 --- a/src/safeds/ml/metrics/_regression_metrics.py +++ b/src/safeds/ml/metrics/_regression_metrics.py @@ -4,7 +4,7 @@ from safeds.data.labeled.containers import TabularDataset, TimeSeriesDataset from safeds.data.tabular.containers import Column, Table -from safeds.exceptions import ColumnLengthMismatchError +from safeds.exceptions import LengthMismatchError class RegressionMetrics(ABC): @@ -334,4 +334,4 @@ def _check_equal_length(column1: Column, column2: Column) -> None: If the columns have different lengths. """ if column1.row_count != column2.row_count: - ColumnLengthMismatchError("") # TODO: pass list of columns to exception, let it handle the formatting + LengthMismatchError("") # TODO: pass list of columns to exception, let it handle the formatting diff --git a/src/safeds/ml/nn/_model.py b/src/safeds/ml/nn/_model.py index b9c6a4e74..9b2d4f024 100644 --- a/src/safeds/ml/nn/_model.py +++ b/src/safeds/ml/nn/_model.py @@ -14,7 +14,7 @@ FeatureDataMismatchError, FittingWithChoiceError, InvalidModelStructureError, - ModelNotFittedError, + NotFittedError, ) from safeds.ml.metrics import ClassificationMetrics, RegressionMetrics from safeds.ml.nn.converters import ( @@ -357,11 +357,11 @@ def fit( def _data_split_table(self, data: TabularDataset) -> tuple[TabularDataset, TabularDataset]: [train_split, test_split] = data.to_table().split_rows(0.75) train_data = train_split.to_tabular_dataset( - target_name=data.target.name, + data.target.name, extra_names=data.extras.column_names, ) test_dataset = test_split.to_tabular_dataset( - target_name=train_data.target.name, + train_data.target.name, extra_names=train_data.extras.column_names, ) return train_data, test_dataset @@ -445,10 +445,17 @@ def _get_best_fnn_model( def _data_split_time_series(self, data: TimeSeriesDataset) -> tuple[TimeSeriesDataset, Table]: (train_split, test_split) = data.to_table().split_rows(0.75) - train_data = train_split.to_time_series_dataset( - target_name=data.target.name, + # train_data = train_split.to_time_series_dataset( + # data.target.name, + # window_size=data.window_size, + # extra_names=data.extras.column_names, + # continuous=data.continuous, + # forecast_horizon=data.forecast_horizon, + # ) + train_data = TimeSeriesDataset( + train_split, + data.target.name, window_size=data.window_size, - extra_names=data.extras.column_names, continuous=data.continuous, forecast_horizon=data.forecast_horizon, ) @@ -587,7 +594,7 @@ def predict(self, test_data: IPT) -> IFT: Raises ------ - ModelNotFittedError + NotFittedError If the model has not been fitted yet """ import torch @@ -595,7 +602,7 @@ def predict(self, test_data: IPT) -> IFT: _init_default_device() if not self._is_fitted or self._model is None: - raise ModelNotFittedError + raise NotFittedError(kind="model") if not self._input_conversion._is_predict_data_valid(test_data): raise FeatureDataMismatchError dataloader = self._input_conversion._data_conversion_predict(test_data, self._batch_size) @@ -994,21 +1001,28 @@ def fit( def _data_split_table(self, data: TabularDataset) -> tuple[TabularDataset, TabularDataset]: [train_split, test_split] = data.to_table().split_rows(0.75) train_data = train_split.to_tabular_dataset( - target_name=data.target.name, + data.target.name, extra_names=data.extras.column_names, ) test_data = test_split.to_tabular_dataset( - target_name=train_data.target.name, + train_data.target.name, extra_names=train_data.extras.column_names, ) return (train_data, test_data) def _data_split_time_series(self, data: TimeSeriesDataset) -> tuple[TimeSeriesDataset, Table]: (train_split, test_split) = data.to_table().split_rows(0.75) - train_data = train_split.to_time_series_dataset( - target_name=data.target.name, + # train_data = train_split.to_time_series_dataset( + # data.target.name, + # window_size=data.window_size, + # extra_names=data.extras.column_names, + # continuous=data.continuous, + # forecast_horizon=data.forecast_horizon, + # ) + train_data = TimeSeriesDataset( + train_split, + data.target.name, window_size=data.window_size, - extra_names=data.extras.column_names, continuous=data.continuous, forecast_horizon=data.forecast_horizon, ) @@ -1402,7 +1416,7 @@ def predict(self, test_data: IPT) -> IFT: Raises ------ - ModelNotFittedError + NotFittedError If the Model has not been fitted yet """ import torch @@ -1410,7 +1424,7 @@ def predict(self, test_data: IPT) -> IFT: _init_default_device() if not self._is_fitted or self._model is None: - raise ModelNotFittedError + raise NotFittedError(kind="model") if not self._input_conversion._is_predict_data_valid(test_data): raise FeatureDataMismatchError dataloader = self._input_conversion._data_conversion_predict(test_data, self._batch_size) diff --git a/src/safeds/ml/nn/converters/_input_converter_time_series.py b/src/safeds/ml/nn/converters/_input_converter_time_series.py index 08e166f19..3ee39a06c 100644 --- a/src/safeds/ml/nn/converters/_input_converter_time_series.py +++ b/src/safeds/ml/nn/converters/_input_converter_time_series.py @@ -89,9 +89,16 @@ def _data_conversion_fit( ) def _data_conversion_predict(self, input_data: Table, batch_size: int) -> DataLoader: - data: TimeSeriesDataset - data = input_data.to_time_series_dataset( - target_name=self._target_name, + # data = input_data.to_time_series_dataset( + # self._target_name, + # window_size=self._window_size, + # extra_names=self._extra_names, + # forecast_horizon=self._forecast_horizon, + # continuous=self._continuous, + # ) + data = TimeSeriesDataset( + input_data, + self._target_name, window_size=self._window_size, extra_names=self._extra_names, forecast_horizon=self._forecast_horizon, @@ -114,11 +121,22 @@ def _data_conversion_output( table_data = input_data input_data_table = table_data.slice_rows(start=window_size + forecast_horizon) - return input_data_table.replace_column( + # return input_data_table.replace_column( + # self._target_name, + # [Column(self._target_name, output_data.tolist())], + # ).to_time_series_dataset( + # self._target_name, + # window_size=self._window_size, + # extra_names=self._extra_names, + # forecast_horizon=self._forecast_horizon, + # continuous=self._continuous, + # ) + return TimeSeriesDataset( + input_data_table.replace_column( + self._target_name, + [Column(self._target_name, output_data.tolist())], + ), self._target_name, - [Column(self._target_name, output_data.tolist())], - ).to_time_series_dataset( - target_name=self._target_name, window_size=self._window_size, extra_names=self._extra_names, forecast_horizon=self._forecast_horizon, @@ -145,6 +163,4 @@ def _is_predict_data_valid(self, input_data: Table) -> bool: for name in self._feature_names: if name not in input_data.column_names: return False - if self._target_name not in input_data.column_names: - return False - return True + return self._target_name in input_data.column_names diff --git a/src/safeds/ml/nn/layers/__init__.py b/src/safeds/ml/nn/layers/__init__.py index 8d195dde2..d0a547e4a 100644 --- a/src/safeds/ml/nn/layers/__init__.py +++ b/src/safeds/ml/nn/layers/__init__.py @@ -31,14 +31,14 @@ ) __all__ = [ + "AveragePooling2DLayer", "Convolutional2DLayer", "ConvolutionalTranspose2DLayer", + "DropoutLayer", "FlattenLayer", "ForwardLayer", - "Layer", - "LSTMLayer", "GRULayer", - "AveragePooling2DLayer", + "LSTMLayer", + "Layer", "MaxPooling2DLayer", - "DropoutLayer", ] diff --git a/tests/conftest.py b/tests/conftest.py index 4a7b24e87..100dbc4b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,7 +38,6 @@ def matches( serialized_data: SerializableData, snapshot_data: SerializableData, ) -> bool: - # We decode the byte arrays, since torchvision seems to use different compression methods on different operating # systems, thus leading to different byte arrays for the same image. actual = open_image(io.BytesIO(serialized_data)) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 973be40b1..24b79b286 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,7 +1,7 @@ from ._assertions import ( assert_cell_operation_works, assert_row_operation_works, - assert_tables_equal, + assert_tables_are_equal, assert_that_tabular_datasets_are_equal, ) from ._devices import ( @@ -40,17 +40,17 @@ __all__ = [ "assert_cell_operation_works", "assert_row_operation_works", - "assert_tables_equal", + "assert_tables_are_equal", "assert_that_tabular_datasets_are_equal", "configure_test_with_device", "device_cpu", "device_cuda", + "get_devices", + "get_devices_ids", "grayscale_jpg_id", "grayscale_jpg_path", "grayscale_png_id", "grayscale_png_path", - "get_devices", - "get_devices_ids", "images_all", "images_all_channel", "images_all_channel_ids", diff --git a/tests/helpers/_assertions.py b/tests/helpers/_assertions.py index fd02c6fa6..76358b17e 100644 --- a/tests/helpers/_assertions.py +++ b/tests/helpers/_assertions.py @@ -2,13 +2,14 @@ from typing import Any from polars.testing import assert_frame_equal + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Cell, Column, Table -def assert_tables_equal( - table1: Table, - table2: Table, +def assert_tables_are_equal( + actual: Table, + expected: Table, *, ignore_column_order: bool = False, ignore_row_order: bool = False, @@ -16,26 +17,26 @@ def assert_tables_equal( ignore_float_imprecision: bool = True, ) -> None: """ - Assert that two tables are almost equal. + Assert that two tables are equal. Parameters ---------- - table1: - The first table. - table2: - The table to compare the first table to. + actual: + The actual table. + expected: + The expected table. ignore_column_order: - Ignore the column order when True. Will return true, even when the column order is different. + Ignore the column order when True. ignore_row_order: - Ignore the column order when True. Will return true, even when the row order is different. + Ignore the column order when True. ignore_types: - Ignore differing data Types. Will return true, even when columns have differing data types. + Ignore differing data types. ignore_float_imprecision: If False, check if floating point values match EXACTLY. """ assert_frame_equal( - table1._data_frame, - table2._data_frame, + actual._data_frame, + expected._data_frame, check_row_order=not ignore_row_order, check_column_order=not ignore_column_order, check_dtypes=not ignore_types, diff --git a/tests/helpers/_devices.py b/tests/helpers/_devices.py index 54b043e0e..3c4d4ce9a 100644 --- a/tests/helpers/_devices.py +++ b/tests/helpers/_devices.py @@ -1,8 +1,9 @@ import pytest import torch -from safeds._config import _init_default_device, _set_default_device from torch.types import Device +from safeds._config import _init_default_device, _set_default_device + _init_default_device() device_cpu = torch.device("cpu") diff --git a/tests/helpers/_resources.py b/tests/helpers/_resources.py index e1c30b808..f4587b470 100644 --- a/tests/helpers/_resources.py +++ b/tests/helpers/_resources.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pathlib import Path from typing import overload diff --git a/tests/resources/emptytable.csv b/tests/resources/csv/empty.csv similarity index 100% rename from tests/resources/emptytable.csv rename to tests/resources/csv/empty.csv diff --git a/tests/resources/csv/non-empty.csv b/tests/resources/csv/non-empty.csv new file mode 100644 index 000000000..a3672abe3 --- /dev/null +++ b/tests/resources/csv/non-empty.csv @@ -0,0 +1,2 @@ +A,B +1,2 diff --git a/tests/resources/table.csv b/tests/resources/csv/special-character.csv similarity index 100% rename from tests/resources/table.csv rename to tests/resources/csv/special-character.csv diff --git a/tests/resources/dummy_excel_file.xlsx b/tests/resources/dummy_excel_file.xlsx deleted file mode 100644 index bf39ce52bc11374dd29b22cb267e2afb21498381..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4927 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 diff --git a/tests/resources/emptytable.json b/tests/resources/emptytable.json deleted file mode 100644 index 0967ef424..000000000 --- a/tests/resources/emptytable.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/tests/resources/json/empty.json b/tests/resources/json/empty.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/resources/json/empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/resources/json/non-empty.json b/tests/resources/json/non-empty.json new file mode 100644 index 000000000..0a99ca9a0 --- /dev/null +++ b/tests/resources/json/non-empty.json @@ -0,0 +1,6 @@ +[ + { + "A": 1, + "B": 2 + } +] diff --git a/tests/resources/table.json b/tests/resources/json/special-character.json similarity index 100% rename from tests/resources/table.json rename to tests/resources/json/special-character.json diff --git a/tests/resources/parquet/empty.parquet b/tests/resources/parquet/empty.parquet new file mode 100644 index 0000000000000000000000000000000000000000..af10173c5bff93e8800ea46b0ff46770927f830b GIT binary patch literal 135 zcmWG=3^EjDl9cgaDay|;5oKTyW02&Ll#$?Z3<~lOw<=D~NX<=@@X-f>Dn}4-1rZiy zKz>Caln)m2c62Oubar$E(t(cJ0YLfka*({IqhmT$9f)$YWsqPC$j?bEDwgD7sAgbb I2mm?(0G}QqAOHXW literal 0 HcmV?d00001 diff --git a/tests/resources/parquet/non-empty.parquet b/tests/resources/parquet/non-empty.parquet new file mode 100644 index 0000000000000000000000000000000000000000..1bc948e353f14fe82d1640e0d10702a2561ffa25 GIT binary patch literal 844 zcmb7Du}T9$5S?2u;ffRyGRrMoA%Y=bAPQ1ZB8m>j&dk1fZ{HqlO!ZlGF^`iws1N{3kCo2}j{qEQBoG8G zGn&UNW`hMNc`4=(Djpm#6*tO(F?7v5Wb?*%2y4qa9SpCpx3d(b6^7fs`@1=v$E-4v|1f> z0o^IZKPAZ_Xk`LZE#N;d1-v8a2b&fb9Sr#w3zp5p@-P$H<(;5-y8coNHce{db5jB%JE-BoV zCX{9Gnuivxs2~*DxlqPgabI*e*Au?w5v|WKsbWv=TVd*{=y4VwAzL7-%K6t;&n_FX zNs(YR3b$mQb0mH_tWTo&fJsYshf$6utyv2!A$=4P_}RF1*0^Y!asa>|yzz60`5k=$ D&?bPi literal 0 HcmV?d00001 diff --git a/tests/resources/parquet/special-character.parquet b/tests/resources/parquet/special-character.parquet new file mode 100644 index 0000000000000000000000000000000000000000..25a441cf7e49eca7a39ff82e9403fca588badede GIT binary patch literal 791 zcmb7DPfHs?6n`_jY;y>bLf)_gdkBI{XalKWjY=rIsfz}cY*I?E4N@$$C6I`=9((Sw zC((;1kG=RAgno=3Joy1iLGitDlcvx^JBK&(-uwM|v%}`pU&IjSu(pjF0ib@-ys14{ z1qe9#arzYjD>&caW(6aRl4Ym|T#hA7;3WdkoM;6BmhDrBDmGSxWuwi3IaX<@{U?nb zS#;KMBEc%~7t4~!%ZjHbPW-GBrZ*2K=FXdcQRbUUua($OiL(;+p|<`i_YuJw0AHNK z?QeY1PK?9Xj02mPbB+6QL~Yxf;b=JA#}Kf*p!xm_u$@RNl+1zAHnENLOeO#Hjm%Yu zRdXBQBS=qz1poJH=2wWuoQL=f%x7(MS~A~tRfdks)bDp+t&MlzzWFeSf2yXeR)=_H z5>^g4|JW;XU)mxX({m!;d-OQx{^6nYnY35ngzt7FaT2;m3|d)|qgQqw=LF7*FGPp; ze!=%VqV*?CYI^7UQJDHp^mvy!9FIWM68}%$--~WYk+PQ@dgMBS=RK2od3hXB?&o0< cRu_XTBZ#AX_ None: + assert _get_similar_column_names(table.schema, name) == expected diff --git a/tests/safeds/data/image/typing/test_image_size.py b/tests/safeds/data/image/typing/test_image_size.py index f8622c2de..dd51a5ede 100644 --- a/tests/safeds/data/image/typing/test_image_size.py +++ b/tests/safeds/data/image/typing/test_image_size.py @@ -2,11 +2,11 @@ from typing import Any import pytest +from torch.types import Device + from safeds.data.image.containers import Image from safeds.data.image.typing import ImageSize from safeds.exceptions import OutOfBoundsError -from torch.types import Device - from tests.helpers import ( configure_test_with_device, get_devices, diff --git a/tests/safeds/data/labeled/containers/_tabular_dataset/test_extras.py b/tests/safeds/data/labeled/containers/_tabular_dataset/test_extras.py index 1c380af59..a8b6ae192 100644 --- a/tests/safeds/data/labeled/containers/_tabular_dataset/test_extras.py +++ b/tests/safeds/data/labeled/containers/_tabular_dataset/test_extras.py @@ -15,7 +15,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", ), Table({}), ), @@ -27,7 +27,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", extra_names=["A", "C"], ), Table({"A": [1, 4], "C": [3, 6]}), diff --git a/tests/safeds/data/labeled/containers/_tabular_dataset/test_features.py b/tests/safeds/data/labeled/containers/_tabular_dataset/test_features.py index 446664c36..73a3e8c96 100644 --- a/tests/safeds/data/labeled/containers/_tabular_dataset/test_features.py +++ b/tests/safeds/data/labeled/containers/_tabular_dataset/test_features.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table @@ -14,7 +15,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", ), Table({"A": [1, 4], "B": [2, 5], "C": [3, 6]}), ), @@ -26,7 +27,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", extra_names=["B"], ), Table({"A": [1, 4], "C": [3, 6]}), diff --git a/tests/safeds/data/labeled/containers/_tabular_dataset/test_hash.py b/tests/safeds/data/labeled/containers/_tabular_dataset/test_hash.py index c40d6c43a..7e7f580dd 100644 --- a/tests/safeds/data/labeled/containers/_tabular_dataset/test_hash.py +++ b/tests/safeds/data/labeled/containers/_tabular_dataset/test_hash.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset diff --git a/tests/safeds/data/labeled/containers/_tabular_dataset/test_init.py b/tests/safeds/data/labeled/containers/_tabular_dataset/test_init.py index 93d5f6681..52ada3350 100644 --- a/tests/safeds/data/labeled/containers/_tabular_dataset/test_init.py +++ b/tests/safeds/data/labeled/containers/_tabular_dataset/test_init.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import ColumnNotFoundError @@ -153,7 +154,7 @@ def test_should_raise_error( error_msg: str | None, ) -> None: with pytest.raises(error, match=error_msg): - TabularDataset(data, target_name=target_name, extra_names=extra_names) + TabularDataset(data, target_name, extra_names=extra_names) @pytest.mark.parametrize( @@ -240,7 +241,7 @@ def test_should_create_a_tabular_dataset( target_name: str, extra_names: list[str] | None, ) -> None: - tabular_dataset = TabularDataset(data, target_name=target_name, extra_names=extra_names) + tabular_dataset = TabularDataset(data, target_name, extra_names=extra_names) if not isinstance(data, Table): data = Table(data) @@ -250,5 +251,5 @@ def test_should_create_a_tabular_dataset( assert isinstance(tabular_dataset, TabularDataset) assert tabular_dataset._extras.column_names == extra_names assert tabular_dataset._target.name == target_name - assert tabular_dataset._extras == data.remove_columns_except(extra_names) + assert tabular_dataset._extras == data.select_columns(extra_names) assert tabular_dataset._target == data.get_column(target_name) diff --git a/tests/safeds/data/labeled/containers/_tabular_dataset/test_into_dataloader.py b/tests/safeds/data/labeled/containers/_tabular_dataset/test_into_dataloader.py index 43329ec8f..d99eb7734 100644 --- a/tests/safeds/data/labeled/containers/_tabular_dataset/test_into_dataloader.py +++ b/tests/safeds/data/labeled/containers/_tabular_dataset/test_into_dataloader.py @@ -1,9 +1,9 @@ import pytest -from safeds._config import _get_device -from safeds.data.tabular.containers import Table from torch.types import Device from torch.utils.data import DataLoader +from safeds._config import _get_device +from safeds.data.tabular.containers import Table from tests.helpers import configure_test_with_device, get_devices, get_devices_ids diff --git a/tests/safeds/data/labeled/containers/_tabular_dataset/test_repr_html.py b/tests/safeds/data/labeled/containers/_tabular_dataset/test_repr_html.py index f369c7c04..64c233d4d 100644 --- a/tests/safeds/data/labeled/containers/_tabular_dataset/test_repr_html.py +++ b/tests/safeds/data/labeled/containers/_tabular_dataset/test_repr_html.py @@ -1,13 +1,14 @@ import re import pytest + from safeds.data.labeled.containers import TabularDataset @pytest.mark.parametrize( "tabular_dataset", [ - TabularDataset({"a": [1, 2], "b": [3, 4]}, target_name="b"), + TabularDataset({"a": [1, 2], "b": [3, 4]}, "b"), ], ids=[ "non-empty", @@ -21,7 +22,7 @@ def test_should_contain_tabular_dataset_element(tabular_dataset: TabularDataset) @pytest.mark.parametrize( "tabular_dataset", [ - TabularDataset({"a": [1, 2], "b": [3, 4]}, target_name="b"), + TabularDataset({"a": [1, 2], "b": [3, 4]}, "b"), ], ids=[ "non-empty", @@ -35,7 +36,7 @@ def test_should_contain_th_element_for_each_column_name(tabular_dataset: Tabular @pytest.mark.parametrize( "tabular_dataset", [ - TabularDataset({"a": [1, 2], "b": [3, 4]}, target_name="b"), + TabularDataset({"a": [1, 2], "b": [3, 4]}, "b"), ], ids=[ "non-empty", diff --git a/tests/safeds/data/labeled/containers/_tabular_dataset/test_sizeof.py b/tests/safeds/data/labeled/containers/_tabular_dataset/test_sizeof.py index 1edcc1163..2f36c9461 100644 --- a/tests/safeds/data/labeled/containers/_tabular_dataset/test_sizeof.py +++ b/tests/safeds/data/labeled/containers/_tabular_dataset/test_sizeof.py @@ -1,6 +1,7 @@ import sys import pytest + from safeds.data.labeled.containers import TabularDataset diff --git a/tests/safeds/data/labeled/containers/_tabular_dataset/test_target.py b/tests/safeds/data/labeled/containers/_tabular_dataset/test_target.py index e7b49ac93..d6bb24887 100644 --- a/tests/safeds/data/labeled/containers/_tabular_dataset/test_target.py +++ b/tests/safeds/data/labeled/containers/_tabular_dataset/test_target.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Column @@ -14,7 +15,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", ), Column("T", [0, 1]), ), diff --git a/tests/safeds/data/labeled/containers/_tabular_dataset/test_to_table.py b/tests/safeds/data/labeled/containers/_tabular_dataset/test_to_table.py index 17cb64b3c..f96527b57 100644 --- a/tests/safeds/data/labeled/containers/_tabular_dataset/test_to_table.py +++ b/tests/safeds/data/labeled/containers/_tabular_dataset/test_to_table.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table @@ -47,6 +48,5 @@ ids=["normal", "table_with_extra_column"], ) def test_should_return_table(tabular_dataset: TabularDataset, expected: Table) -> None: - table = tabular_dataset.to_table() - assert table.schema == expected.schema - assert table == expected + actual = tabular_dataset.to_table() + assert actual == expected diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_continuous.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_continuous.py index eae4dd23e..3a6892b67 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_continuous.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_continuous.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TimeSeriesDataset @@ -13,7 +14,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", window_size=1, continuous=True, ), @@ -27,7 +28,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", window_size=1, extra_names=["A", "C"], continuous=False, diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_extras.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_extras.py index 3aae37aaa..ad36e7661 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_extras.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_extras.py @@ -15,7 +15,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", window_size=1, ), Table({}), @@ -28,7 +28,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", window_size=1, extra_names=["A", "C"], ), diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_features.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_features.py index b36270725..59955c574 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_features.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_features.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TimeSeriesDataset from safeds.data.tabular.containers import Table @@ -14,7 +15,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", extra_names=["C"], window_size=1, ), @@ -29,7 +30,7 @@ "T": [0, 1], "time": [0, 0], }, - target_name="T", + "T", window_size=1, extra_names=["B"], ), diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_hash.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_hash.py index 546c6b532..7143a3c82 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_hash.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_hash.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TimeSeriesDataset diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_init.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_init.py index 43700bb6b..f9aeff8ba 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_init.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_init.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TimeSeriesDataset from safeds.data.tabular.containers import Table from safeds.exceptions import ColumnNotFoundError @@ -109,7 +110,7 @@ def test_should_raise_error( error_msg: str | None, ) -> None: with pytest.raises(error, match=error_msg): - TimeSeriesDataset(data, target_name=target_name, window_size=1, extra_names=extra_names) + TimeSeriesDataset(data, target_name, window_size=1, extra_names=extra_names) @pytest.mark.parametrize( @@ -204,7 +205,7 @@ def test_should_create_a_tabular_dataset( ) -> None: tabular_dataset = TimeSeriesDataset( data, - target_name=target_name, + target_name, window_size=1, extra_names=extra_names, ) @@ -217,5 +218,5 @@ def test_should_create_a_tabular_dataset( assert isinstance(tabular_dataset, TimeSeriesDataset) assert tabular_dataset._extras.column_names == extra_names assert tabular_dataset._target.name == target_name - assert tabular_dataset._extras == data.remove_columns_except(extra_names) + assert tabular_dataset._extras == data.select_columns(extra_names) assert tabular_dataset._target == data.get_column(target_name) diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_into_dataloader.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_into_dataloader.py index b430f4a7a..1c4e03743 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_into_dataloader.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_into_dataloader.py @@ -1,12 +1,12 @@ import pytest import torch +from torch.types import Device +from torch.utils.data import DataLoader + from safeds._config import _get_device from safeds.data.labeled.containers import TimeSeriesDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError -from torch.types import Device -from torch.utils.data import DataLoader - from tests.helpers import configure_test_with_device, get_devices, get_devices_ids @@ -36,7 +36,13 @@ def test_should_create_dataloader( device: Device, ) -> None: configure_test_with_device(device) - tabular_dataset = Table.from_dict(data).to_time_series_dataset( + # tabular_dataset = Table.from_dict(data).to_time_series_dataset( + # target_name, + # window_size=1, + # extra_names=extra_names, + # ) + tabular_dataset = TimeSeriesDataset( + Table.from_dict(data), target_name, window_size=1, extra_names=extra_names, @@ -74,7 +80,13 @@ def test_should_create_dataloader_predict( device: Device, ) -> None: configure_test_with_device(device) - tabular_dataset = Table.from_dict(data).to_time_series_dataset( + # tabular_dataset = Table.from_dict(data).to_time_series_dataset( + # target_name, + # window_size=1, + # extra_names=extra_names, + # ) + tabular_dataset = TimeSeriesDataset( + Table.from_dict(data), target_name, window_size=1, extra_names=extra_names, @@ -89,42 +101,78 @@ def test_should_create_dataloader_predict( ("data", "window_size", "forecast_horizon", "error_type", "error_msg"), [ ( - Table( - { - "A": [1, 4], - "B": [2, 5], - "C": [3, 6], - "T": [0, 1], - }, - ).to_time_series_dataset(target_name="T", window_size=1), + # Table( + # { + # "A": [1, 4], + # "B": [2, 5], + # "C": [3, 6], + # "T": [0, 1], + # }, + # ).to_time_series_dataset("T", window_size=1), + TimeSeriesDataset( + Table( + { + "A": [1, 4], + "B": [2, 5], + "C": [3, 6], + "T": [0, 1], + }, + ), + "T", + window_size=1, + ), 1, 2, ValueError, r"Can not create windows with window size less then forecast horizon \+ window_size", ), ( - Table( - { - "A": [1, 4], - "B": [2, 5], - "C": [3, 6], - "T": [0, 1], - }, - ).to_time_series_dataset(target_name="T", window_size=1), + # Table( + # { + # "A": [1, 4], + # "B": [2, 5], + # "C": [3, 6], + # "T": [0, 1], + # }, + # ).to_time_series_dataset("T", window_size=1), + TimeSeriesDataset( + Table( + { + "A": [1, 4], + "B": [2, 5], + "C": [3, 6], + "T": [0, 1], + }, + ), + "T", + window_size=1, + ), 1, 0, OutOfBoundsError, None, ), ( - Table( - { - "A": [1, 4], - "B": [2, 5], - "C": [3, 6], - "T": [0, 1], - }, - ).to_time_series_dataset(target_name="T", window_size=1), + # Table( + # { + # "A": [1, 4], + # "B": [2, 5], + # "C": [3, 6], + # "T": [0, 1], + # }, + # ).to_time_series_dataset("T", window_size=1), + TimeSeriesDataset( + Table( + { + "A": [1, 4], + "B": [2, 5], + "C": [3, 6], + "T": [0, 1], + }, + ), + "T", + window_size=1, + ), 0, 1, OutOfBoundsError, @@ -155,43 +203,79 @@ def test_should_create_dataloader_invalid( ("data", "window_size", "forecast_horizon", "error_type", "error_msg"), [ ( - Table( - { - "A": [1, 4], - "B": [2, 5], - "C": [3, 6], - "T": [0, 1], - }, - ).to_time_series_dataset(target_name="T", window_size=1), + # Table( + # { + # "A": [1, 4], + # "B": [2, 5], + # "C": [3, 6], + # "T": [0, 1], + # }, + # ).to_time_series_dataset("T", window_size=1), + TimeSeriesDataset( + Table( + { + "A": [1, 4], + "B": [2, 5], + "C": [3, 6], + "T": [0, 1], + }, + ), + "T", + window_size=1, + ), 1, 2, ValueError, r"Can not create windows with window size less then forecast horizon \+ window_size", ), ( - Table( - { - "A": [1, 4], - "B": [2, 5], - "C": [3, 6], - "T": [0, 1], - }, - ).to_time_series_dataset(target_name="T", window_size=1), + # Table( + # { + # "A": [1, 4], + # "B": [2, 5], + # "C": [3, 6], + # "T": [0, 1], + # }, + # ).to_time_series_dataset("T", window_size=1), + TimeSeriesDataset( + Table( + { + "A": [1, 4], + "B": [2, 5], + "C": [3, 6], + "T": [0, 1], + }, + ), + "T", + window_size=1, + ), 1, 0, OutOfBoundsError, None, ), ( - Table( - { - "A": [1, 4], - "B": [2, 5], - "C": [3, 6], - "T": [0, 1], - }, - ).to_time_series_dataset( - target_name="T", + # Table( + # { + # "A": [1, 4], + # "B": [2, 5], + # "C": [3, 6], + # "T": [0, 1], + # }, + # ).to_time_series_dataset( + # "T", + # window_size=1, + # ), + TimeSeriesDataset( + Table( + { + "A": [1, 4], + "B": [2, 5], + "C": [3, 6], + "T": [0, 1], + }, + ), + "T", window_size=1, ), 0, @@ -225,9 +309,17 @@ def test_should_create_dataloader_predict_invalid( def test_continues_dataloader() -> None: - ts = Table( - {"a": [1, 2, 3, 4, 5, 6, 7], "b": [1, 2, 3, 4, 5, 6, 7], "c": [1, 2, 3, 4, 5, 6, 7]}, - ).to_time_series_dataset("a", window_size=1, forecast_horizon=2) + # ts = Table( + # {"a": [1, 2, 3, 4, 5, 6, 7], "b": [1, 2, 3, 4, 5, 6, 7], "c": [1, 2, 3, 4, 5, 6, 7]}, + # ).to_time_series_dataset("a", window_size=1, forecast_horizon=2) + ts = TimeSeriesDataset( + Table( + {"a": [1, 2, 3, 4, 5, 6, 7], "b": [1, 2, 3, 4, 5, 6, 7], "c": [1, 2, 3, 4, 5, 6, 7]}, + ), + "a", + window_size=1, + forecast_horizon=2, + ) dl = ts._into_dataloader_with_window(1, 2, 1, continuous=True) dl_2 = ts._into_dataloader_with_window(1, 2, 1, continuous=False) assert len(dl_2.dataset.Y) == len(dl.dataset.Y) diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_repr_html.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_repr_html.py index 2e589b00b..950ffb93a 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_repr_html.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_repr_html.py @@ -1,13 +1,14 @@ import re import pytest + from safeds.data.labeled.containers import TimeSeriesDataset @pytest.mark.parametrize( "tabular_dataset", [ - TimeSeriesDataset({"a": [1, 2], "b": [3, 4]}, target_name="b", window_size=1), + TimeSeriesDataset({"a": [1, 2], "b": [3, 4]}, "b", window_size=1), ], ids=[ "non-empty", @@ -21,7 +22,7 @@ def test_should_contain_tabular_dataset_element(tabular_dataset: TimeSeriesDatas @pytest.mark.parametrize( "tabular_dataset", [ - TimeSeriesDataset({"a": [1, 2], "b": [3, 4]}, target_name="b", window_size=1), + TimeSeriesDataset({"a": [1, 2], "b": [3, 4]}, "b", window_size=1), ], ids=[ "non-empty", @@ -35,7 +36,7 @@ def test_should_contain_th_element_for_each_column_name(tabular_dataset: TimeSer @pytest.mark.parametrize( "tabular_dataset", [ - TimeSeriesDataset({"a": [1, 2], "b": [3, 4]}, target_name="b", window_size=1), + TimeSeriesDataset({"a": [1, 2], "b": [3, 4]}, "b", window_size=1), ], ids=[ "non-empty", diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_sizeof.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_sizeof.py index da73db266..598c3ba7b 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_sizeof.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_sizeof.py @@ -1,6 +1,7 @@ import sys import pytest + from safeds.data.labeled.containers import TimeSeriesDataset diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_target.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_target.py index fe840c2b0..469d7cc81 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_target.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_target.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TimeSeriesDataset from safeds.data.tabular.containers import Column @@ -14,7 +15,7 @@ "C": [3, 6], "T": [0, 1], }, - target_name="T", + "T", window_size=1, ), Column("T", [0, 1]), diff --git a/tests/safeds/data/labeled/containers/_time_series_dataset/test_to_table.py b/tests/safeds/data/labeled/containers/_time_series_dataset/test_to_table.py index b33734595..e03240cdd 100644 --- a/tests/safeds/data/labeled/containers/_time_series_dataset/test_to_table.py +++ b/tests/safeds/data/labeled/containers/_time_series_dataset/test_to_table.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TimeSeriesDataset from safeds.data.tabular.containers import Table @@ -13,7 +14,7 @@ "feature_2": [6, 12, 9], "target": [1, 3, 2], }, - target_name="target", + "target", window_size=1, ), Table( @@ -32,7 +33,7 @@ "other": [3, 9, 12], "target": [1, 3, 2], }, - target_name="target", + "target", window_size=1, extra_names=["other"], ), @@ -49,6 +50,5 @@ ids=["normal", "table_with_extra_column"], ) def test_should_return_table(tabular_dataset: TimeSeriesDataset, expected: Table) -> None: - table = tabular_dataset.to_table() - assert table.schema == expected.schema - assert table == expected + actual = tabular_dataset.to_table() + assert actual == expected diff --git a/tests/safeds/data/labeled/containers/test_image_dataset.py b/tests/safeds/data/labeled/containers/test_image_dataset.py index c38b5fa0b..c3d70223b 100644 --- a/tests/safeds/data/labeled/containers/test_image_dataset.py +++ b/tests/safeds/data/labeled/containers/test_image_dataset.py @@ -19,9 +19,9 @@ from safeds.exceptions import ( IndexOutOfBoundsError, NonNumericColumnError, + NotFittedError, OutOfBoundsError, OutputLengthMismatchError, - TransformerNotFittedError, ) from tests.helpers import ( configure_test_with_device, @@ -384,7 +384,6 @@ def test_get_batch_device(self, device: Device) -> None: @pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) @pytest.mark.parametrize("shuffle", [True, False]) class TestSplit: - @pytest.mark.parametrize( "output", [ @@ -530,7 +529,7 @@ class TestColumnAsTensor: ValueError, r"Tensor has an invalid amount of dimensions. Needed 2 dimensions but got 3.", ), - (torch.randn(10, 10), OneHotEncoder(), TransformerNotFittedError, r""), + (torch.randn(10, 10), OneHotEncoder(), NotFittedError, r""), ( torch.randn(10, 10), OneHotEncoder().fit(Table({"b": ["a", "b", "c"]})), diff --git a/tests/safeds/data/tabular/containers/_cell/test_add.py b/tests/safeds/data/tabular/containers/_cell/test_add.py index 429653cf3..9a2d59a39 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_add.py +++ b/tests/safeds/data/tabular/containers/_cell/test_add.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_and.py b/tests/safeds/data/tabular/containers/_cell/test_and.py index 23fddc3bc..ffbe3786f 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_and.py +++ b/tests/safeds/data/tabular/containers/_cell/test_and.py @@ -2,8 +2,8 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_div.py b/tests/safeds/data/tabular/containers/_cell/test_div.py index 80260de30..1aa8ab692 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_div.py +++ b/tests/safeds/data/tabular/containers/_cell/test_div.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_eq.py b/tests/safeds/data/tabular/containers/_cell/test_eq.py index 97c60a3d6..0cd8e0b02 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_eq.py +++ b/tests/safeds/data/tabular/containers/_cell/test_eq.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_first_not_none.py b/tests/safeds/data/tabular/containers/_cell/test_first_not_none.py index de68cc994..91f38f4b5 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_first_not_none.py +++ b/tests/safeds/data/tabular/containers/_cell/test_first_not_none.py @@ -2,6 +2,7 @@ import polars as pl import pytest + from safeds.data.tabular.containers._cell import Cell from safeds.data.tabular.containers._lazy_cell import _LazyCell diff --git a/tests/safeds/data/tabular/containers/_cell/test_floordiv.py b/tests/safeds/data/tabular/containers/_cell/test_floordiv.py index 3296a3f97..e2b2316bb 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_floordiv.py +++ b/tests/safeds/data/tabular/containers/_cell/test_floordiv.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_ge.py b/tests/safeds/data/tabular/containers/_cell/test_ge.py index e1406f027..2269066c7 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_ge.py +++ b/tests/safeds/data/tabular/containers/_cell/test_ge.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_gt.py b/tests/safeds/data/tabular/containers/_cell/test_gt.py index 2d7bef1b4..4c39978d3 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_gt.py +++ b/tests/safeds/data/tabular/containers/_cell/test_gt.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_le.py b/tests/safeds/data/tabular/containers/_cell/test_le.py index 0880eec3f..2f4e43806 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_le.py +++ b/tests/safeds/data/tabular/containers/_cell/test_le.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_lt.py b/tests/safeds/data/tabular/containers/_cell/test_lt.py index 60550bc1d..45280e2cd 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_lt.py +++ b/tests/safeds/data/tabular/containers/_cell/test_lt.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_mod.py b/tests/safeds/data/tabular/containers/_cell/test_mod.py index fd323daec..f1d960e62 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_mod.py +++ b/tests/safeds/data/tabular/containers/_cell/test_mod.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_mul.py b/tests/safeds/data/tabular/containers/_cell/test_mul.py index 021a62723..279522aa1 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_mul.py +++ b/tests/safeds/data/tabular/containers/_cell/test_mul.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_ne.py b/tests/safeds/data/tabular/containers/_cell/test_ne.py index 3c8fdb195..be55a9c41 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_ne.py +++ b/tests/safeds/data/tabular/containers/_cell/test_ne.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_or.py b/tests/safeds/data/tabular/containers/_cell/test_or.py index 4f6ceb0f2..6220b4011 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_or.py +++ b/tests/safeds/data/tabular/containers/_cell/test_or.py @@ -2,8 +2,8 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_pow.py b/tests/safeds/data/tabular/containers/_cell/test_pow.py index ef07ed74e..a977b5e92 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_pow.py +++ b/tests/safeds/data/tabular/containers/_cell/test_pow.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_sizeof.py b/tests/safeds/data/tabular/containers/_cell/test_sizeof.py index 1043ed5df..3bd544fc5 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_sizeof.py +++ b/tests/safeds/data/tabular/containers/_cell/test_sizeof.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Any import polars as pl + from safeds.data.tabular.containers._lazy_cell import _LazyCell if TYPE_CHECKING: diff --git a/tests/safeds/data/tabular/containers/_cell/test_sub.py b/tests/safeds/data/tabular/containers/_cell/test_sub.py index 57bb0035a..15593ae79 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_sub.py +++ b/tests/safeds/data/tabular/containers/_cell/test_sub.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_cell/test_xor.py b/tests/safeds/data/tabular/containers/_cell/test_xor.py index 8e852dd9a..e62398512 100644 --- a/tests/safeds/data/tabular/containers/_cell/test_xor.py +++ b/tests/safeds/data/tabular/containers/_cell/test_xor.py @@ -2,8 +2,8 @@ import polars as pl import pytest -from safeds.data.tabular.containers._lazy_cell import _LazyCell +from safeds.data.tabular.containers._lazy_cell import _LazyCell from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_column/test_all.py b/tests/safeds/data/tabular/containers/_column/test_all.py index ac6b825c0..4caea54b9 100644 --- a/tests/safeds/data/tabular/containers/_column/test_all.py +++ b/tests/safeds/data/tabular/containers/_column/test_all.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_any.py b/tests/safeds/data/tabular/containers/_column/test_any.py index 440be41c3..23976665c 100644 --- a/tests/safeds/data/tabular/containers/_column/test_any.py +++ b/tests/safeds/data/tabular/containers/_column/test_any.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_contains.py b/tests/safeds/data/tabular/containers/_column/test_contains.py index b84a16432..0d37d4418 100644 --- a/tests/safeds/data/tabular/containers/_column/test_contains.py +++ b/tests/safeds/data/tabular/containers/_column/test_contains.py @@ -1,6 +1,7 @@ from typing import Any import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_correlation_with.py b/tests/safeds/data/tabular/containers/_column/test_correlation_with.py index e62fe92d9..15347cbf2 100644 --- a/tests/safeds/data/tabular/containers/_column/test_correlation_with.py +++ b/tests/safeds/data/tabular/containers/_column/test_correlation_with.py @@ -1,8 +1,9 @@ import pytest + from safeds.data.tabular.containers import Column from safeds.exceptions import ( - ColumnLengthMismatchError, ColumnTypeError, + LengthMismatchError, MissingValuesColumnError, ) @@ -49,7 +50,7 @@ def test_should_raise_if_columns_are_not_numeric(values1: list, values2: list) - def test_should_raise_if_column_lengths_differ() -> None: column1 = Column("A", [1, 2, 3, 4]) column2 = Column("B", [2]) - with pytest.raises(ColumnLengthMismatchError): + with pytest.raises(LengthMismatchError): column1.correlation_with(column2) diff --git a/tests/safeds/data/tabular/containers/_column/test_count_if.py b/tests/safeds/data/tabular/containers/_column/test_count_if.py index 82825dd33..c68815026 100644 --- a/tests/safeds/data/tabular/containers/_column/test_count_if.py +++ b/tests/safeds/data/tabular/containers/_column/test_count_if.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_distinct_value_count.py b/tests/safeds/data/tabular/containers/_column/test_distinct_value_count.py index 0d72ba09e..96bef2de6 100644 --- a/tests/safeds/data/tabular/containers/_column/test_distinct_value_count.py +++ b/tests/safeds/data/tabular/containers/_column/test_distinct_value_count.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_from_polars_series.py b/tests/safeds/data/tabular/containers/_column/test_from_polars_series.py index dd016e1ed..0751716b2 100644 --- a/tests/safeds/data/tabular/containers/_column/test_from_polars_series.py +++ b/tests/safeds/data/tabular/containers/_column/test_from_polars_series.py @@ -1,5 +1,6 @@ import polars as pl import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_get_distinct_values.py b/tests/safeds/data/tabular/containers/_column/test_get_distinct_values.py index a2d70cdb4..db6bc82d4 100644 --- a/tests/safeds/data/tabular/containers/_column/test_get_distinct_values.py +++ b/tests/safeds/data/tabular/containers/_column/test_get_distinct_values.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING import pytest + from safeds.data.tabular.containers import Column if TYPE_CHECKING: diff --git a/tests/safeds/data/tabular/containers/_column/test_get_value.py b/tests/safeds/data/tabular/containers/_column/test_get_value.py index 34ee47ad0..72564a100 100644 --- a/tests/safeds/data/tabular/containers/_column/test_get_value.py +++ b/tests/safeds/data/tabular/containers/_column/test_get_value.py @@ -1,6 +1,7 @@ from typing import Any import pytest + from safeds.data.tabular.containers import Column from safeds.exceptions import IndexOutOfBoundsError diff --git a/tests/safeds/data/tabular/containers/_column/test_getitem.py b/tests/safeds/data/tabular/containers/_column/test_getitem.py index 9a5c1d6f0..9443bd610 100644 --- a/tests/safeds/data/tabular/containers/_column/test_getitem.py +++ b/tests/safeds/data/tabular/containers/_column/test_getitem.py @@ -1,6 +1,7 @@ from typing import Any import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_hash.py b/tests/safeds/data/tabular/containers/_column/test_hash.py index 8783118b6..cfb4d5fb6 100644 --- a/tests/safeds/data/tabular/containers/_column/test_hash.py +++ b/tests/safeds/data/tabular/containers/_column/test_hash.py @@ -6,8 +6,8 @@ @pytest.mark.parametrize( ("column", "expected"), [ - (Column("a", []), 1581717131331298536), - (Column("a", [1, 2, 3]), 239695622656180157), + (Column("a", []), 570351198003906119), + (Column("a", [1, 2, 3]), 1036496604269026516), ], ids=[ "empty", diff --git a/tests/safeds/data/tabular/containers/_column/test_idness.py b/tests/safeds/data/tabular/containers/_column/test_idness.py index 99a1db29c..e3563a804 100644 --- a/tests/safeds/data/tabular/containers/_column/test_idness.py +++ b/tests/safeds/data/tabular/containers/_column/test_idness.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_is_numeric.py b/tests/safeds/data/tabular/containers/_column/test_is_numeric.py deleted file mode 100644 index 0a8267efb..000000000 --- a/tests/safeds/data/tabular/containers/_column/test_is_numeric.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest -from safeds.data.tabular.containers import Column - - -@pytest.mark.parametrize( - ("column", "expected"), - [ - (Column("a", []), False), - (Column("a", [0]), True), - (Column("a", [0.5]), True), - (Column("a", [0, None]), True), - (Column("a", ["a", "b"]), False), - ], - ids=[ - "empty", - "int", - "float", - "numeric with missing", - "non-numeric", - ], -) -def test_should_return_whether_column_is_numeric(column: Column, expected: bool) -> None: - assert column.is_numeric == expected diff --git a/tests/safeds/data/tabular/containers/_column/test_is_temporal.py b/tests/safeds/data/tabular/containers/_column/test_is_temporal.py deleted file mode 100644 index b2606e0ef..000000000 --- a/tests/safeds/data/tabular/containers/_column/test_is_temporal.py +++ /dev/null @@ -1,29 +0,0 @@ -from datetime import UTC, datetime - -import pytest -from safeds.data.tabular.containers import Column - -now = datetime.now(UTC) - - -@pytest.mark.parametrize( - ("column", "expected"), - [ - (Column("a", []), False), - (Column("a", [now]), True), - (Column("a", [now.date()]), True), - (Column("a", [now.time()]), True), - (Column("a", [now, None]), True), - (Column("a", ["a", "b"]), False), - ], - ids=[ - "empty", - "datetime", - "date", - "time", - "operator with missing", - "non-operator", - ], -) -def test_should_return_whether_column_is_temporal(column: Column, expected: bool) -> None: - assert column.is_temporal == expected diff --git a/tests/safeds/data/tabular/containers/_column/test_iter.py b/tests/safeds/data/tabular/containers/_column/test_iter.py index 2e3fcc9a9..bfb15ec22 100644 --- a/tests/safeds/data/tabular/containers/_column/test_iter.py +++ b/tests/safeds/data/tabular/containers/_column/test_iter.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_len.py b/tests/safeds/data/tabular/containers/_column/test_len.py index 16be0e173..2f2cb750c 100644 --- a/tests/safeds/data/tabular/containers/_column/test_len.py +++ b/tests/safeds/data/tabular/containers/_column/test_len.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_max.py b/tests/safeds/data/tabular/containers/_column/test_max.py index f40f06d13..a88b9dd7d 100644 --- a/tests/safeds/data/tabular/containers/_column/test_max.py +++ b/tests/safeds/data/tabular/containers/_column/test_max.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_mean.py b/tests/safeds/data/tabular/containers/_column/test_mean.py index 382de2380..c84b6428a 100644 --- a/tests/safeds/data/tabular/containers/_column/test_mean.py +++ b/tests/safeds/data/tabular/containers/_column/test_mean.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column from safeds.exceptions import ColumnTypeError diff --git a/tests/safeds/data/tabular/containers/_column/test_median.py b/tests/safeds/data/tabular/containers/_column/test_median.py index 9a0ceb637..27f6b0799 100644 --- a/tests/safeds/data/tabular/containers/_column/test_median.py +++ b/tests/safeds/data/tabular/containers/_column/test_median.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column from safeds.exceptions import ColumnTypeError diff --git a/tests/safeds/data/tabular/containers/_column/test_min.py b/tests/safeds/data/tabular/containers/_column/test_min.py index 86397da13..f55359eb2 100644 --- a/tests/safeds/data/tabular/containers/_column/test_min.py +++ b/tests/safeds/data/tabular/containers/_column/test_min.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_missing_value_count.py b/tests/safeds/data/tabular/containers/_column/test_missing_value_count.py index e6696ac5a..a4764120d 100644 --- a/tests/safeds/data/tabular/containers/_column/test_missing_value_count.py +++ b/tests/safeds/data/tabular/containers/_column/test_missing_value_count.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_missing_value_ratio.py b/tests/safeds/data/tabular/containers/_column/test_missing_value_ratio.py index 64a859933..198d08cd7 100644 --- a/tests/safeds/data/tabular/containers/_column/test_missing_value_ratio.py +++ b/tests/safeds/data/tabular/containers/_column/test_missing_value_ratio.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_mode.py b/tests/safeds/data/tabular/containers/_column/test_mode.py index 459827c88..78cef40f0 100644 --- a/tests/safeds/data/tabular/containers/_column/test_mode.py +++ b/tests/safeds/data/tabular/containers/_column/test_mode.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_none.py b/tests/safeds/data/tabular/containers/_column/test_none.py index ade98c699..4795cd1ca 100644 --- a/tests/safeds/data/tabular/containers/_column/test_none.py +++ b/tests/safeds/data/tabular/containers/_column/test_none.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_plot_box_plot.py b/tests/safeds/data/tabular/containers/_column/test_plot_box_plot.py index 089e4f7fe..6c11d99c7 100644 --- a/tests/safeds/data/tabular/containers/_column/test_plot_box_plot.py +++ b/tests/safeds/data/tabular/containers/_column/test_plot_box_plot.py @@ -1,7 +1,8 @@ import pytest +from syrupy import SnapshotAssertion + from safeds.data.tabular.containers import Column from safeds.exceptions import ColumnTypeError -from syrupy import SnapshotAssertion @pytest.mark.parametrize( diff --git a/tests/safeds/data/tabular/containers/_column/test_plot_histogram.py b/tests/safeds/data/tabular/containers/_column/test_plot_histogram.py index 032fe2155..be508a112 100644 --- a/tests/safeds/data/tabular/containers/_column/test_plot_histogram.py +++ b/tests/safeds/data/tabular/containers/_column/test_plot_histogram.py @@ -1,7 +1,8 @@ import pytest -from safeds.data.tabular.containers import Column from syrupy import SnapshotAssertion +from safeds.data.tabular.containers import Column + @pytest.mark.parametrize( "column", diff --git a/tests/safeds/data/tabular/containers/_column/test_plot_lag_plot.py b/tests/safeds/data/tabular/containers/_column/test_plot_lag_plot.py index 429aa3f97..6a123953c 100644 --- a/tests/safeds/data/tabular/containers/_column/test_plot_lag_plot.py +++ b/tests/safeds/data/tabular/containers/_column/test_plot_lag_plot.py @@ -1,7 +1,8 @@ import pytest +from syrupy import SnapshotAssertion + from safeds.data.tabular.containers import Column from safeds.exceptions import ColumnTypeError -from syrupy import SnapshotAssertion @pytest.mark.parametrize( diff --git a/tests/safeds/data/tabular/containers/_column/test_plot_violin_plot.py b/tests/safeds/data/tabular/containers/_column/test_plot_violin_plot.py index e3dabb212..bf7a14df8 100644 --- a/tests/safeds/data/tabular/containers/_column/test_plot_violin_plot.py +++ b/tests/safeds/data/tabular/containers/_column/test_plot_violin_plot.py @@ -1,7 +1,8 @@ import pytest +from syrupy import SnapshotAssertion + from safeds.data.tabular.containers import Column from safeds.exceptions import ColumnTypeError -from syrupy import SnapshotAssertion @pytest.mark.parametrize( diff --git a/tests/safeds/data/tabular/containers/_column/test_repr.py b/tests/safeds/data/tabular/containers/_column/test_repr.py index dc4c6989b..301461e8c 100644 --- a/tests/safeds/data/tabular/containers/_column/test_repr.py +++ b/tests/safeds/data/tabular/containers/_column/test_repr.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_repr_html.py b/tests/safeds/data/tabular/containers/_column/test_repr_html.py index 0f94355a6..7f610e883 100644 --- a/tests/safeds/data/tabular/containers/_column/test_repr_html.py +++ b/tests/safeds/data/tabular/containers/_column/test_repr_html.py @@ -1,5 +1,6 @@ import pytest import regex as re + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_row_count.py b/tests/safeds/data/tabular/containers/_column/test_row_count.py index 2829d798d..ce5c93b41 100644 --- a/tests/safeds/data/tabular/containers/_column/test_row_count.py +++ b/tests/safeds/data/tabular/containers/_column/test_row_count.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_sizeof.py b/tests/safeds/data/tabular/containers/_column/test_sizeof.py index 1517e6ce0..49535ef5a 100644 --- a/tests/safeds/data/tabular/containers/_column/test_sizeof.py +++ b/tests/safeds/data/tabular/containers/_column/test_sizeof.py @@ -1,6 +1,7 @@ import sys import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_stability.py b/tests/safeds/data/tabular/containers/_column/test_stability.py index 611e1c025..a2f4c2d1d 100644 --- a/tests/safeds/data/tabular/containers/_column/test_stability.py +++ b/tests/safeds/data/tabular/containers/_column/test_stability.py @@ -1,6 +1,7 @@ from typing import Any import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_standard_deviation.py b/tests/safeds/data/tabular/containers/_column/test_standard_deviation.py index 924614286..5aa642e9d 100644 --- a/tests/safeds/data/tabular/containers/_column/test_standard_deviation.py +++ b/tests/safeds/data/tabular/containers/_column/test_standard_deviation.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column from safeds.exceptions import ColumnTypeError diff --git a/tests/safeds/data/tabular/containers/_column/test_str.py b/tests/safeds/data/tabular/containers/_column/test_str.py index dc4c6989b..301461e8c 100644 --- a/tests/safeds/data/tabular/containers/_column/test_str.py +++ b/tests/safeds/data/tabular/containers/_column/test_str.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_summarize_statistics.py b/tests/safeds/data/tabular/containers/_column/test_summarize_statistics.py index 2ec858b6d..d59f1a43f 100644 --- a/tests/safeds/data/tabular/containers/_column/test_summarize_statistics.py +++ b/tests/safeds/data/tabular/containers/_column/test_summarize_statistics.py @@ -1,136 +1,181 @@ +import datetime from statistics import stdev import pytest + from safeds.data.tabular.containers import Column, Table +_HEADERS = [ + "min", + "max", + "mean", + "median", + "standard deviation", + "missing value ratio", + "stability", + "idness", +] +_EMPTY_COLUMN_RESULT = [ + None, + None, + None, + None, + None, + 1.0, + 1.0, + 1.0, +] + @pytest.mark.parametrize( ("column", "expected"), [ - ( # boolean - Column("col", [True, False, True]), + # no rows + ( + Column("col1", []), Table( { - "metric": [ - "min", - "max", - "mean", - "median", - "standard deviation", - "distinct value count", - "idness", - "missing value ratio", - "stability", - ], - "col": [ - "False", - "True", - "-", - "-", - "-", - "2", - str(2 / 3), - "0.0", - str(2 / 3), - ], + "statistic": _HEADERS, + "col1": _EMPTY_COLUMN_RESULT, }, ), ), - ( # ints - Column("col", [1, 2, 1]), + # null column + ( + Column("col1", [None, None, None]), Table( { - "metric": [ - "min", - "max", - "mean", - "median", - "standard deviation", - "distinct value count", - "idness", - "missing value ratio", - "stability", + "statistic": _HEADERS, + "col1": [ + None, + None, + None, + None, + None, + 1.0, + 1.0, + 1 / 3, ], - "col": [ + }, + ), + ), + # numeric column + ( + Column("col1", [1, 2, 1, None]), + Table( + { + "statistic": _HEADERS, + "col1": [ 1, 2, 4 / 3, 1, stdev([1, 2, 1]), - 2, - 2 / 3, - 0, + 1 / 4, 2 / 3, + 3 / 4, ], }, ), ), - ( # strings - Column("col", ["a", "b", "c"]), + # temporal column + ( + Column( + "col1", + [ + datetime.time(1, 2, 3), + datetime.time(4, 5, 6), + datetime.time(7, 8, 9), + None, + ], + ), Table( { - "metric": [ - "min", - "max", - "mean", - "median", - "standard deviation", - "distinct value count", - "idness", - "missing value ratio", - "stability", + "statistic": _HEADERS, + "col1": [ + "01:02:03", + "07:08:09", + None, + None, + None, + "0.25", + "0.3333333333333333", + "1.0", ], - "col": [ + }, + ), + ), + # string column + ( + Column("col1", ["a", "b", "c", None]), + Table( + { + "statistic": _HEADERS, + "col1": [ "a", "c", - "-", - "-", - "-", - "3", + None, + None, + None, + "0.25", + "0.3333333333333333", "1.0", - "0.0", - str(1.0 / 3), ], }, ), ), - ( # only missing - Column("col", [None, None]), + # boolean column + ( + Column("col1", [True, False, True, None]), Table( { - "metric": [ - "min", - "max", - "mean", - "median", - "standard deviation", - "distinct value count", - "idness", - "missing value ratio", - "stability", - ], - "col": [ - "-", - "-", - "-", - "-", - "-", - "0", - "0.5", - "1.0", - "1.0", + "statistic": _HEADERS, + "col1": [ + "false", + "true", + None, + None, + None, + "0.25", + "0.6666666666666666", + "0.75", ], }, ), ), ], ids=[ - "boolean", - "ints", - "strings", - "only missing", + "no rows", + "null column", + "numeric column", + "temporal column", + "string column", + "boolean column", ], ) def test_should_summarize_statistics(column: Column, expected: Table) -> None: - assert column.summarize_statistics().schema == expected.schema - assert column.summarize_statistics() == expected + actual = column.summarize_statistics() + assert actual == expected + + +@pytest.mark.parametrize( + ("column", "expected"), + [ + # statistic column + ( + Column("statistic", []), + Table( + { + "statistic_": _HEADERS, + "statistic": _EMPTY_COLUMN_RESULT, + }, + ), + ), + ], + ids=[ + "statistic column", + ], +) +def test_should_ensure_new_column_has_unique_name(column: Column, expected: Table) -> None: + actual = column.summarize_statistics() + assert actual == expected diff --git a/tests/safeds/data/tabular/containers/_column/test_to_list.py b/tests/safeds/data/tabular/containers/_column/test_to_list.py index cb386a43f..ef3d5d7d5 100644 --- a/tests/safeds/data/tabular/containers/_column/test_to_list.py +++ b/tests/safeds/data/tabular/containers/_column/test_to_list.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_to_table.py b/tests/safeds/data/tabular/containers/_column/test_to_table.py index f8f2c2c5c..be711087a 100644 --- a/tests/safeds/data/tabular/containers/_column/test_to_table.py +++ b/tests/safeds/data/tabular/containers/_column/test_to_table.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column, Table diff --git a/tests/safeds/data/tabular/containers/_column/test_transform.py b/tests/safeds/data/tabular/containers/_column/test_transform.py index a59d8799d..3801906de 100644 --- a/tests/safeds/data/tabular/containers/_column/test_transform.py +++ b/tests/safeds/data/tabular/containers/_column/test_transform.py @@ -1,6 +1,7 @@ from typing import Any import pytest + from safeds.data.tabular.containers import Column diff --git a/tests/safeds/data/tabular/containers/_column/test_type.py b/tests/safeds/data/tabular/containers/_column/test_type.py index f335c1ec2..749f95a0c 100644 --- a/tests/safeds/data/tabular/containers/_column/test_type.py +++ b/tests/safeds/data/tabular/containers/_column/test_type.py @@ -5,4 +5,4 @@ def test_should_return_the_type() -> None: column: Column[Any] = Column("a", []) - assert str(column.type) == "Null" + assert str(column.type) == "null" diff --git a/tests/safeds/data/tabular/containers/_column/test_variance.py b/tests/safeds/data/tabular/containers/_column/test_variance.py index 10b19c217..1a63b4671 100644 --- a/tests/safeds/data/tabular/containers/_column/test_variance.py +++ b/tests/safeds/data/tabular/containers/_column/test_variance.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Column from safeds.exceptions import ColumnTypeError diff --git a/tests/safeds/data/tabular/containers/_row/test_get_column_type.py b/tests/safeds/data/tabular/containers/_row/test_get_column_type.py index c0af9aeaf..2cf29bfab 100644 --- a/tests/safeds/data/tabular/containers/_row/test_get_column_type.py +++ b/tests/safeds/data/tabular/containers/_row/test_get_column_type.py @@ -2,20 +2,20 @@ from safeds.data.tabular.containers import Table from safeds.data.tabular.containers._lazy_vectorized_row import _LazyVectorizedRow -from safeds.data.tabular.typing import DataType +from safeds.data.tabular.typing import ColumnType @pytest.mark.parametrize( ("table", "column_name", "expected"), [ - (Table({"col1": ["A"]}), "col1", "String"), - (Table({"col1": ["a"], "col2": [1]}), "col2", "Int64"), + (Table({"col1": ["A"]}), "col1", ColumnType.string()), + (Table({"col1": ["a"], "col2": [1]}), "col2", ColumnType.int64()), ], ids=[ "one column", "two columns", ], ) -def test_should_return_the_type_of_the_column(table: Table, column_name: str, expected: DataType) -> None: +def test_should_return_the_type_of_the_column(table: Table, column_name: str, expected: ColumnType) -> None: row = _LazyVectorizedRow(table=table) - assert str(row.get_column_type(column_name)) == expected + assert row.get_column_type(column_name) == expected diff --git a/tests/safeds/data/tabular/containers/_string_cell/test_sizeof.py b/tests/safeds/data/tabular/containers/_string_cell/test_sizeof.py index 43df6affb..d01d9b0f7 100644 --- a/tests/safeds/data/tabular/containers/_string_cell/test_sizeof.py +++ b/tests/safeds/data/tabular/containers/_string_cell/test_sizeof.py @@ -1,6 +1,7 @@ import sys import polars as pl + from safeds.data.tabular.containers._lazy_string_cell import _LazyStringCell diff --git a/tests/safeds/data/tabular/containers/_string_cell/test_substring.py b/tests/safeds/data/tabular/containers/_string_cell/test_substring.py index 1305e76b3..ab2496486 100644 --- a/tests/safeds/data/tabular/containers/_string_cell/test_substring.py +++ b/tests/safeds/data/tabular/containers/_string_cell/test_substring.py @@ -1,6 +1,6 @@ import pytest -from safeds.exceptions import OutOfBoundsError +from safeds.exceptions import OutOfBoundsError from tests.helpers import assert_cell_operation_works diff --git a/tests/safeds/data/tabular/containers/_table/__snapshots__/test_hash.ambr b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_hash.ambr new file mode 100644 index 000000000..f079fdc47 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_hash.ambr @@ -0,0 +1,10 @@ +# serializer version: 1 +# name: TestContract.test_should_return_same_hash_in_different_processes[empty] + 1789859531466043636 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[no rows] + 585695607399955642 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[with data] + 909875695937937648 +# --- diff --git a/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_boxplots/test_should_match_snapshot[four columns (all numeric)].png b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_boxplots/test_should_match_snapshot[four columns (all numeric)].png deleted file mode 100644 index 796fca5e8a2695e836df31a38e9330136606a664..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20427 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 Z>Il8>9wa3Rhxb6FugPD{xUB#1e*o``Dt`a~ diff --git a/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_boxplots/test_should_match_snapshot[one column].png b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_boxplots/test_should_match_snapshot[one column].png deleted file mode 100644 index 4c5b2c817a257cd9390adf10e4967653a485beea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7951 zcmcIp2Q*yYx*t8lkRgQ8qKi&QM2%j9Ac)?g6TO${BYH#-od}|h=)FaTh=}ODNAwac z>f7_b?SJoG_r6=+TZ^@<5~>5~L^>c6yp; z?3wl~RkD3P=x9CNcW>sjzW;nGv8CSCDqNmk@dY*m14i_o8)-g@-t(azqUl# z(=jsq+KlNy{J_9KkeKh`O%4u%TeohpadPgvDGPkyMOt@{HG1;ARY?u*?pEM5ZlMp>yi zU%rHQnY9;}x2(-?Sr^uQ8H8TLn4L8=Z1EePr*C9ZRA)~*UN^qHnxwSc4sMuJmfEZ9 zP3FlfEWE~~hUWGJ-c@X(`Bi@mD0*0+I-p+PPb%E_P=@e8IB<5t@C_V%5F z16e64Ow~+L?*knvna<3?w%5F-n3?{EkB3G^7LIopkAL+C#xg6vnoCp@U#-6QF_5RA ztlatTQSnYY9^L)oVX5Nb+?E!jvhgY#QX{`zS^L=*8bLup3Nar65>nEE@$q{|9XXlK zxFI!jRBV#R#@N7mi|=-`j*_CH^B6f5703DayR8XKiZ2=(8}qZXZ?67Y$x1$|t*%y7 z%Ga1^_VsK%0f$Zk=`pL=|9KUmpyeph364ohQ4!z%4%NcKf=_ADyLVDjQo)@yA3n%v zJ$i%zdHwpe$(=MBTP=1nGjsE0I74nrUHbR+#W)E$0|RPd5fOBGnq6L;*KO8J`jiT$ z^<5z(bQ>e5ptw>w&bfVQs&6dlUop9foZQ@N7`AmEK0yqRk4Jw0ZmCwww-6Q{-ho1) z1|0}FxUo3+`AOW}-N6!%Q4$wH(b0rjT3Y**dLdmVye6ID(9!+$Tw6y+IZMm81(OuJ zdq01|V6gesJKLCc$)F^Y*S7!b=Z>5)e>zQp>qj$O(;%|m&2*x0U=mX;Dt>gnsl zu3u+DFUiRXDg8i2C3;YN-$%^F)pgmuc=&mI{574;IE9>?obLYqBR49Yjs4ZZu{vi) z+Q2jC(6BJ?h71mJIDBoDJUkb{dUNO71`51369ITaVj|;H5w{=lXP-GlMX4nv zC3)d7F83doY7Y$!fgbH$^-fY^eyQ{%aFQm$&(ANsOYIPeM4CF&2sowVzA`G8cX8pD zl9nErnTcIkFiTNkd6}Gy1^G5O==`(qZl?(^Ks>)4NlslP1l?R38XD>v8o`UGxL%1( zva2{QE-pKtZ}*$*E%jU>CPp>5uV)WfXAj%r9VtaBc&TEogKkq$Qo$4uiJ$LlM$?KljGE7QEkX^NydD}!1YU^d z=H*qmt!hLxym`a4F;T5-v~t}@X6gB08#@Pwt1b^GXQ*DOoPz_`_x`Nebv_IX45D>r zX6Cy2a4H|{R~khxS+$uNT;y9bO&@cTL&T(eQiFprr#xr<#kRUtSV+O585kK8l9IxH zuJnu0pd4#EZL5VjWdz>W(dbT3Pj~n9w1X3)`A+Hf9pGPKc{vIY0Zk1nGc(x66}dcE zzt|BqJ3D)PdU|wOS(!MBpMp@mNNcadW-U^VwzlBi=&Bp~QwfanQ?yNhJO&HYP#fcw zG&Qqe5qnt$YFV)W;HG@mOLHq-y(kPdYGTyzbV^phGO30N9`&cScvaw-LE#w4= zAg22micdNPAG55?dGiLlSi98av5516Of=P9qL`Q%{}Hw%C2;&A=p6_(cLMCS8!2hO zI6pPzPrXJps|&lPv2KT95T;p8IeK=9MrZo96+LStiNCMKr2rJh8cKtUOSl6%mS=g+VHa1++j z(OLYOBVFgVNAcGn!B)$vlI z8n0;wc1oWm%-E&}`0<{$t!|h+A+vLENRobCmO)2Lf42V+(~yci z=^d)IKc{1}?c1MwBz>f-tBanSTh%D*wiQvpVP)2QezH-=pqhk#R}7pG*M1B+|2%HH zd}KtM^U__I+pwyU`>kY!^P-$uEiZ%NcIgMZa&lq#HeJAUJ1xZB z6qS+gDO4rck;UO8kMas)RwNlIc^q z1_T7`-DXKr!T~h}x$om6?AY?ll7bKy(nfc&gE!^9l93`{LozWjv2*sbe-RL)ZnKZz zv+Y^Q&j6}!_lt(~B(gFB;#(gn4Ux+W(k`a?pv=rL04lPnNfdJ5&#$TT(6G_NwCgz; zgeLw(VmHZ0z>J_->JPBeGt$;U0!Ogbjojg%P@r1gb={;X0}U2Qtk zJK%jtQ+4=c1M6WblyI59QI%E*TIN;ZlDfdxB z^wvJc(nN)ZVpEHFe9o~1FF~E{I*~G9r(t6#3~Hx}Rh*lPDV#wxJUlGq_Tw72VMAzF z)QwKd$^-A{9t$ZwJvb4ac=Wq>?4hBdCc6t=LcWJ3(uNuuBzk&!OHYorrm07P z30<3QYg=1#s;Wfazhp$&48E=R4+b!L9Ug;=kB`4s19FWW5m@2R}-ne{*F z3Csjy8)z1%_0rl73;y}7#_cV4&Ss;ysVM_=oAXy8QYIyN?D6{ArU+S=OMDJ8=gH7LTV_yTF!2j7@&rDI9N zpP!$1gf`d=ve-}6!Iqbo>lR~03`Vm4!dt$Eqyvbh|CP6li~_gnSCEd=!W&cdq_tBn zQGO}_3?bQhd026xdsp5S7dx*F-2w-~`S?9{BFh6QGqZ)8hAc2Bo+=gx4-YW_gWcWT zD?~)El7ZLj76c4tZDZps^nIZ#>Ug+3VA<*O_r5d?h>?-e@sGSXCm;)MGB98Qg6{a7 zdV6;}@RCX?-zA*t?T}ehi~%pO^4q-I-Uz524Xj7 z*!H2ZG0&*|KO#5kIu6EMQ+fLOpHY*St>vv3aL^=VWMscJiD2(5u8=;<&LAwjB?r$L zZ&ouiGo=5ihr$c&7`c!LTAwS#5@)=SHnWPGyu8=K!or9WON7n;gh04YVmm-}-MoF9 zv}STk^5};%mbmzUy1?IIP|jbNF}wdY=;#QWRg9Z_d@x}DA+0Z&p28ynND z4@VTS*|M_fGQz=sc>g0%k&d;! z>!3lWN6J4H5;%-50rqHDzdYUYFj;jI9vU58>`q_|8CT09#KpQ}Kjzfezdotu-Pf-u z)9m`U)TL?{5*++>Vqy(hT3t;BSby(=uIAj!_5_gNhYuh2@09`5;^a%O8%je11Pv1!RCe0#E_` zQPZ#QX#i~#v-pa%*pE+6F#iRo#3&&_2gz1vi%(1pvC8zuMLQfXFQJiAeY_YstWs?e zM6!}ht}9&E+uIxR?D*I#V)L(+<67X;J6}?7;{mPUwGhd=GiDxV3Jez;7ni%2daK8# z-kALz3row~(o*Qp?|ssbA5#LohWq0^!dhr;bOjks5&fLE_o)E^P@r~L-OY;2R&Z}~*-ngy!vg+GU|OJ$gdl1YnLSXmkG z-5cM;c*IVo#h%n;1j3xBt>@$%I%RI6yY8bsiSI~Y_2yr*Ov$caze!JzVQOkhG^v&; ziVu1yl9YAf;&fZy)wOcadBa1bM7tP`zP;JFBRavc|77Gczl|_er++Y8GjYTA;~84_6Rrvt&>#%Cuc=9j&b}g+e41%jS69ViIoR0l+uL&jD?MU93lgztAkx!?-LJ~W z(RT3!{!;ex_NM3J+GOX6K!o20C>=*95gSHub~9>O}sl~NaQM?6ze|2aS_u^P}R zen-=u{Uw%>RWla+k~!-8 zx&W!9r)wN0oqD-y+fXRHii!#$kYPPCyaIy;{=OF1N85nw0X&y*qgsz^d$Q7s7-TfN zK<+P`AI-|UxuMfA=dG#w3u6pe3Y|4(bF^#nhDqTsSIHSHCK=+DH%a!AbB7JWA z9@+!xgbm?!oDu@Hlv7E|`42#(@`(fR;gG8;~c`V8f8M$w{i)R$oE~tV6YnaUdX@?F9HOBGTB;_Yxgy zEmvFXfWS}4;t~~u4LTk)(T*FHKxiFdNo*{Uz@0mv)vtWMqJ_18>Iv$PjJK_b5R9cXxNU(RfY;jaOQk1A+$3ztf zzJJoY100$8`N;t=9f-(CNdVK^mWb|KVIVgxNUI~!E+%77x(XWY;^HFHF#`81fUWq{ z)X1|lzZNDYy#J?2i&8f!DM{hMgD_y8q;l@t*c1((+@0~liX!Lg03AKQy&YFqCj>G< zks;B8Ax*S>=zV!hvusTYVSNlb9mI2BATUjRijB)oXB@V*5o^Q^#&djJ&h@@N>~ECw^C?rv^EQ@;cR z1#`;Eh(OeL1qux_Do25me5j>`J$ITML`g|G*5J+o0&`K~qV1S+O*@upMgVa0nSUhw#fT4z9UvF(NVzdF1g-JCB6xsc zN#-#QS1kZth-?xCGS{Uq^|n>~t5@M*wlf9ZD>@HTH#Cd^k0IVafDQ02>=AK@i_^Yf zI;d|&BEOnWTkzM5+9)bU_JZU-Yrzvg^8YNX`b~atV->%DPXaaxax+krXf1TFu*aff zvv!s!Un3Gk@(0JIZy-pA$vaany<9uHyOMwkV*zq9pOX$;S>Q*4$pboU1vk{<+S}TK z`PWr)ZUY3M(xs&(lY_NkHXfd2yTgs~5McL2e=Q|?U7jDoXlP=^yg<1ogj@`<8!t(Gh+aMK_EAHjAR@fN~QY>ALhl! z$KM>i20O59vkJKLyKx5?ArRc)o|}ByDyyTTQ!a=$vRR80e@>%_>=!!~tgH$;hwO6i z`JYsE2FjAb;aBPC=)feTgm6OZ0ITm0ke}%&eFZqZ?$4k1!5G3}@ncM;JV5fBRE{HEmOo*%h%&&n4Vq)Tj(1G)@5eDSGD^Q-QvB529Z$}w`*krT zC557Aa#H`bkaKPocsT9i#WGJ|0^kL2Swg`PazuOC!$) z3J&IMyTdxhhRzzK2`dJxt@@@w)yvU}yRrUfA@J@cWIKy!Fd^3cCm3`?6lGOq%A`z! F{|6Zwp(y|W diff --git a/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_correlation_heatmap/test_should_match_snapshot[normal].png b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_correlation_heatmap/test_should_match_snapshot[normal].png deleted file mode 100644 index eedf2950c2e44e59527ed382a7f6900babc2dce4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11831 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 c{b7q}6 z?a`!AW_eO5Gxb=0!6#DB`5P$|wPNa?-P(>JUz%JDwe8!dzYe#W_RqYtB!1rByAF>} z?EMlH6dMz8@NSUT!-jy}!7BQtZYpVFNwt-)^6ZYP=ovKT#Uwn)Iqp`&=IiQ6)pEB| zpLun~ta#oh6I1;?CzArTFG?154xN*J(@`OL=FRKiuY-r1L$-CkGZYC_!A(#o9V2?2 z%pVRdWaS}0thy@CLjJxOwPYs#@`&WeJyR%$`2UB09Yu2<|B`8%R$m>b#!Zi=Y~F8c z`|#xUgRue<5_*&4!*z)|^d#@OMVsd?TKksQM>?t}C0Dhepy1o^aNVZN_fe8(K6O32 zvYb|;ut~jzK`HXzN2OL|)EU#04`y0UPfc1jK1!~+MOpR9Yo)Qeqhl7Hn3a{)FC-*s zXJ4LHxa;@37Z-6_+DlV@(aW}t-Ftl%Pi|Anoja@SyDJsrR6{F^Z>$k_`gZI~Lx#%y zl_Kd<8!RPQc`QRO%w23*b??B~V3pd?3tk`o_3IV2;#4!FR#2YqQBtDorW*H-e(fj` z;^W)nKG|a;;7LhrdVFE7xMekehDFuhp4w!s(tu4(KF^;&x4>N*;M!jEG!9z7;VP=lSZlO^Vc=jpi-|iyY%HNoAxI? zI@QRyvN7!bud6NBkQ>BFn$DEVSiZX-xz~p)*>(K8MdPDWw33;J?nFhcV_&g?@!}S5 zL&k}~4XPpWzP`(3Mp`e>lx7|BD3=}IUuWbHYE>|5MvsmN=qSGvuk-NX!*ug?g4{&^ zh2Da3E1YRj#F8i)l}gpvx9{@UaGxmkVf{%CN!yqen+#&~)65c#^PN|0oPFqO_LixZ~2D(H^aljd$T*aaH;sxwTmwCy(}*;Z|94jFJa1-vDe8dyRx#f zy3pO7Q`%8|e7LV}YI3{=%ZZ(h?KmcmrZoG|%{8aK z>s11dPfd(kR>$9((@ohWYO{z zrb{jIkm~J@3#Tb7U)`|(PK13=tpFciOl1ezMOWE{vW8~6E%jQG@dW44F*Hod?ak@Q z^J%fGI*>EQp!@s!%Ja-_WvA1UE5l@5_^6i_bJHv>6Tg3HjFX%C>eJgdOxb0hJvG+j z9~6{$i&rV}Xo>gLD_1lEMU3ni)dHs)(y7;0iq>Y?bR>CSF(~?En{7zDL~(m*+@2L%n58ThM6{rAtkmXMDZDDuoEfB*68jPJ#R?Ck8g>dlFXiL938hkgw# zjcso~l3`h+EGt_(aq5~4%jSazR$byNURPwN!+Fi}6pu8%Ew7-E>^-CCNOpEM*%#|m z%Oqy+dU$rN@2YE*5S!Se8p)eH-Q3)K-%PYlO--$co6&j-TO%?uviCs|>vaW>ddnvg zd|Ot`xJPH`X`h(iR zs&6cK`>D#VifD1S3CB|{1x8OVEw1Ztdzx{k^G)L386Ksz*=Ni@e*Bmnhxs<(oaYgV zICXE%{1yD`);(eHQx<4`l$SX9ZgZ1UXJ}5_#WmF_Mmx*yY*9E6yuBu1)4?~nQz4RP z#2ot%9SD=w$HK0@CRz~BCF9(coY|ac$%fk>>Z#McwqnzJEp_#qB+kdj#Oyg*e7yqE zJ(`}9^NJxsS1bJyB1d$cIYX82+XZu6?1XVbs{4v#h3qJxyCrZy%3m ziK3EH?(|MvK{`F%{Iaa9X1SVlBtpUH*qCwU_UnrA zg2j>Jl0xu=~ta9R~8{bLBY_*sWTcH8V0#%PsN&c zcXzLxc*8?+R6)vJv{oVli_?H}jt7rb?w9Rf-aSyLudnY-K-TBjW_JByT3Wfl1xl-h zj;#EhJ9px{x{iB!c_FjtB_t%gdhz0IkoXBc>Xt2Aay|u}@bmNIqdK==*=UD*v8+wf zCutw)%37KYTW>n+cLP$``uMoks=U9$HtMN1G{7?rUq zv8D%t#54{aYLzUV5h10Wy@X5loE|Ros(-^4m2ot=jeqfdbIzisq*HG0 zy3@$=vG?vV>dnJz!lwo|Hd#MiXpmzkaDj`GIzBbtC+<9WoP=v#6W0v4A-ahLPzy;B zxGsg#%B~~g*_S`utjJyd;)R5H{pF>+Vn?1`PAm|kXj2Ugf@+phTGw`tf1XXLi8Uxt zU;@hc(f=hR`M(HL6K#3+D=TaEHRoe<#4>X9Q+h@kJatgNhbcu79vZi7fb8Iqv_byVnUwF7Em6e4>9r)lM zHe8$6N`Rdm38xw#jR7YOGU%xcB(*BfdCLTccObYrK`Sn? z7^#q^sTsB9XmMAlLvw|Wu92zzT0C`X!ndXJfu%#&dX61CrXA!yIaD_^)|09`6g#7c zx4pex?AVJX3)f1hSH-DiN-e@D)j19dMpM)q{}RvLY>eWn1mdc?!#cn3pim+n4UF9A zo)Pir#m_K{QUGCgs(lRL8>3;Z2IOJ9ZytrB?uxB=-QV9o7HONVlseSa_Ni`nNY6U|Y4y@r^AWA?LCdbEg!kujQHT5kA`2 z_yHqPZHj+UDEHsyISsHaUCQI`?oM{f5B%~Fg_VLjfpNmsIrb;YLL{~H_4Qx9eqHS` zW0u{|l~2kS3T}~{l|dLeFaio@2%7AhG%T`Nh0= zu@AYeGp}dEh7Gun7cw6|eR7uEg`R_)4Ox^+BE{guU}t~te;lQI8Irx>&Gy*J-x)fe0em0!JOH%0k`B&)FxGY$!xK* z7o+?VL5h04E`;thshN}uHX5rj13=9G1{wbw;H;CqWW)OP2@ZYD-v$P>tgY`~T+E%4 z?=n2qe)eCXO*V3kBGY(V9>YSnd@SyC)6x~OadGsdr2P)>Z7wORsEB5jVc5lxbZ&6V z88>F0CSX$h+=%s;_t|NQHsl19z<4*E@I%Od{-186SU`c5lq5X#rExf~@imYFD)ZV8 zFK@4>iWZDSRaR9E)gC-;fuc+21{3XP%|_0ePL7Y?=Ua`HHa{iJX~1kfAD^}X;?u92 zjB*CgH%f%6N`|Tq=RIwUH&CzEmz0zg6c$dG+Uq}O&Kv<@;n$2+xQ+Oy(gp-5h*?zf zHQAPKLIy6KQ&(DEuDfsFzF1Qp9-dd_<%O-=vFU=gp9(5@7-jVekz#xir~S8QHuPxi zyQiaz8*)3>DD&WKi8qwyA2u`$Iota_0Z`fbo5gx66MPB&1YZ;O>baz9|8S*)b?sOT z$L!kX$>`6=Q@m5|!~&EtS((HeC9H>0(bAIiA{VaN9*?STrBNobhaf+He0owu#4gm+ zS*_d8<{F*NZQ|9}7#3$);GeH}kbaa5thj-H>Q;trHXA}WsZ^VC>{%Bto_>A`mDlFA9+1(Y;2Sd9H@C{TKb{0vvZ2;5Re9kxFsJI^A_yq*qd;NO;z;5d*Ih(2nY4nDF|<945T z;1U`VLQ+6>=TE3YYUHceuUBsjKNl6N5@O23r9Q1hb375WnnFSH2w57#_;Jyq?|C$FlGVh{1OOYPZQ7u*%iIuqV zg{wC2LM@(&K{3U4LKu(#rn46zmt*@$z1Y#!+suw5RWv*_y(Z<*ds0IX-{^~6GSegG z+c(=y#(8?66tJ})G6T9U!rH1+CElF)r1ItMjWy{OTATDA?g2X?)t93hr==QKCDUgu zk;lFqk;|Z;Q=YQ-L`y+FP$ueN6N~m|S2%W_O*-4x?AmO9+t-(E$&%JE;DP**71(WQ zB*NF;Kdb;CO_Oo?uC1x*yN;Jv-26?*KuR#cDaL!)FF3g8V>)g!?72WRrGFdd*k5)2 zq-1WBNN!W_$36H>%_TAl|3%oLP>gr;%s!NaOd_;l12`Ecl6d#*@dTueZaiJsgwXW+ zcZQo?${7R<2DC?Q#b5tZtl}$PFKfVLOu`*Z-}>OPdt`ywO(HnfpL}%Y-o(T?wP(m7 z@ua#U)D_AkLNk)0wK?A<*?UgW5-tHxGCNpc@3YT{p2$Tw6C`MD&fK{!>yUtVB|mVR zbSAVNFqE>Inl6}^-mH#A{s937oC`f>FkITHQPI)toSd%pjW~!7YAftAo>5&W**HI^ z@*E1b^A|3Vg;d}=Zs5o2As{@t=y0Z06M+;2oR76kPnrltf9{>KOroh!@c%p1aOA`39 zuAw@UIwfvbyXI3rE7H@7h@<^ESy>W@$y#f6Smm`!=}hkTyL~(QJEs(hPr4Ip3EyJS^r#7vdW*xNl9Mhw(sX=4|^x5UicoNihgu=3pdAFyM~Qq0cKnQTMn ziLWIDLYf{s#xE^B5!K_u*rsuzNWK1Gdh)jcN_>;FCX0%)a=QDp+wcTPS^v<`WQ?(p zu<(8?@UgZ_+=KYPvaeZrSaN^AwSDh-#BsmUdSS!`G? zx)4i@ZuEH@8%2AEO5cxK>;9tuyM+GFkr6lZH==Bn5ReV_1VMIK#>~{*lv~Hp!}p98 zNC5bEl~47L0H6Vqo^r^)hOVm{q^7D$18M-dT%CE^1n`VPaa@c$o>Bu9!I$7P@Tt@F zeYf4KZo8aL&P64rg@g)Woj*TS_}4@u@I6nSJW2AN0Zfb#x=T}0(QCtjpqPVJ*@{Lv zc9MNkKQeCvJM*xg42sFuGZD_C+bxswT?!)EW>T&rXi=z0Vh!16;CXVmLeRxOt2x*LJ5Iif0N0YBL#LsFMbY0aYIXb8tw<0gFRU`@ zz^Tfrs^9mpk>iWjf7{0+4DRgMv6xS9LS3jm+@RVOE-qlw;MhOZ+Z!yNc+N9OGrO^| zog6_C`AsQhD+ecMa#vTEim^Ko7{COWnKV8ssU4xKkV5Cg+lbv^mzl_HWKO`jOV+^a zAkPlPnqK&2VI_8D@?}ySQuaRlAnS>6%{p&hLb@J;DTg?6Yo9Ra*eo34^E-{Yc$itx~Xie3|_3JGf(k*n;PyNZQxk&gA|6CYt3flw| zgnmCjUz6ov`tiu~=g%vbB3ORt>vM08zToSJ`N^AL_Zhb>=*px!>iZ*37LfV&x&}|;io}jHQ!$WlbK+_*ft4~L{ zOy?z$5T%~S1Jp$9t5+vLLIT~0eYh-SD{GNnMMVY5zeiHw;5lmld#&TgXbt7z+wT=LvA1^%amS z_*1h5pGX%U1s;DBNWYy&S*d8?()LOv<_%Q4$#tQ7!Y%WRp5pW?w zJUqJzHVKz?6?6H1av_Jj*KzaXuJJEr! zUn?L4W?=5-Tj_$A*?y`4NLXIRdGM`@XnxF_(48jMt|G&WimJwjdNu}@nqqxXD5ct? zED!=BL8z8XK3TbJSrka70jnwiCZc;1)`^IjVBMO|4U~<6J{292GVEmv>(ZsZjjDST z6$Jo}QCL+G0Ygo0l1Z$t5Z1GPd@AexPBNN~G=H_#3jxaVorkp5)zvMWcJKbpApN)v zwY(x?_blw&{L1H?Q)>cUn0tgC;_QurgV56` zr;;oQ-$Ww$i&Gn+e2AU>tcN06Wk<7PG(kF*m733si*3YEbyZ&0D^~k)UDFZp$2FmUyp~)Kv0bTj3|51_FppUUe(p{3xw#2b8NB1P zY~ObLHzmD*54YHLzGv1h`z+tD^C{9-Y*;*M7`j9A5HUe4rrf-FQ@K<@ULM|pumP*v z;omfHcHv!7k4=5AbCZdS|P z&0f9^AGdY?l-84Fm?Gp>R?1nJxOEZLzM~~UB=@~*zw!g_>XU(K+$A_)SP$JfrdS1kQ z%(G{EKsaS}f;_VO{BFyfUw(n!s?)Zyf5D}vk#FCsh3}4@se2Y) z|IwBRNXU#i?VVZgztZpdC^XZS56^cDe>wged&kXZdV?+do9EhG>>l5xQxXLU;x9n~ zyqrk)>mrai2H0TX4#6NjlZ|p|s0(dHRZzr25}jjG@&kbwpkhx;EFP8s1KV+S&yUf~ zy*K$9gr-izlx<0B!`4Mk2M;ug9$Kh1JnOWN!m^_kUUza9x69!M61T1ZEvT$1FMqz+ zo0ASxM|bg>)778r(+Ju6?wuZ0bN~M9DcPSmAYD*>!yMb^HDPr@5(~JvV0Bb_x|pe% z8R2-s0Q>?!2NsJ`;;qOY_;5>J0&WLRN!vYI_x5YJxfSHt_uNArkz4p{=ARxa-P)co#@v2WdFs}>`nrOyx9gJ6vviK0 z$xgIRK1RaPu4J8$YpR%t)p8?~lly1rjeL#&5hInz<@s-GT|t#JMWwIAYN)P^r$+`?b%Z6aT#KxAp_2M_aGYjHDAr-^e7=6f15Rz zfrO+|Q&Qm!%9Nbe9T!ioF} zy2hgT`s$#qrdJnnN*%LLw_Le&X(VxX@hW*&~=Z zlT8UHhn!oUhWpxWHJ#z^Da5FSN+?k`HB5GLP;24pt$S|WxtKyB1eCR}Ke@e*1RZ402T}$ZlFoziJ>ENettY333sckcQ0%8bR(g+$(GaX0 zZ4aG>Z`y$Et{Zx`TrDdQLz#tp`HX7j_6d+TDIgbmX40ae7v)0j{%l#7652D~SLomh znJWbVESJk|ozFR-;gY>su3@(q*FZ6^gPdmwvJw6U!N4-JGdDQIU-<_IpRkwK zzKt?e7iFr@_U*Oytwl31n7M{fom0B2;&RR2gmgQ9eo$fv=x&!kSgAr&Qsm48Nfro0 zRS!H%_SuH!#eBhKpOmT82NJ9EES_IwPuY8eBiq>NBYPb@XhwyTUrrl=Z`rcXIaC^?MPdb(OTT|#9hzn>*)+t;wqnDOG>$WJ z9jak81YeKHdzr~CAH%g`mciQR?cO?`H#01!@lV8N?l z)X?qQpUunvTD*o-15$$0q(}`v_w5Ro(}$(`NV(AV?~2CV zr{vrfc}ZCHdJdyhw6Gc@mJI74I9}JQK0b9LUpt11W-Z?5s~RqwJT^2sVxXICD1vH; z0rjQ|h>Pr-NmPB#{g3C0AAT|$G z5G;vFhmUGuVS(_N2%7>)DKIE@VZ;ggd`^|=JNY;jJ80|Ht$fsQx3L6(Wp)mZMA5n<*tVPA;Wpd5>%>y zJ9{ec9{FIKErz@UlLT>RIFEh|w1)*lW~f@rnUN4@H!)&!t;)H$UXy6)zbxd~CpDlO z1VTBtj;gF&`NV6!{n(&|%2wK3IXSsucO?`6+qP*DCt(l}5*T1X@XshqR%}S(GM9Tn zgawqIJ~H3$$WBk3A=qkcu-gD~nJj3)P-&;i_L%;A$n5ZS9?pRQZ)mhLycB4XxVXl@ z5{U)2tgY=)D)-0}Ham54BEpbVPYfz0s0EqAb+RR+{Vs2RN?PGHK=<}3*E5}=4vY>T zIT|*Z+dyy&kuQ2(PeJyFb{~p8V0yRB$$a=iTH!$mjx{mV<-q6h7-N*7wG*QQ>#2~& z#y;Fu8-&7WYqNTB`<-#cYPzl({Kbe{5QO{?@41}2c*r0V6C(8W>MWb1^rOyxK0ZF< zrFrI^>n*@T1j)MQfKo6vSF1S=V*}zEZ}8WJ-vQNt7#%P`r=zPtf%mnOhW1=vF> zkUFxmuFtJE3>o-(?M;E=_TU4VO=p84@A!j^L!dPXNpcRET+~MBB5*Xsq2v6$-W)dK z4MSZOPZ^HJ+fe9EeiU`DfH`yV@Y3p>dr(xt2;Bk(jlF#Nva^mxThr6iwMkITK)xvcb#e3(<0zD@#O;@6UQRoE_R&)At7fn7M0SR`sKPrrRw6eQ z2~4Ta`BG?ws4W+xUBfr z(F+I(0-$dM1O$HAur3&Mkq9?EQ9gYfc8%U`R^NM@=p-SMWk!PMj+S#;DgnY32sYKw z7D+IuGj>VNc&w33zKX{nwuH*NL)m!f2$y=}? zOjXywfHAe`78RbR5w``DiExyP-$IWVK0weTY%YupZ(F9P9EjiuoLECvJq%66917il zhAmVBr}kv8L!s8mlSv!Y!cN-TIle8IQ|fEXR9h@Fc++{f7gh$dY?U@RX9h7Y$B8Qr znjV1*;wNf9hkR+%XIA9ebSU5O9QGhb8Wfz%9=9yU{1E&`A`S6rR^AmlB-dXwD`@lK z^Uo97sOIyVkN^-T24VWKgbP2X`(Civ(c%RT0@5&JlZpcTQnM@D-F15TOrRcr)?DQ< za4RjvFafihQrUq;vCZuDTlntdkUFwNw_eEM<>ghF^~-{{z%n(_N{h*+Fgf?ti@;aN z->6a4fE|3~rm`RhC(Y$@+yy!Lk<{5xCW+yp%eG>LWq<4AUS2Pd?*4qpr$l=v3N)^& z-znN<7WI=PYjGP-4s-gnnpz2jAb#f4e%G(hR}zfJ1^$<;PZPJ%SlC^PhXe9GRpx^# zDLRUdhw%U-R2jtB?9C-3NCk^s3BXc=_)Cmzut^5;d3k#BBNeS)z1r`wR2Q@^Eo|vQ z0FN#pC*L=Qd+ynE6pwXqPsbD3izHJC_(AlyZZKI<3!IlK5qFNdDU?x|LC(1Ao*e!5 zk;EyI|47l6S~!)1#C6ZZgF+020M43_PRS;QicSie-o9IrXG`H(g4ZyXEMTCFvCnDc zL>rR9*5!avS9Yw6muhPwnU2kgA%!!;vi{+E)3>ErXN6EyC4O)y91kTx3D(I(5UmCe zj(BmM>)$OiJuxs=cG@jEv&HQo$*vdzF<6#Sds||*asb;%00cCC73=CqH4s< zg8FT!E7}Jfjg4=NM)ckUJiCwVSUeJ{@wu;xqa*zbx6_$Cm-TZ%8unGI;!%5a1Kw82 zO^>P(cmr#94g5gjFjAA+&l!fPp?61px&Z=dm;_kvYVw@Uh@WnL&)}ZHRg3g44)BAh z-GI^zRiYR=CP?qeV(ESlzHTtRx~TMw!Ovc+IbgF#@J6E%rCq*nz=S_UQ+m~-Mac!>uHTTLAW zJz&Za8v`c6ZvC7@2;xG+VkP_64(%!efTzG3fUc575z!iIv zrZm%fsi8K60t$0Nn!x>$N3t8~KMY6T3y1RskX>VT)uoHI{<;hJ z^FA~rgsT(kEE?wU+56F%M%=*Pa3~y0dr;fbO7I1WCEDQ_o@DSrgD`u^peFr(jc6ZP zUqW%dK>rT7pD(+4_9Vl|A(cPUevOEoceWf&@+MDL%`ud`h+^mssnr4rko5f^0Hx!r z#Qp~7EXiKyTwPI)Gr!FyUo8lbbc>HXzNHB7Q#?iE~c|e>q+}u--pf*{r|JMyJOsdK<*5Dw^ zBsN;;=;p4zzSA}wl;%&?5^NkC46gaO8S7DUuuD*UVWFJYlFbm-FcOB61&&O1>>ry~ z;yE-};7Jldg$5~EpJ-ha%JsueJns<{p^rphJJ6%ahaMWjn>@iCGI<%)Hw3)VemJi? z^bA-!{_xDceMM|~ltX-_y$W|=I70$5z{<*v84CSFEYsE%GdvH<1g;VT&gi`?zk6ZG zbxO^sJg@(;Q{jiFz(9QC(^%3V@`szRn&l6}3yzJqKZ)7-_+=%G2nnQ~iU(%xHHD20 z^bwlJZdG+B4c@Vv;`M|T7mi|I#H_l93S$t&Ytqcs<}7m6L_0&W_tAuh(b-ZtM*@gN z0)c|wvFdr7HUF=_dJv5+f>l^j-m&pirUjUK{{SqGLX;oMhyS*dN9$85@>E{X<0% zTaVT)BQv(D#8-ag43?Zho+DF6W@op5JcRNGe~3=($jdZFR_g~CX`5jISvI66K>cL` z$shK^oYucN#`lw5O|kbe{k>tRmH;)RNz2dg9w2)T<}zaKQnEtgkiWkW{{$k*dt zgm;=k*S|<&Ctm_|9)fbvOco7JC&a<^hsCh$GWo80T+N;FB{Xeq?J@6P$p;b6`nH~% zQSCtQMrE#r=%lHoMap870eU|?4cCuRd8E&c+=mcD60(N}1(yBe1k<4l^$+t1R4=fd zv^-&Q+E#PR*AC2%bASO|LHD=H3p8;f8nIO%oJ&h5o%IQ3XO@q3L*g9 z!#XtDy`8;T3gGN%#ED6v@YZCuE5glaWFadlsgFcYx(q>mCjT%66(=lh;SolJm znP(}77O8kInKJyl-z&S-`A_3V-qOYKXaBHm-&pdy4+z&=_4J$ks=sx$K&kD#IFhjMLfO{;KKEnGF>^>kG|B|`>(vy>;w-{iuMJBZY5}ka-Vi1YcUV= zv++MwDDver5LtD{7|GGz{w*lAW#90y=egTjy552~ z#4flnAH@9bqk>7Z)&2a)$Z>c-8qiD4ev5peNMb12u>!0d6lZ2b^EWg!f2mLF%=^8m z<{Yv1zHGCeXzzgoEX2usVlq*}{+sgAx_PE01%Y;-WU~E+d(Sz_O-Cp8ZS4E#ZrJPM zbe1!)R3|wb7hr6Ko7{gNq!sYyZ}ho1IGkUWf9(j$lwO@c%SEpbePV51__RjN@a^Gm zaw5a|!XhHHUAuOP69Z9vqp*PI?%lfyLx}oIaFZxMzdD-7-{c-ZJ0iH5_X7|sh$e-% z5r8C26iY5UM+x3oh%B^2ha%uMh$q}Bgn>s= z2FU|`?$ZYeqmQCKF0YrI)zrUh#RAu37hEwTB7olf?~LwRs`$~(?M-99zh=T66J z={1F$kaR1cg?;GtwsJziVxm{vOX37Q8wboE4{m_i8VUQ%qi``xrR{B`0wE!l+tyoW z8xndB-8@Zwq=VfK?Lg$L&QqhE_V^iWb?-w70X(xMtLp0fnGlx1OVt!k4T-wK&(Zr4 z_g4WQ0vphfL$i|-*no$dO}`vsif9Q-dH3$~#gj&W;w4Ue=kD0RblZ>0!(L_qq9#^0 z4ME9>FW@HCu;>9+LkR15gbiOTI}$;j8@y>eo}H$YR6!IkF!Kk>r3bEqrLixU z8IH%0_{P90PiE+IATDbS?dmsJKSxJKx)!^ST!0ZK5~HC`#7>NIHQs1I7+vDKJ%9O? zTlcsJ$YEVPF3!l##g&2!5c-dSM#q&}wX){JrGX}H`tZ0j`)ff2TaNU%F(qHz4q75$ zi+Ib3$A#m0PKu=TxbMas_^X;vWT$Z#tnU_eAH7CM=#Qm=bPO;obi}nnNX)V%@r6Ei!tF%NNb3~MqKjg-x$;lyY}-jqNbY@#D`u6b73M!x| zURt!ikIe6Te$SQt5PlhibDp(W+WK)7n;O%tP`>uqrcIk_&}8!wW)9G>kQp4eS>1T# zGHf;3w%GkGULFHuM})4ilOnX(1iP62&N<^rh;1+0th+#mWUf1V11m)J`Lkyv%rK`P zntJiOims{xelaMR zbx11_Ud&{^yCDdg;S0 zcmDo40n%fgI@v7AwR%{Mw$NS^?elf5Eoj~bLauRIPMC`(fp+N$zgsh1E?|2v3 z&wLBhNQ57-_UxAiIZez2!xNaHB-l}UUTzF$&J={1L_}7kSm=oe%pcCOth|pvL)35I zH*C+qHFsrqhE)+WGIrV9{AS_!OyEGlR=E;1BFE*mI1!!l?{s zDp4RsgT}Ls?xmS;tV=a90UbF8A43w^k8sqZ10-F-=nL7qfyT~Mn0jkrkYsY&9tNHY zTLX*mb_$FZB;Z=2HK8BkjB+!(gp2lljEXZ%B?vI7Fm~mrxjg%L5NRJeQ7Tk%(B8Rf z6@=s!nWMxas(?-d;xELzobXnlj0WHaJ}O>zqKUL7tg4ku0uC@w1tQ|hr6+Kd;r5iR z&a1Ol=XXvF`y1MPd}Ex?j*qhpf9G$QZ5;pMQj;+~D>TtuBbk0`3im>xDyr^Dkw0|) F{{zd(eX;-m diff --git a/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_histograms/test_should_match_snapshot[one column].png b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_histograms/test_should_match_snapshot[one column].png deleted file mode 100644 index eb31137046b9e166db9cda1bd209215ded245c77..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7705 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 bWq~5zvrctIXMHRJFYHmPBg}uhc=JC1)sA$# diff --git a/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_histograms/test_should_match_snapshot[two columns with compressed visualization].png b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_histograms/test_should_match_snapshot[two columns with compressed visualization].png deleted file mode 100644 index b865e63be81fa36f562824b6e2f2876282c9bb07..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14032 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 F{{Yo}!I}U7 diff --git a/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot.1.png b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot.1.png deleted file mode 100644 index 4e5a4aac3227cfd983e356a90c009f43bbd1fd04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28546 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ⅆ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 Vxqj(eln073)ZeL>tm}C8e*m$OCinmV diff --git a/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot[functional].png b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot[functional].png deleted file mode 100644 index a91fb5bb2246ead75ead39801f2c68bfcf4ba640..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21296 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 diff --git a/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot[sorted grouped].png b/tests/safeds/data/tabular/containers/_table/__snapshots__/test_plot_lineplot/test_should_match_snapshot[sorted grouped].png deleted file mode 100644 index c86ef70525bbba65bbab19131d598c7733294828..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22212 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 NH None: - table1 = table1.add_columns(column) - # assert table1.schema == expected.schema - assert table1 == expected - - -def test_should_raise_error_if_column_name_exists() -> None: - table1 = Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}) - with pytest.raises(DuplicateColumnError): - table1.add_columns(Column("col1", ["a", "b", "c"])) - - -# TODO -# def test_should_raise_error_if_column_size_invalid() -> None: -# table1 = Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}) -# with pytest.raises(ColumnSizeError, match=r"Expected a column of size 3 but got column of size 4."): -# table1.add_columns(Column("col3", ["a", "b", "c", "d"])) diff --git a/tests/safeds/data/tabular/containers/_table/test_add_columns.py b/tests/safeds/data/tabular/containers/_table/test_add_columns.py index 34f757695..bc2d80339 100644 --- a/tests/safeds/data/tabular/containers/_table/test_add_columns.py +++ b/tests/safeds/data/tabular/containers/_table/test_add_columns.py @@ -1,95 +1,141 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Column, Table +from safeds.exceptions import DuplicateColumnError, LengthMismatchError @pytest.mark.parametrize( - ("table1", "columns", "expected"), + ("table_factory", "columns", "expected"), [ ( - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), - [Column("col3", [0, -1, -2]), Column("col4", ["a", "b", "c"])], - Table({"col1": [1, 2, 1], "col2": [1, 2, 4], "col3": [0, -1, -2], "col4": ["a", "b", "c"]}), + lambda: Table({}), + [], + Table({}), ), ( - Table({}), - [Column("col3", []), Column("col4", [])], - Table({"col3": [], "col4": []}), + lambda: Table({}), + Column("col1", [1]), + Table({"col1": [1]}), ), ( - Table({}), - [Column("col3", [1]), Column("col4", [2])], - Table({"col3": [1], "col4": [2]}), + lambda: Table({}), + Table({"col1": [1]}), + Table({"col1": [1]}), + ), + ( + lambda: Table({}), + [Column("col1", [1]), Column("col2", [2])], + Table({"col1": [1], "col2": [2]}), + ), + ( + lambda: Table({"col0": [0]}), + [], + Table({"col0": [0]}), + ), + ( + lambda: Table({"col0": [0]}), + Column("col1", [1]), + Table({"col0": [0], "col1": [1]}), + ), + ( + lambda: Table({"col0": [0]}), + Table({"col1": [1]}), + Table({"col0": [0], "col1": [1]}), + ), + ( + lambda: Table({"col0": [0]}), + [Column("col1", [1]), Column("col2", [2])], + Table({"col0": [0], "col1": [1], "col2": [2]}), ), ], - ids=["add 2 columns", "empty with empty column", "empty with filled column"], + ids=[ + "empty table, empty list", + "empty table, single column", + "empty table, single table", + "empty table, multiple columns", + "non-empty table, empty list", + "non-empty table, single column", + "empty table, single table", + "non-empty table, multiple columns", + ], ) -def test_should_add_columns(table1: Table, columns: list[Column], expected: Table) -> None: - table1 = table1.add_columns(columns) - # assert table1.schema == expected.schema - assert table1 == expected +class TestHappyPath: + def test_should_add_columns( + self, + table_factory: Callable[[], Table], + columns: Column | list[Column] | Table, + expected: Table, + ) -> None: + actual = table_factory().add_columns(columns) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + columns: Column | list[Column] | Table, + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.add_columns(columns) + assert original == table_factory() @pytest.mark.parametrize( - ("table1", "table2", "expected"), + ("table", "columns"), [ ( - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), - Table({"col3": [0, -1, -2], "col4": ["a", "b", "c"]}), - Table({"col1": [1, 2, 1], "col2": [1, 2, 4], "col3": [0, -1, -2], "col4": ["a", "b", "c"]}), + Table({"col1": [1]}), + Column("col2", [1, 2]), ), - (Table({}), Table({"col1": [1, 2], "col2": [60, 2]}), Table({"col1": [1, 2], "col2": [60, 2]})), ( - Table({"col1": [1, 2], "col2": [60, 2]}), - Table({}), - Table({"col1": [1, 2], "col2": [60, 2]}), + Table({"col1": [1]}), + [Column("col2", [1, 2])], ), - (Table({"yeet": [], "col": []}), Table({"gg": []}), Table({"yeet": [], "col": [], "gg": []})), + ( + Table({"col1": [1]}), + Table({"col2": [1, 2]}), + ), + ], + ids=[ + "single new column", + "list of new columns", + "table of new columns", ], - ids=["add a table with 2 columns", "empty add filled", "filled add empty", "rowless"], ) -def test_should_add_columns_from_table(table1: Table, table2: Table, expected: Table) -> None: - table1 = table1.add_table_as_columns(table2) # TODO: move to separate test file - assert table1.schema == expected.schema - assert table1 == expected - +def test_should_raise_if_row_counts_differ(table: Table, columns: Column | list[Column] | Table) -> None: + with pytest.raises(LengthMismatchError): + table.add_columns(columns) -# TODO - separate test for add_table_as_columns and a new one here -# @pytest.mark.parametrize( -# ("table", "columns", "error_message_regex"), -# [ -# ( -# Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), -# [Column("col3", ["a", "b", "c", "d"]), Column("col4", ["e", "f", "g", "h"])], -# r"Expected a column of size 3 but got column of size 4.", -# ), -# ], -# ids=["Two Columns with too many values"], -# ) -# def test_should_raise_error_if_column_size_invalid( -# table: Table, -# columns: list[Column] | Table, -# error_message_regex: str, -# ) -> None: -# with pytest.raises(ColumnSizeError, match=error_message_regex): -# table.add_columns(columns) -# TODO - separate test for add_table_as_columns and a new one here -# @pytest.mark.parametrize( -# ("table", "columns", "error_message_regex"), -# [ -# ( -# Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), -# [Column("col2", ["a", "b", "c"]), Column("col3", [2, 3, 4])], -# r"Column 'col2' already exists.", -# ), -# ], -# ids=["Column already exists"], -# ) -# def test_should_raise_error_if_column_name_in_result_column( -# table: Table, -# columns: list[Column] | Table, -# error_message_regex: str, -# ) -> None: -# with pytest.raises(DuplicateColumnError, match=error_message_regex): -# table.add_columns(columns) +@pytest.mark.parametrize( + ("table", "columns"), + [ + ( + Table({"col1": [1]}), + Column("col1", [1]), + ), + ( + Table({"col1": [1]}), + [Column("col1", [1])], + ), + ( + Table({"col1": [1]}), + Table({"col1": [1]}), + ), + ( + Table({}), + [Column("col1", [1]), Column("col1", [2])], + ), + ], + ids=[ + "single new column clashes with existing column", + "list of new columns clashes with existing column", + "table of new columns clashes with existing column", + "new columns clash with each", + ], +) +def test_should_raise_if_duplicate_column_name(table: Table, columns: Column | list[Column] | Table) -> None: + with pytest.raises(DuplicateColumnError): + table.add_columns(columns) diff --git a/tests/safeds/data/tabular/containers/_table/test_add_computed_column.py b/tests/safeds/data/tabular/containers/_table/test_add_computed_column.py new file mode 100644 index 000000000..c0920d5de --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_add_computed_column.py @@ -0,0 +1,69 @@ +from collections.abc import Callable + +import pytest + +from safeds.data.tabular.containers import Cell, Row, Table +from safeds.exceptions import DuplicateColumnError + + +@pytest.mark.parametrize( + ("table_factory", "name", "computer", "expected"), + [ + ( + lambda: Table({}), + "col1", + lambda _: Cell.from_literal(None), + Table({"col1": []}), + ), + ( + lambda: Table({"col1": []}), + "col2", + lambda _: Cell.from_literal(None), + Table({"col1": [], "col2": []}), + ), + ( + lambda: Table({"col1": [1, 2, 3]}), + "col2", + lambda _: Cell.from_literal(None), + Table({"col1": [1, 2, 3], "col2": [None, None, None]}), + ), + ( + lambda: Table({"col1": [1, 2, 3]}), + "col2", + lambda row: 2 * row["col1"], + Table({"col1": [1, 2, 3], "col2": [2, 4, 6]}), + ), + ], + ids=[ + "empty", + "no rows", + "non-empty, constant value", + "non-empty, computed value", + ], +) +class TestHappyPath: + def test_should_add_computed_column( + self, + table_factory: Callable[[], Table], + name: str, + computer: Callable[[Row], Cell], + expected: Table, + ) -> None: + actual = table_factory().add_computed_column(name, computer) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + name: str, + computer: Callable[[Row], Cell], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.add_computed_column(name, computer) + assert original == table_factory() + + +def test_should_raise_if_duplicate_column_name() -> None: + with pytest.raises(DuplicateColumnError): + Table({"col1": []}).add_computed_column("col1", lambda row: row["col1"]) diff --git a/tests/safeds/data/tabular/containers/_table/test_add_index_column.py b/tests/safeds/data/tabular/containers/_table/test_add_index_column.py new file mode 100644 index 000000000..ff83709a7 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_add_index_column.py @@ -0,0 +1,74 @@ +from collections.abc import Callable + +import pytest + +from safeds.data.tabular.containers import Table +from safeds.exceptions import DuplicateColumnError, OutOfBoundsError + + +@pytest.mark.parametrize( + ("table_factory", "name", "first_index", "expected"), + [ + ( + lambda: Table({}), + "id", + 0, + Table({"id": []}), + ), + ( + lambda: Table({"col1": []}), + "id", + 0, + Table({"id": [], "col1": []}), + ), + ( + lambda: Table({"col1": [1, 2, 3]}), + "id", + 0, + Table({"id": [0, 1, 2], "col1": [1, 2, 3]}), + ), + ( + lambda: Table({"col1": [1, 2, 3]}), + "id", + 10, + Table({"id": [10, 11, 12], "col1": [1, 2, 3]}), + ), + ], + ids=[ + "empty", + "no rows", + "non-empty (first index 0)", + "non-empty (first index 10)", + ], +) +class TestHappyPath: + def test_should_add_index_column( + self, + table_factory: Callable[[], Table], + name: str, + first_index: int, + expected: Table, + ) -> None: + actual = table_factory().add_index_column(name, first_index=first_index) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + name: str, + first_index: int, + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.add_index_column(name, first_index=first_index) + assert original == table_factory() + + +def test_should_raise_if_duplicate_column_name() -> None: + with pytest.raises(DuplicateColumnError): + Table({"col1": []}).add_index_column("col1") + + +def test_should_raise_if_first_index_is_negative() -> None: + with pytest.raises(OutOfBoundsError): + Table({}).add_index_column("col1", first_index=-1) diff --git a/tests/safeds/data/tabular/containers/_table/test_add_tables_as_columns.py b/tests/safeds/data/tabular/containers/_table/test_add_tables_as_columns.py new file mode 100644 index 000000000..38fc99198 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_add_tables_as_columns.py @@ -0,0 +1,135 @@ +from collections.abc import Callable + +import pytest + +from safeds.data.tabular.containers import Table +from safeds.exceptions import DuplicateColumnError, LengthMismatchError + + +@pytest.mark.parametrize( + ("table_factory", "others_factory", "expected"), + [ + ( + lambda: Table({}), + lambda: Table({}), + Table({}), + ), + ( + lambda: Table({}), + lambda: Table({"col1": [1]}), + Table({"col1": [1]}), + ), + ( + lambda: Table({"col1": [1]}), + lambda: Table({}), + Table({"col1": [1]}), + ), + ( + lambda: Table({"col1": [1]}), + lambda: Table({"col2": [2]}), + Table({"col1": [1], "col2": [2]}), + ), + ( + lambda: Table({"col1": [1]}), + lambda: [ + Table({"col2": [2]}), + Table({"col3": [3]}), + ], + Table({"col1": [1], "col2": [2], "col3": [3]}), + ), + ], + ids=[ + "empty, empty", + "empty, non-empty", + "non-empty, empty", + "non-empty, non-empty", + "multiple tables", + ], +) +class TestHappyPath: + def test_should_add_columns( + self, + table_factory: Callable[[], Table], + others_factory: Callable[[], Table | list[Table]], + expected: Table, + ) -> None: + actual = table_factory().add_tables_as_columns(others_factory()) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + others_factory: Callable[[], Table | list[Table]], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.add_tables_as_columns(others_factory()) + assert original == table_factory() + + def test_should_not_mutate_others( + self, + table_factory: Callable[[], Table], + others_factory: Callable[[], Table | list[Table]], + expected: Table, # noqa: ARG002 + ) -> None: + original = others_factory() + table_factory().add_tables_as_columns(original) + assert original == others_factory() + + +@pytest.mark.parametrize( + ("table", "others"), + [ + ( + Table({"col1": [1]}), + Table({"col2": [1, 2]}), + ), + ( + Table({"col1": [1]}), + [ + Table({"col2": [1]}), + Table({"col3": [1, 2]}), + ], + ), + ], + ids=[ + "one table", + "multiple tables", + ], +) +def test_should_raise_if_row_counts_differ(table: Table, others: Table | list[Table]) -> None: + with pytest.raises(LengthMismatchError): + table.add_tables_as_columns(others) + + +@pytest.mark.parametrize( + ("table", "others"), + [ + ( + Table({"col1": [1]}), + Table({"col1": [1]}), + ), + ( + Table({"col1": [1]}), + [ + Table({"col2": [1]}), + Table({"col1": [1]}), + ], + ), + ( + Table({"col1": [1]}), + [ + Table({"col2": [1]}), + Table({"col2": [1]}), + ], + ), + ], + ids=[ + "one table", + "multiple tables, clash with receiver", + "multiple tables, clash with other table", + ], +) +def test_should_raise_if_duplicate_column_name(table: Table, others: Table | list[Table]) -> None: + with pytest.raises(DuplicateColumnError): + table.add_tables_as_columns(others) diff --git a/tests/safeds/data/tabular/containers/_table/test_add_tables_as_rows.py b/tests/safeds/data/tabular/containers/_table/test_add_tables_as_rows.py new file mode 100644 index 000000000..d55e58791 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_add_tables_as_rows.py @@ -0,0 +1,121 @@ +from collections.abc import Callable + +import pytest + +from safeds.data.tabular.containers import Table +from safeds.exceptions import SchemaError + + +@pytest.mark.parametrize( + ("table_factory", "others_factory", "expected"), + [ + ( + lambda: Table({}), + lambda: Table({}), + Table({}), + ), + ( + lambda: Table({"col1": []}), + lambda: Table({"col1": []}), + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [1]}), + lambda: Table({"col1": [2]}), + Table({"col1": [1, 2]}), + ), + ( + lambda: Table({"col1": [1]}), + lambda: [ + Table({"col1": [2]}), + Table({"col1": [3]}), + ], + Table({"col1": [1, 2, 3]}), + ), + ], + ids=[ + "empty, empty", + "no rows, no rows", + "with data, with data", + "multiple tables", + ], +) +class TestHappyPath: + def test_should_add_rows( + self, + table_factory: Callable[[], Table], + others_factory: Callable[[], Table | list[Table]], + expected: Table, + ) -> None: + actual = table_factory().add_tables_as_rows(others_factory()) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + others_factory: Callable[[], Table | list[Table]], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.add_tables_as_rows(others_factory()) + assert original == table_factory() + + def test_should_not_mutate_others( + self, + table_factory: Callable[[], Table], + others_factory: Callable[[], Table | list[Table]], + expected: Table, # noqa: ARG002 + ) -> None: + original = others_factory() + table_factory().add_tables_as_rows(original) + assert original == others_factory() + + +@pytest.mark.parametrize( + ("table", "others"), + [ + ( + Table({}), + Table({"col1": [1]}), + ), + ( + Table({"col1": [], "col2": []}), + Table({"col1": []}), + ), + ( + Table({"col1": []}), + Table({"col1": [], "col2": []}), + ), + ( + Table({"col1": []}), + Table({"col2": []}), + ), + ( + Table({"col1": [], "col2": []}), + Table({"col2": [], "col1": []}), + ), + ( + Table({"col1": [1]}), + Table({"col1": ["a"]}), + ), + ( + Table({"col1": []}), + [ + Table({"col1": []}), + Table({"col2": []}), + ], + ), + ], + ids=[ + "empty table, non-empty table", # polars does not raise for this, so we need to check it upfront + "too few columns", + "too many columns", + "different column names", + "swapped columns", + "different column types", + "multiple tables", + ], +) +def test_should_raise_if_schemas_differ(table: Table, others: Table | list[Table]) -> None: + with pytest.raises(SchemaError): + table.add_tables_as_rows(others) diff --git a/tests/safeds/data/tabular/containers/_table/test_column_count.py b/tests/safeds/data/tabular/containers/_table/test_column_count.py index ae0719d8a..e6b7f0499 100644 --- a/tests/safeds/data/tabular/containers/_table/test_column_count.py +++ b/tests/safeds/data/tabular/containers/_table/test_column_count.py @@ -8,9 +8,13 @@ [ (Table({}), 0), (Table({"col1": []}), 1), - (Table({"col1": [], "col2": []}), 2), + (Table({"col1": [1], "col2": [1]}), 2), + ], + ids=[ + "empty", + "no rows", + "with data", ], - ids=["empty", "a column", "2 columns"], ) def test_should_return_number_of_columns(table: Table, expected: int) -> None: assert table.column_count == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_column_names.py b/tests/safeds/data/tabular/containers/_table/test_column_names.py index 809ad7eec..645db455f 100644 --- a/tests/safeds/data/tabular/containers/_table/test_column_names.py +++ b/tests/safeds/data/tabular/containers/_table/test_column_names.py @@ -6,11 +6,15 @@ @pytest.mark.parametrize( ("table", "expected"), [ - (Table({"col1": [1], "col2": [1]}), ["col1", "col2"]), - (Table({"col": [], "gg": []}), ["col", "gg"]), (Table({}), []), + (Table({"col1": []}), ["col1"]), + (Table({"col1": [1], "col2": [1]}), ["col1", "col2"]), + ], + ids=[ + "empty", + "no rows", + "with data", ], - ids=["Integer", "rowless", "empty"], ) -def test_should_compare_column_names(table: Table, expected: list) -> None: +def test_should_return_column_names(table: Table, expected: list[str]) -> None: assert table.column_names == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_count_rows_if.py b/tests/safeds/data/tabular/containers/_table/test_count_rows_if.py index 638675d49..408326559 100644 --- a/tests/safeds/data/tabular/containers/_table/test_count_rows_if.py +++ b/tests/safeds/data/tabular/containers/_table/test_count_rows_if.py @@ -31,7 +31,8 @@ def test_should_handle_boolean_logic( expected: int, ) -> None: table = Table({"a": values}) - assert table.count_rows_if(lambda row: row["a"] < 2) == expected + actual = table.count_rows_if(lambda row: row["a"] < 2) + assert actual == expected @pytest.mark.parametrize( @@ -62,4 +63,5 @@ def test_should_handle_kleene_logic( expected: int | None, ) -> None: table = Table({"a": values}) - assert table.count_rows_if(lambda row: row["a"] < 2, ignore_unknown=False) == expected + actual = table.count_rows_if(lambda row: row["a"] < 2, ignore_unknown=False) + assert actual == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_dataframe.py b/tests/safeds/data/tabular/containers/_table/test_dataframe.py index b5e1d76c9..b7d4fd8af 100644 --- a/tests/safeds/data/tabular/containers/_table/test_dataframe.py +++ b/tests/safeds/data/tabular/containers/_table/test_dataframe.py @@ -8,16 +8,17 @@ "table", [ Table({}), - Table({"a": [1, 2], "b": [3, 4]}), + Table({"col1": []}), + Table({"col1": [1, 2], "col2": [3, 4]}), ], ids=[ "empty", + "no rows", "non-empty", ], ) -def test_should_restore_table_from_exchange_object(table: Table) -> None: +def test_should_be_able_to_restore_table_from_exchange_object(table: Table) -> None: exchange_object = table.__dataframe__() restored = Table._from_polars_data_frame(from_dataframe(exchange_object)) - assert restored.schema == table.schema assert restored == table diff --git a/tests/safeds/data/tabular/containers/_table/test_eq.py b/tests/safeds/data/tabular/containers/_table/test_eq.py index ef9bcb116..da61b8564 100644 --- a/tests/safeds/data/tabular/containers/_table/test_eq.py +++ b/tests/safeds/data/tabular/containers/_table/test_eq.py @@ -6,45 +6,125 @@ @pytest.mark.parametrize( - ("table1", "table2", "expected"), + ("table_1", "table_2", "expected"), [ - (Table({}), Table({}), True), - (Table({"a": [], "b": []}), Table({"a": [], "b": []}), True), - (Table({"col1": [1]}), Table({"col1": [1]}), True), - (Table({"col1": [1]}), Table({"col2": [1]}), False), - (Table({"col1": [1, 2, 3]}), Table({"col1": [1, 1, 3]}), False), - (Table({"col1": [1, 2, 3]}), Table({"col1": ["1", "2", "3"]}), False), + # equal (empty) + ( + Table({}), + Table({}), + True, + ), + # equal (no rows) + ( + Table({"col1": []}), + Table({"col1": []}), + True, + ), + # equal (with data) + ( + Table({"col1": [1], "col2": [2]}), + Table({"col1": [1], "col2": [2]}), + True, + ), + # not equal (too few columns) + ( + Table({"col1": [1]}), + Table({}), + False, + ), + # not equal (too many columns) + ( + Table({}), + Table({"col1": [1]}), + False, + ), + # not equal (different column order) + ( + Table({"col1": [1], "col2": [2]}), + Table({"col2": [2], "col1": [1]}), + False, + ), + # not equal (different column names) + ( + Table({"col1": [1]}), + Table({"col2": [1]}), + False, + ), + # not equal (different types) + ( + Table({"col1": [1]}), + Table({"col1": ["1"]}), + False, + ), + # not equal (too few rows) + ( + Table({"col1": [1, 2]}), + Table({"col1": [1]}), # Needs at least one value, so the types match + False, + ), + # not equal (too many rows) + ( + Table({"col1": [1]}), # Needs at least one value, so the types match + Table({"col1": [1, 2]}), + False, + ), + # not equal (different row order) + ( + Table({"col1": [1, 2]}), + Table({"col1": [2, 1]}), + False, + ), + # not equal (different values) + ( + Table({"col1": [1, 2]}), + Table({"col1": [1, 3]}), + False, + ), ], ids=[ - "empty table", - "rowless table", - "equal tables", - "different column names", - "different values", - "different types", + # Equal + "equal (empty)", + "equal (no rows)", + "equal (with data)", + # Not equal because of columns + "not equal (too few columns)", + "not equal (too many columns)", + "not equal (different column order)", + "not equal (different column names)", + "not equal (different types)", + # Not equal because of rows + "not equal (too few rows)", + "not equal (too many rows)", + "not equal (different row order)", + "not equal (different values)", ], ) -def test_should_return_whether_two_tables_are_equal(table1: Table, table2: Table, expected: bool) -> None: - assert (table1.__eq__(table2)) == expected +def test_should_return_whether_tables_are_equal(table_1: Table, table_2: Table, expected: bool) -> None: + assert (table_1.__eq__(table_2)) == expected @pytest.mark.parametrize( "table", - [Table({}), Table({"col1": [1]})], + [ + Table({}), + Table({"col1": []}), + Table({"col1": [1]}), + ], ids=[ "empty", + "no rows", "non-empty", ], ) -def test_should_return_true_if_objects_are_identical(table: Table) -> None: +def test_should_return_true_if_tables_are_identical(table: Table) -> None: assert (table.__eq__(table)) is True @pytest.mark.parametrize( ("table", "other"), [ - (Table({"col1": [1]}), None), - (Table({"col1": [1]}), Column("a", [])), + (Table({}), None), + (Table({}), Column("col1", [])), ], ids=[ "Table vs. None", diff --git a/tests/safeds/data/tabular/containers/_table/test_filter_rows.py b/tests/safeds/data/tabular/containers/_table/test_filter_rows.py new file mode 100644 index 000000000..a53034e7b --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_filter_rows.py @@ -0,0 +1,63 @@ +from collections.abc import Callable + +import pytest + +from safeds.data.tabular.containers import Cell, Row, Table + + +@pytest.mark.parametrize( + ("table_factory", "predicate", "expected"), + [ + ( + lambda: Table({}), + lambda _: Cell.from_literal(False), # noqa: FBT003 + Table({}), + ), + ( + lambda: Table({"col1": []}), + lambda _: Cell.from_literal(False), # noqa: FBT003 + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [1, 2]}), + lambda row: row["col1"] <= 0, + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [1, 2]}), + lambda row: row["col1"] <= 1, + Table({"col1": [1]}), + ), + ( + lambda: Table({"col1": [1, 2]}), + lambda row: row["col1"] <= 2, + Table({"col1": [1, 2]}), + ), + ], + ids=[ + "empty", + "no rows", + "no matches", + "some matches", + "only matches", + ], +) +class TestHappyPath: + def test_should_filter_rows( + self, + table_factory: Callable[[], Table], + predicate: Callable[[Row], Cell[bool]], + expected: Table, + ) -> None: + actual = table_factory().filter_rows(predicate) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + predicate: Callable[[Row], Cell[bool]], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.filter_rows(predicate) + assert original == table_factory() diff --git a/tests/safeds/data/tabular/containers/_table/test_filter_rows_by_column.py b/tests/safeds/data/tabular/containers/_table/test_filter_rows_by_column.py new file mode 100644 index 000000000..56a7f4c5e --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_filter_rows_by_column.py @@ -0,0 +1,69 @@ +from collections.abc import Callable + +import pytest + +from safeds.data.tabular.containers import Cell, Table +from safeds.exceptions import ColumnNotFoundError + + +@pytest.mark.parametrize( + ("table_factory", "name", "predicate", "expected"), + [ + ( + lambda: Table({"col1": [], "col2": []}), + "col1", + lambda _: Cell.from_literal(False), # noqa: FBT003 + Table({"col1": [], "col2": []}), + ), + ( + lambda: Table({"col1": [1, 2], "col2": [-1, -2]}), + "col1", + lambda cell: cell <= 0, + Table({"col1": [], "col2": []}), + ), + ( + lambda: Table({"col1": [1, 2], "col2": [-1, -2]}), + "col1", + lambda cell: cell <= 1, + Table({"col1": [1], "col2": [-1]}), + ), + ( + lambda: Table({"col1": [1, 2], "col2": [-1, -2]}), + "col1", + lambda cell: cell <= 2, + Table({"col1": [1, 2], "col2": [-1, -2]}), + ), + ], + ids=[ + "no rows", + "no matches", + "some matches", + "only matches", + ], +) +class TestHappyPath: + def test_should_filter_rows( + self, + table_factory: Callable[[], Table], + name: str, + predicate: Callable[[Cell], Cell[bool]], + expected: Table, + ) -> None: + actual = table_factory().filter_rows_by_column(name, predicate) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + name: str, + predicate: Callable[[Cell], Cell[bool]], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.filter_rows_by_column(name, predicate) + assert original == table_factory() + + +def test_should_raise_if_column_does_not_exist() -> None: + with pytest.raises(ColumnNotFoundError): + Table({}).filter_rows_by_column("col1", lambda cell: cell <= 0) diff --git a/tests/safeds/data/tabular/containers/_table/test_from_columns.py b/tests/safeds/data/tabular/containers/_table/test_from_columns.py index 01762699b..e4f58784c 100644 --- a/tests/safeds/data/tabular/containers/_table/test_from_columns.py +++ b/tests/safeds/data/tabular/containers/_table/test_from_columns.py @@ -1,13 +1,14 @@ import pytest from safeds.data.tabular.containers import Column, Table -from safeds.exceptions import ColumnLengthMismatchError, DuplicateColumnError +from safeds.exceptions import DuplicateColumnError, LengthMismatchError @pytest.mark.parametrize( ("columns", "expected"), [ ([], Table({})), + (Column("A", []), Table({"A": []})), ( [ Column("A", [1, 2]), @@ -22,19 +23,20 @@ ), ], ids=[ - "empty", - "non-empty", + "empty list", + "single column", + "non-empty list", ], ) -def test_should_create_table_from_list_of_columns(columns: list[Column], expected: Table) -> None: +def test_should_create_table_from_columns(columns: Column | list[Column], expected: Table) -> None: assert Table.from_columns(columns) == expected -def test_should_raise_error_if_column_lengths_mismatch() -> None: - with pytest.raises(ColumnLengthMismatchError): +def test_should_raise_if_row_counts_differ() -> None: + with pytest.raises(LengthMismatchError): Table.from_columns([Column("col1", []), Column("col2", [1])]) -def test_should_raise_error_if_duplicate_column_name() -> None: +def test_should_raise_if_duplicate_column_name() -> None: with pytest.raises(DuplicateColumnError): Table.from_columns([Column("col1", []), Column("col1", [])]) diff --git a/tests/safeds/data/tabular/containers/_table/test_from_csv_file.py b/tests/safeds/data/tabular/containers/_table/test_from_csv_file.py index d8971aa04..75914f016 100644 --- a/tests/safeds/data/tabular/containers/_table/test_from_csv_file.py +++ b/tests/safeds/data/tabular/containers/_table/test_from_csv_file.py @@ -10,39 +10,35 @@ @pytest.mark.parametrize( ("path", "expected"), [ - ("table.csv", Table({"A": ["❔"], "B": [2]})), - (Path("table.csv"), Table({"A": ["❔"], "B": [2]})), - ("emptytable.csv", Table({})), + ("csv/empty.csv", Table({})), + ("csv/non-empty.csv", Table({"A": [1], "B": [2]})), + ("csv/special-character.csv", Table({"A": ["❔"], "B": [2]})), + ("csv/empty", Table({})), + ], + ids=[ + "empty", + "non-empty", + "special character", + "missing extension", ], - ids=["by String", "by path", "empty"], ) -def test_should_create_table_from_csv_file(path: str | Path, expected: Table) -> None: - table = Table.from_csv_file(resolve_resource_path(path)) - assert table.schema == expected.schema - assert table == expected +class TestShouldCreateTableFromCsvFile: + def test_path_as_string(self, path: str, expected: Table) -> None: + path_as_string = resolve_resource_path(path) + actual = Table.from_csv_file(path_as_string) + assert actual == expected + def test_path_as_path_object(self, path: str, expected: Table) -> None: + path_as_path_object = Path(resolve_resource_path(path)) + actual = Table.from_csv_file(path_as_path_object) + assert actual == expected -@pytest.mark.parametrize( - "path", - [ - "test_table_from_csv_file_invalid.csv", - Path("test_table_from_csv_file_invalid.csv"), - ], - ids=["by String", "by path"], -) -def test_should_raise_error_if_file_not_found(path: str | Path) -> None: + +def test_should_raise_if_file_not_found() -> None: with pytest.raises(FileNotFoundError): - Table.from_csv_file(resolve_resource_path(path)) + Table.from_csv_file(resolve_resource_path("not-found.csv")) -@pytest.mark.parametrize( - "path", - [ - "invalid_file_extension.file_extension", - Path("invalid_file_extension.file_extension"), - ], - ids=["by String", "by path"], -) -def test_should_raise_error_if_wrong_file_extension(path: str | Path) -> None: +def test_should_raise_if_wrong_file_extension() -> None: with pytest.raises(FileExtensionError): - Table.from_csv_file(resolve_resource_path(path)) + Table.from_csv_file(resolve_resource_path("invalid-extension.txt")) diff --git a/tests/safeds/data/tabular/containers/_table/test_from_dict.py b/tests/safeds/data/tabular/containers/_table/test_from_dict.py index aa579af2f..3f9a74bc1 100644 --- a/tests/safeds/data/tabular/containers/_table/test_from_dict.py +++ b/tests/safeds/data/tabular/containers/_table/test_from_dict.py @@ -3,7 +3,7 @@ import pytest from safeds.data.tabular.containers import Table -from safeds.exceptions import ColumnLengthMismatchError +from safeds.exceptions import LengthMismatchError @pytest.mark.parametrize( @@ -26,13 +26,13 @@ ), ), ], - ids=["empty", "with values"], + ids=["empty", "non-empty"], ) def test_should_create_table_from_dict(data: dict[str, list[Any]], expected: Table) -> None: - assert Table.from_dict(data).schema == expected.schema - assert Table.from_dict(data) == expected + actual = Table.from_dict(data) + assert actual == expected -def test_should_raise_error_if_columns_have_different_lengths() -> None: - with pytest.raises(ColumnLengthMismatchError, match=r"The length of at least one column differs: \na: 2\nb: 1"): +def test_should_raise_if_row_counts_differ() -> None: + with pytest.raises(LengthMismatchError): Table.from_dict({"a": [1, 2], "b": [3]}) diff --git a/tests/safeds/data/tabular/containers/_table/test_from_json_file.py b/tests/safeds/data/tabular/containers/_table/test_from_json_file.py index f884f4b8c..9e57b10ce 100644 --- a/tests/safeds/data/tabular/containers/_table/test_from_json_file.py +++ b/tests/safeds/data/tabular/containers/_table/test_from_json_file.py @@ -10,39 +10,35 @@ @pytest.mark.parametrize( ("path", "expected"), [ - ("table.json", Table({"A": ["❔"], "B": [2]})), - (Path("table.json"), Table({"A": ["❔"], "B": [2]})), - (Path("emptytable.json"), Table({})), + ("json/empty.json", Table({})), + ("json/non-empty.json", Table({"A": [1], "B": [2]})), + ("json/special-character.json", Table({"A": ["❔"], "B": [2]})), + ("json/empty", Table({})), + ], + ids=[ + "empty", + "non-empty", + "special character", + "missing extension", ], - ids=["by string", "by path", "empty"], ) -def test_should_create_table_from_json_file(path: str | Path, expected: Table) -> None: - table = Table.from_json_file(resolve_resource_path(path)) - assert table.schema == expected.schema - assert table == expected +class TestShouldCreateTableFromJsonFile: + def test_path_as_string(self, path: str, expected: Table) -> None: + path_as_string = resolve_resource_path(path) + actual = Table.from_json_file(path_as_string) + assert actual == expected + def test_path_as_path_object(self, path: str, expected: Table) -> None: + path_as_path_object = Path(resolve_resource_path(path)) + actual = Table.from_json_file(path_as_path_object) + assert actual == expected -@pytest.mark.parametrize( - "path", - [ - "test_table_from_json_file_invalid.json", - Path("test_table_from_json_file_invalid.json"), - ], - ids=["by string", "by path"], -) -def test_should_raise_error_if_file_not_found(path: str | Path) -> None: + +def test_should_raise_if_file_not_found() -> None: with pytest.raises(FileNotFoundError): - Table.from_json_file(resolve_resource_path(path)) + Table.from_json_file(resolve_resource_path("not-found.json")) -@pytest.mark.parametrize( - "path", - [ - "invalid_file_extension.file_extension", - Path("invalid_file_extension.file_extension"), - ], - ids=["by String", "by path"], -) -def test_should_raise_error_if_wrong_file_extension(path: str | Path) -> None: +def test_should_raise_if_wrong_file_extension() -> None: with pytest.raises(FileExtensionError): - Table.from_json_file(resolve_resource_path(path)) + Table.from_json_file(resolve_resource_path("invalid-extension.txt")) diff --git a/tests/safeds/data/tabular/containers/_table/test_from_parquet_file.py b/tests/safeds/data/tabular/containers/_table/test_from_parquet_file.py new file mode 100644 index 000000000..811243b10 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_from_parquet_file.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import pytest + +from safeds.data.tabular.containers import Table +from safeds.exceptions import FileExtensionError +from tests.helpers import resolve_resource_path + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("parquet/empty.parquet", Table({})), + ("parquet/non-empty.parquet", Table({"A": [1], "B": [2]})), + ("parquet/special-character.parquet", Table({"A": ["❔"], "B": [2]})), + ("parquet/empty", Table({})), + ], + ids=[ + "empty", + "non-empty", + "special character", + "missing extension", + ], +) +class TestShouldCreateTableFromParquetFile: + def test_path_as_string(self, path: str, expected: Table) -> None: + path_as_string = resolve_resource_path(path) + actual = Table.from_parquet_file(path_as_string) + assert actual == expected + expected.to_parquet_file(path_as_string) + + def test_path_as_path_object(self, path: str, expected: Table) -> None: + path_as_path_object = Path(resolve_resource_path(path)) + actual = Table.from_parquet_file(path_as_path_object) + assert actual == expected + + +def test_should_raise_if_file_not_found() -> None: + with pytest.raises(FileNotFoundError): + Table.from_parquet_file(resolve_resource_path("not-found.parquet")) + + +def test_should_raise_if_wrong_file_extension() -> None: + with pytest.raises(FileExtensionError): + Table.from_parquet_file(resolve_resource_path("invalid-extension.txt")) diff --git a/tests/safeds/data/tabular/containers/_table/test_from_polars_data_frame.py b/tests/safeds/data/tabular/containers/_table/test_from_polars_data_frame.py new file mode 100644 index 000000000..d1518b568 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_from_polars_data_frame.py @@ -0,0 +1,25 @@ +from typing import Any + +import polars as pl +import pytest + +from safeds.data.tabular.containers import Table + + +@pytest.mark.parametrize( + "data", + [ + {}, + {"col1": []}, + {"col1": [1, 2], "col2": [3, 4]}, + ], + ids=[ + "empty", + "no rows", + "non-empty", + ], +) +def test_should_create_table(data: dict[str, list[Any]]) -> None: + actual = Table._from_polars_data_frame(pl.DataFrame(data)) + expected = Table(data) + assert actual == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_from_polars_dataframe.py b/tests/safeds/data/tabular/containers/_table/test_from_polars_dataframe.py deleted file mode 100644 index 519cc7bb1..000000000 --- a/tests/safeds/data/tabular/containers/_table/test_from_polars_dataframe.py +++ /dev/null @@ -1,79 +0,0 @@ -# import pandas as pd -# import pytest -# from safeds.data.tabular.containers import Table -# from safeds.data.tabular.typing import Schema - - -# TODO -# @pytest.mark.parametrize( -# ("dataframe", "schema", "expected", "expected_table"), -# [ -# (pd.DataFrame({"col1": [0]}), Schema({"col1": Integer()}), Schema({"col1": Integer()}), Table({"col1": [0]})), -# ( -# pd.DataFrame({"col1": [0], "col2": ["a"]}), -# Schema({"col1": Integer(), "col2": String()}), -# Schema({"col1": Integer(), "col2": String()}), -# Table({"col1": [0], "col2": ["a"]}), -# ), -# ( -# pd.DataFrame({"col1": [0, 1.1]}), -# Schema({"col1": String()}), -# Schema({"col1": String()}), -# Table({"col1": [0, 1.1]}), -# ), -# ( -# pd.DataFrame({"col1": [0, 1.1], "col2": ["a", "b"]}), -# Schema({"col1": String(), "col2": String()}), -# Schema({"col1": String(), "col2": String()}), -# Table({"col1": [0, 1.1], "col2": ["a", "b"]}), -# ), -# (pd.DataFrame(), Schema({}), Schema({}), Table({})), -# ], -# ids=["one row, one column", "one row, two columns", "two rows, one column", "two rows, two columns", "empty"], -# ) -# def test_should_use_the_schema_if_passed( -# dataframe: pd.DataFrame, -# schema: Schema, -# expected: Schema, -# expected_table: Table, -# ) -> None: -# table = Table._from_pandas_dataframe(dataframe, schema) -# assert table._schema == expected -# assert table == expected_table - -# TODO -# @pytest.mark.parametrize( -# ("dataframe", "expected"), -# [ -# ( -# pd.DataFrame({"col1": [0]}), -# Schema({"col1": Integer()}), -# ), -# ( -# pd.DataFrame({"col1": [0], "col2": ["a"]}), -# Schema({"col1": Integer(), "col2": String()}), -# ), -# ( -# pd.DataFrame({"col1": [0, 1.1]}), -# Schema({"col1": RealNumber()}), -# ), -# ( -# pd.DataFrame({"col1": [0, 1.1], "col2": ["a", "b"]}), -# Schema({"col1": RealNumber(), "col2": String()}), -# ), -# ], -# ids=[ -# "one row, one column", -# "one row, two columns", -# "two rows, one column", -# "two rows, two columns", -# ], -# ) -# def test_should_infer_the_schema_if_not_passed(dataframe: pd.DataFrame, expected: Schema) -> None: -# table = Table._from_pandas_dataframe(dataframe) -# assert table._schema == expected - -# TODO -# def test_should_be_able_to_handle_empty_dataframe_with_given_schema() -> None: -# table = Table._from_pandas_dataframe(pd.DataFrame(), Schema({"col1": Integer(), "col2": Integer()})) -# table.get_column("col1") diff --git a/tests/safeds/data/tabular/containers/_table/test_from_polars_lazy_frame.py b/tests/safeds/data/tabular/containers/_table/test_from_polars_lazy_frame.py new file mode 100644 index 000000000..0c74fdfee --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_from_polars_lazy_frame.py @@ -0,0 +1,25 @@ +from typing import Any + +import polars as pl +import pytest + +from safeds.data.tabular.containers import Table + + +@pytest.mark.parametrize( + "data", + [ + {}, + {"col1": []}, + {"col1": [1, 2], "col2": [3, 4]}, + ], + ids=[ + "empty", + "no rows", + "non-empty", + ], +) +def test_should_create_table(data: dict[str, list[Any]]) -> None: + actual = Table._from_polars_lazy_frame(pl.LazyFrame(data)) + expected = Table(data) + assert actual == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_get_column.py b/tests/safeds/data/tabular/containers/_table/test_get_column.py index 928e6c2ec..7d622ae93 100644 --- a/tests/safeds/data/tabular/containers/_table/test_get_column.py +++ b/tests/safeds/data/tabular/containers/_table/test_get_column.py @@ -5,24 +5,20 @@ @pytest.mark.parametrize( - ("table1", "expected"), + ("table", "name", "expected"), [ - (Table({"col1": ["col1_1"], "col2": ["col2_1"]}), Column("col1", ["col1_1"])), + (Table({"col1": [1]}), "col1", Column("col1", [1])), + (Table({"col1": [1], "col2": [2]}), "col2", Column("col2", [2])), + ], + ids=[ + "one column", + "multiple columns", ], - ids=["First column"], ) -def test_should_get_column(table1: Table, expected: Column) -> None: - assert table1.get_column("col1") == expected +def test_should_get_column(table: Table, name: str, expected: Column) -> None: + assert table.get_column(name) == expected -@pytest.mark.parametrize( - "table", - [ - (Table({"col1": ["col1_1"], "col2": ["col2_1"]})), - (Table({})), - ], - ids=["no col3", "empty"], -) -def test_should_raise_error_if_column_name_unknown(table: Table) -> None: +def test_should_raise_if_column_name_is_unknown() -> None: with pytest.raises(ColumnNotFoundError): - table.get_column("col3") + Table({}).get_column("col1") diff --git a/tests/safeds/data/tabular/containers/_table/test_get_column_type.py b/tests/safeds/data/tabular/containers/_table/test_get_column_type.py new file mode 100644 index 000000000..a6cdf4c1f --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_get_column_type.py @@ -0,0 +1,30 @@ +import pytest + +from safeds.data.tabular.containers import Table +from safeds.data.tabular.typing import ColumnType +from safeds.exceptions import ColumnNotFoundError + + +@pytest.mark.parametrize( + ("table", "name", "expected"), + [ + ( + Table({"col1": [1]}), + "col1", + ColumnType.int64(), + ), + ( + Table({"col1": ["a"]}), + "col1", + ColumnType.string(), + ), + ], + ids=["int column", "string column"], +) +def test_should_return_data_type_of_column(table: Table, name: str, expected: ColumnType) -> None: + assert table.get_column_type(name) == expected + + +def test_should_raise_if_column_name_is_unknown() -> None: + with pytest.raises(ColumnNotFoundError): + Table({}).get_column_type("col1") diff --git a/tests/safeds/data/tabular/containers/_table/test_get_similar_columns.py b/tests/safeds/data/tabular/containers/_table/test_get_similar_columns.py deleted file mode 100644 index e6d1e9e15..000000000 --- a/tests/safeds/data/tabular/containers/_table/test_get_similar_columns.py +++ /dev/null @@ -1,38 +0,0 @@ -import pytest -from safeds._validation._check_columns_exist import _get_similar_column_names -from safeds.data.tabular.containers import Table - - -@pytest.mark.parametrize( - ("table", "column_name", "expected"), - [ - (Table({"column1": ["col1_1"], "x": ["y"], "cilumn2": ["cil2_1"]}), "col1", ["column1"]), - ( - Table( - { - "column1": ["col1_1"], - "col2": ["col2_1"], - "col3": ["col2_1"], - "col4": ["col2_1"], - "cilumn2": ["cil2_1"], - }, - ), - "clumn1", - ["column1", "cilumn2"], - ), - ( - Table({"column1": ["a"], "column2": ["b"], "column3": ["c"]}), - "notexisting", - [], - ), - ( - Table({"column1": ["col1_1"], "x": ["y"], "cilumn2": ["cil2_1"]}), - "x", - ["x"], - ), - (Table({}), "column1", []), - ], - ids=["one similar", "two similar/ dynamic increase", "no similar", "exact match", "empty table"], -) -def test_should_get_similar_column_names(table: Table, column_name: str, expected: list[str]) -> None: - assert _get_similar_column_names(table.schema, column_name) == expected # TODO: move to validation tests diff --git a/tests/safeds/data/tabular/containers/_table/test_has_column.py b/tests/safeds/data/tabular/containers/_table/test_has_column.py index cacd180e2..5e128347d 100644 --- a/tests/safeds/data/tabular/containers/_table/test_has_column.py +++ b/tests/safeds/data/tabular/containers/_table/test_has_column.py @@ -5,8 +5,12 @@ @pytest.mark.parametrize( ("table", "column", "expected"), - [(Table({"A": [1], "B": [2]}), "A", True), (Table({"A": [1], "B": [2]}), "C", False), (Table({}), "C", False)], - ids=["has column", "doesn't have column", "empty"], + [ + (Table({}), "C", False), + (Table({"A": []}), "A", True), + (Table({"A": []}), "B", False), + ], + ids=["empty", "has column", "doesn't have column"], ) def test_should_return_if_column_is_in_table(table: Table, column: str, expected: bool) -> None: assert table.has_column(column) == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_hash.py b/tests/safeds/data/tabular/containers/_table/test_hash.py index ba626061e..bf184dc21 100644 --- a/tests/safeds/data/tabular/containers/_table/test_hash.py +++ b/tests/safeds/data/tabular/containers/_table/test_hash.py @@ -1,35 +1,87 @@ +from collections.abc import Callable + import pytest +from syrupy import SnapshotAssertion from safeds.data.tabular.containers import Table @pytest.mark.parametrize( - ("table1", "table2"), + "table_factory", [ - (Table({}), Table({})), - (Table({"a": [], "b": []}), Table({"a": [], "b": []})), - (Table({"col1": [1]}), Table({"col1": [1]})), - (Table({"col1": [1, 2, 3]}), Table({"col1": [1, 1, 3]})), + lambda: Table({}), + lambda: Table({"col1": []}), + lambda: Table({"col1": [1, 2]}), ], ids=[ - "empty table", - "rowless table", - "equal tables", - "different values", + "empty", + "no rows", + "with data", ], ) -def test_should_return_same_hash_for_equal_tables(table1: Table, table2: Table) -> None: - assert hash(table1) == hash(table2) +class TestContract: + def test_should_return_same_hash_for_equal_objects(self, table_factory: Callable[[], Table]) -> None: + table_1 = table_factory() + table_2 = table_factory() + assert hash(table_1) == hash(table_2) + + def test_should_return_same_hash_in_different_processes( + self, + table_factory: Callable[[], Table], + snapshot: SnapshotAssertion, + ) -> None: + table = table_factory() + assert hash(table) == snapshot @pytest.mark.parametrize( - ("table1", "table2"), + ("table_1", "table_2"), [ - (Table({"col1": [1]}), Table({"col2": [1]})), - (Table({"col1": [1, 2, 3]}), Table({"col1": ["1", "2", "3"]})), - (Table({"col1": [1, 2, 3]}), Table({"col1": [1, 2, 3, 4]})), + # too few columns + ( + Table({"col1": [1]}), + Table({}), + ), + # too many columns + ( + Table({}), + Table({"col1": [1]}), + ), + # different column order + ( + Table({"col1": [1], "col2": [2]}), + Table({"col2": [2], "col1": [1]}), + ), + # different column names + ( + Table({"col1": [1]}), + Table({"col2": [1]}), + ), + # different types + ( + Table({"col1": [1]}), + Table({"col1": ["1"]}), + ), + # too few rows + ( + Table({"col1": [1, 2]}), + Table({"col1": [1]}), # Needs at least one value, so the types match + ), + # too many rows + ( + Table({"col1": [1]}), # Needs at least one value, so the types match + Table({"col1": [1, 2]}), + ), + ], + ids=[ + "too few columns", + "too many columns", + "different column order", + "different column names", + "different types", + "too few rows", + "too many rows", ], - ids=["different column names", "different types", "different number of rows"], ) -def test_should_return_different_hash_for_unequal_tables(table1: Table, table2: Table) -> None: - assert hash(table1) != hash(table2) +def test_should_be_good_hash(table_1: Table, table_2: Table) -> None: + assert hash(table_1) != hash(table_2) diff --git a/tests/safeds/data/tabular/containers/_table/test_init.py b/tests/safeds/data/tabular/containers/_table/test_init.py index eeafcb900..35a05a91e 100644 --- a/tests/safeds/data/tabular/containers/_table/test_init.py +++ b/tests/safeds/data/tabular/containers/_table/test_init.py @@ -1,26 +1,9 @@ import pytest from safeds.data.tabular.containers import Table -from safeds.exceptions import ColumnLengthMismatchError +from safeds.exceptions import LengthMismatchError -# TODO -# @pytest.mark.parametrize( -# ("table", "expected"), -# [ -# (Table({}), Schema({})), -# (Table({}), Schema({})), -# (Table({"col1": [0]}), Schema({"col1": Integer()})), -# ], -# ids=[ -# "empty", -# "empty (explicit)", -# "one column", -# ], -# ) -# def test_should_infer_the_schema(table: Table, expected: Schema) -> None: -# assert table.schema == expected - -def test_should_raise_error_if_columns_have_different_lengths() -> None: - with pytest.raises(ColumnLengthMismatchError, match=r"The length of at least one column differs: \na: 2\nb: 1"): +def test_should_raise_if_row_counts_differ() -> None: + with pytest.raises(LengthMismatchError): Table({"a": [1, 2], "b": [3]}) diff --git a/tests/safeds/data/tabular/containers/_table/test_inverse_transform_table.py b/tests/safeds/data/tabular/containers/_table/test_inverse_transform_table.py index 98486dfa1..a6aff2870 100644 --- a/tests/safeds/data/tabular/containers/_table/test_inverse_transform_table.py +++ b/tests/safeds/data/tabular/containers/_table/test_inverse_transform_table.py @@ -1,121 +1,50 @@ +from collections.abc import Callable + import pytest -from polars.testing import assert_frame_equal + from safeds.data.tabular.containers import Table -from safeds.data.tabular.transformation import OneHotEncoder -from safeds.exceptions import TransformerNotFittedError +from safeds.data.tabular.transformation import InvertibleTableTransformer, RangeScaler +from safeds.exceptions import NotFittedError +# We test the behavior of each transformer in their own test file. @pytest.mark.parametrize( - ("table_to_fit", "column_names", "table_to_transform"), + ("table_factory", "transformer"), [ ( - Table( - { - "a": [1.0, 0.0, 0.0, 0.0], - "b": ["a", "b", "b", "c"], - "c": [0.0, 0.0, 0.0, 1.0], - }, - ), - ["b"], - Table( - { - "a": [1.0, 0.0, 0.0, 0.0], - "b": ["a", "b", "b", "c"], - "c": [0.0, 0.0, 0.0, 1.0], - }, - ), - ), - ( - Table( - { - "a": [1.0, 0.0, 0.0, 0.0], - "b": ["a", "b", "b", "c"], - "c": [0.0, 0.0, 0.0, 1.0], - }, - ), - ["b"], - Table( - { - "c": [0.0, 0.0, 0.0, 1.0], - "b": ["a", "b", "b", "c"], - "a": [1.0, 0.0, 0.0, 0.0], - }, - ), - ), - ( - Table( - { - "a": [1.0, 0.0, 0.0, 0.0], - "b": ["a", "b", "b", "c"], - "bb": ["a", "b", "b", "c"], - }, - ), - ["b", "bb"], - Table( - { - "a": [1.0, 0.0, 0.0, 0.0], - "b": ["a", "b", "b", "c"], - "bb": ["a", "b", "b", "c"], - }, - ), + lambda: Table({"col1": [1, 2, 3]}), + RangeScaler(), ), ], ids=[ - "same table to fit and transform", - "different tables to fit and transform", - "one column name is a prefix of another column name", + "with data", ], ) -def test_should_return_original_table( - table_to_fit: Table, - column_names: list[str], - table_to_transform: Table, -) -> None: - transformer = OneHotEncoder(column_names=column_names).fit(table_to_fit) - transformed_table = transformer.transform(table_to_transform) - - result = transformed_table.inverse_transform_table(transformer) - - # Order is not guaranteed - assert set(result.column_names) == set(table_to_transform.column_names) - assert_frame_equal( - result._data_frame.select(table_to_transform.column_names), - table_to_transform._data_frame, - ) - - -def test_should_not_change_transformed_table() -> None: - table = Table( - { - "col1": ["a", "b", "b", "c"], - }, - ) - - transformer = OneHotEncoder().fit(table) - transformed_table = transformer.transform(table) - transformed_table.inverse_transform_table(transformer) - - expected = Table( - { - "col1__a": [1.0, 0.0, 0.0, 0.0], - "col1__b": [0.0, 1.0, 1.0, 0.0], - "col1__c": [0.0, 0.0, 0.0, 1.0], - }, - ) - - assert transformed_table == expected - - -def test_should_raise_error_if_not_fitted() -> None: - table = Table( - { - "a": [1.0, 0.0, 0.0, 0.0], - "b": [0.0, 1.0, 1.0, 0.0], - "c": [0.0, 0.0, 0.0, 1.0], - }, - ) - - transformer = OneHotEncoder() - - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): +class TestHappyPath: + def test_should_return_transformed_table( + self, + table_factory: Callable[[], Table], + transformer: InvertibleTableTransformer, + ) -> None: + original = table_factory() + fitted_transformer, transformed_table = transformer.fit_and_transform(original) + restored = transformed_table.inverse_transform_table(fitted_transformer) + assert restored == original + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + transformer: InvertibleTableTransformer, + ) -> None: + original = table_factory() + fitted_transformer = transformer.fit(original) + original.inverse_transform_table(fitted_transformer) + assert original == table_factory() + + +def test_should_raise_if_not_fitted() -> None: + table = Table({}) + transformer = RangeScaler() + + with pytest.raises(NotFittedError): table.inverse_transform_table(transformer) diff --git a/tests/safeds/data/tabular/containers/_table/test_join.py b/tests/safeds/data/tabular/containers/_table/test_join.py index 455360404..597225abd 100644 --- a/tests/safeds/data/tabular/containers/_table/test_join.py +++ b/tests/safeds/data/tabular/containers/_table/test_join.py @@ -1,100 +1,291 @@ +from collections.abc import Callable from typing import Literal import pytest from safeds.data.tabular.containers import Table -from safeds.exceptions import ColumnNotFoundError +from safeds.exceptions import ColumnNotFoundError, DuplicateColumnError, LengthMismatchError @pytest.mark.parametrize( - ("table_left", "table_right", "left_names", "right_names", "mode", "table_expected"), + ("left_table_factory", "right_table_factory", "left_names", "right_names", "mode", "expected"), [ + # inner join (empty) ( - Table({"a": [1, 2], "b": [3, 4]}), - Table({"d": [1, 5], "e": [5, 6]}), - ["a"], - ["d"], - "outer", - Table({"a": [1, None, 2], "b": [3, None, 4], "d": [1, 5, None], "e": [5, 6, None]}), + lambda: Table({"a": [], "b": []}), + lambda: Table({"c": [], "d": []}), + "a", + "c", + "inner", + Table({"a": [], "b": [], "d": []}), + ), + # inner join (missing values only) + ( + lambda: Table({"a": [None], "b": [None]}), + lambda: Table({"c": [None], "d": [None]}), + "a", + "c", + "inner", + Table({"a": [], "b": [], "d": []}), + ), + # inner join (with data) + ( + lambda: Table({"a": [1, 2], "b": [True, False]}), + lambda: Table({"c": [1, 3], "d": [True, True]}), + "a", + "c", + "inner", + Table({"a": [1], "b": [True], "d": [True]}), + ), + # inner join (two columns to join on) + ( + lambda: Table({"a": [1, 2], "b": [True, False]}), + lambda: Table({"c": [1, 3], "d": [True, True]}), + ["a", "b"], + ["c", "d"], + "inner", + Table({"a": [1], "b": [True]}), + ), + # left join (empty) + ( + lambda: Table({"a": [], "b": []}), + lambda: Table({"c": [], "d": []}), + "a", + "c", + "left", + Table({"a": [], "b": [], "d": []}), ), + # left join (missing values only) ( - Table({"a": [1, 2], "b": [3, 4]}), - Table({"d": [1, 5], "e": [5, 6]}), - ["a"], - ["d"], + lambda: Table({"a": [None], "b": [None]}), + lambda: Table({"c": [None], "d": [None]}), + "a", + "c", "left", - Table({"a": [1, 2], "b": [3, 4], "e": [5, None]}), + Table({"a": [None], "b": [None], "d": [None]}), ), + # left join (with data) ( - Table({"a": [1, 2], "b": [3, 4]}), - Table({"d": [1, 5], "e": [5, 6]}), - ["a"], - ["d"], + lambda: Table({"a": [1, 2], "b": [True, False]}), + lambda: Table({"c": [1, 3], "d": [True, True]}), + "a", + "c", + "left", + Table({"a": [1, 2], "b": [True, False], "d": [True, None]}), + ), + # left join (two columns to join on) + ( + lambda: Table({"a": [1, 2], "b": [True, False]}), + lambda: Table({"c": [1, 3], "d": [True, True]}), + ["a", "b"], + ["c", "d"], + "left", + Table({"a": [1, 2], "b": [True, False]}), + ), + # right join (empty) + ( + lambda: Table({"a": [], "b": []}), + lambda: Table({"c": [], "d": []}), + "a", + "c", "right", - Table({"b": [3, None], "d": [1, 5], "e": [5, 6]}), + Table({"b": [], "c": [], "d": []}), ), + # right join (missing values only) ( - Table({"a": [1, 2], "b": [3, 4]}), - Table({"d": [1, 5], "e": [5, 6]}), - ["a"], - ["d"], - "inner", - Table({"a": [1], "b": [3], "e": [5]}), + lambda: Table({"a": [None], "b": [None]}), + lambda: Table({"c": [None], "d": [None]}), + "a", + "c", + "right", + Table({"b": [None], "c": [None], "d": [None]}), ), + # right join (with data) ( - Table({"a": [1, 2], "b": [3, 4], "c": [5, 6]}), - Table({"d": [1, 5], "e": [5, 6], "g": [7, 9]}), - ["a", "c"], - ["d", "e"], - "inner", - Table({"a": [1], "b": [3], "c": [5], "g": [7]}), + lambda: Table({"a": [1, 2], "b": [True, False]}), + lambda: Table({"c": [1, 3], "d": [True, True]}), + "a", + "c", + "right", + Table({"b": [True, None], "c": [1, 3], "d": [True, True]}), ), + # right join (two columns to join on) ( - Table({"a": [1, 2], "b": [3, 4]}), - Table({"d": [1, 5], "e": [5, 6]}), - ["b"], - ["e"], - "inner", + lambda: Table({"a": [1, 2], "b": [True, False]}), + lambda: Table({"c": [1, 3], "d": [True, True]}), + ["a", "b"], + ["c", "d"], + "right", + Table({"c": [1, 3], "d": [True, True]}), + ), + # full join (empty) + ( + lambda: Table({"a": [], "b": []}), + lambda: Table({"c": [], "d": []}), + "a", + "c", + "full", Table({"a": [], "b": [], "d": []}), ), + # full join (missing values only) + ( + lambda: Table({"a": [None], "b": [None]}), + lambda: Table({"c": [None], "d": [None]}), + "a", + "c", + "full", + # nulls do not match each other + Table({"a": [None, None], "b": [None, None], "d": [None, None]}), + ), + # full join (with data) + ( + lambda: Table({"a": [1, 2], "b": [True, False]}), + lambda: Table({"c": [1, 3], "d": [True, True]}), + "a", + "c", + "full", + Table({"a": [1, 2, 3], "b": [True, False, None], "d": [True, None, True]}), + ), + # full join (two columns to join on) + ( + lambda: Table({"a": [1, 2], "b": [True, False]}), + lambda: Table({"c": [1, 3], "d": [True, True]}), + ["a", "b"], + ["c", "d"], + "full", + Table({"a": [1, 2, 3], "b": [True, False, True]}), + ), + ], + ids=[ + "inner join (empty)", + "inner join (missing values only)", + "inner join (with data)", + "inner join (two columns to join on)", + "left join (empty)", + "left join (missing values only)", + "left join (with data)", + "left join (two columns to join on)", + "right join (empty)", + "right join (missing values only)", + "right join (with data)", + "right join (two columns to join on)", + "full join (empty)", + "full join (missing values only)", + "full join (with data)", + "full join (two columns to join on)", ], ) -def test_should_join_two_tables( - table_left: Table, - table_right: Table, - left_names: list[str], - right_names: list[str], - mode: Literal["inner", "left", "right", "outer"], - table_expected: Table, -) -> None: - assert table_left.join(table_right, left_names, right_names, mode=mode) == table_expected +class TestHappyPath: + def test_should_join_two_tables( + self, + left_table_factory: Callable[[], Table], + right_table_factory: Callable[[], Table], + left_names: str | list[str], + right_names: str | list[str], + mode: Literal["inner", "left", "right", "full"], + expected: Table, + ) -> None: + actual = left_table_factory().join(right_table_factory(), left_names, right_names, mode=mode) + assert actual == expected + def test_should_not_mutate_receiver( + self, + left_table_factory: Callable[[], Table], + right_table_factory: Callable[[], Table], + left_names: str | list[str], + right_names: str | list[str], + mode: Literal["inner", "left", "right", "full"], + expected: Table, # noqa: ARG002 + ) -> None: + original = left_table_factory() + original.join(right_table_factory(), left_names, right_names, mode=mode) + assert original == left_table_factory() -def test_should_raise_if_columns_are_mismatched() -> None: - table_left = Table({"a": [1, 2], "b": [3, 4]}) - table_right = Table({"d": [1, 5], "e": [5, 6]}) - left_names = ["a"] - right_names = ["d", "e"] - with pytest.raises(ValueError, match="The number of columns to join on must be the same in both tables."): - table_left.join(table_right, left_names, right_names) + def test_should_not_mutate_right_table( + self, + left_table_factory: Callable[[], Table], + right_table_factory: Callable[[], Table], + left_names: str | list[str], + right_names: str | list[str], + mode: Literal["inner", "left", "right", "full"], + expected: Table, # noqa: ARG002 + ) -> None: + original = right_table_factory() + left_table_factory().join(original, left_names, right_names, mode=mode) + assert original == right_table_factory() @pytest.mark.parametrize( - ("table_left", "table_right", "left_names", "right_names"), + ("left_table", "right_table", "left_names", "right_names"), [ - (Table({"a": [1, 2], "b": [3, 4]}), Table({"d": [1, 5], "e": [5, 6]}), ["c"], ["d"]), - (Table({"a": [1, 2], "b": [3, 4]}), Table({"d": [1, 5], "e": [5, 6]}), ["a"], ["f"]), + ( + Table({"a": [], "b": []}), + Table({"c": [], "d": []}), + "unknown", + "c", + ), + ( + Table({"a": [], "b": []}), + Table({"c": [], "d": []}), + "a", + "unknown", + ), ], ids=[ - "wrong_left_name", - "wrong_right_name", + "wrong left name", + "wrong right name", ], ) -def test_should_raise_if_columns_are_missing( - table_left: Table, - table_right: Table, +def test_should_raise_if_columns_do_not_exist( + left_table: Table, + right_table: Table, left_names: list[str], right_names: list[str], ) -> None: with pytest.raises(ColumnNotFoundError): - table_left.join(table_right, left_names=left_names, right_names=right_names) + left_table.join(right_table, left_names=left_names, right_names=right_names) + + +@pytest.mark.parametrize( + ("left_table", "right_table", "left_names", "right_names"), + [ + ( + Table({"a": [], "b": []}), + Table({"c": [], "d": []}), + ["a", "a"], + ["c", "d"], + ), + ( + Table({"a": [], "b": []}), + Table({"c": [], "d": []}), + ["a", "b"], + ["c", "c"], + ), + ], + ids=[ + "duplicate left name", + "duplicate right name", + ], +) +def test_should_raise_if_columns_are_not_unique( + left_table: Table, + right_table: Table, + left_names: list[str], + right_names: list[str], +) -> None: + with pytest.raises(DuplicateColumnError): + left_table.join(right_table, left_names=left_names, right_names=right_names) + + +def test_should_raise_if_number_of_columns_to_join_on_differs() -> None: + left_table = Table({"a": [], "b": []}) + right_table = Table({"c": [], "d": []}) + with pytest.raises(LengthMismatchError, match="The number of columns to join on must be the same in both tables."): + left_table.join(right_table, ["a"], ["c", "d"]) + + +def test_should_raise_if_columns_to_join_on_are_empty() -> None: + left_table = Table({"a": []}) + right_table = Table({"b": []}) + with pytest.raises(ValueError, match="The columns to join on must not be empty."): + left_table.join(right_table, [], []) diff --git a/tests/safeds/data/tabular/containers/_table/test_remove_columns.py b/tests/safeds/data/tabular/containers/_table/test_remove_columns.py index ce2534446..244a96717 100644 --- a/tests/safeds/data/tabular/containers/_table/test_remove_columns.py +++ b/tests/safeds/data/tabular/containers/_table/test_remove_columns.py @@ -1,68 +1,83 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Table from safeds.exceptions import ColumnNotFoundError -# Test cases where no exception is expected @pytest.mark.parametrize( - ("table", "expected", "columns", "ignore_unknown_names"), + ("table_factory", "names", "ignore_unknown_names", "expected"), [ - (Table({"col1": [1, 2, 1], "col2": ["a", "b", "c"]}), Table({"col1": [1, 2, 1]}), ["col2"], True), - (Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), Table({}), ["col1", "col2"], True), - (Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), [], True), - (Table({}), Table({}), [], True), - (Table({}), Table({}), ["col1"], True), - (Table({"col1": [1, 2, 1], "col2": ["a", "b", "c"]}), Table({"col1": [1, 2, 1]}), ["col2"], False), - (Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), Table({}), ["col1", "col2"], False), ( - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), + lambda: Table({}), [], False, + Table({}), + ), + ( + lambda: Table({}), + "col1", + True, + Table({}), + ), + ( + lambda: Table({}), + ["col1", "col2"], + True, + Table({}), + ), + ( + lambda: Table({"col1": [], "col2": []}), + [], + False, + Table({"col1": [], "col2": []}), + ), + ( + lambda: Table({"col1": [], "col2": []}), + ["col2"], + False, + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [], "col2": []}), + ["col1", "col2"], + False, + Table({}), ), - (Table({}), Table({}), [], False), ], ids=[ - "one column, ignore unknown names", - "multiple columns, ignore unknown names", - "no columns, ignore unknown names", - "empty, ignore unknown names", - "missing columns, ignore unknown names", - "one column", - "multiple columns", - "no columns", - "empty", + "empty table, empty list", + "empty table, single column (ignoring unknown names)", + "empty table, multiple columns (ignoring unknown names)", + "non-empty table, empty list", + "non-empty table, single column", + "non-empty table, multiple columns", ], ) -def test_should_remove_table_columns_no_exception( - table: Table, - expected: Table, - columns: list[str], - ignore_unknown_names: bool, -) -> None: - table = table.remove_columns(columns, ignore_unknown_names=ignore_unknown_names) - assert table.schema == expected.schema - assert table == expected - assert table.row_count == expected.row_count +class TestHappyPath: + def test_should_remove_columns( + self, + table_factory: Callable[[], Table], + names: str | list[str], + ignore_unknown_names: bool, + expected: Table, + ) -> None: + actual = table_factory().remove_columns(names, ignore_unknown_names=ignore_unknown_names) + assert actual == expected + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + names: str | list[str], + ignore_unknown_names: bool, + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.remove_columns(names, ignore_unknown_names=ignore_unknown_names) + assert original == table_factory() -# Test cases where an exception is expected -@pytest.mark.parametrize( - ("table", "columns", "ignore_unknown_names"), - [ - (Table({}), ["col1"], False), - (Table({}), ["col12"], False), - ], - ids=[ - "missing columns", - "missing columns", - ], -) -def test_should_raise_error_for_unknown_columns( - table: Table, - columns: list[str], - ignore_unknown_names: bool, -) -> None: + +def test_should_raise_for_unknown_columns() -> None: with pytest.raises(ColumnNotFoundError): - table.remove_columns(columns, ignore_unknown_names=ignore_unknown_names) + Table({}).remove_columns(["col1"]) diff --git a/tests/safeds/data/tabular/containers/_table/test_remove_columns_except.py b/tests/safeds/data/tabular/containers/_table/test_remove_columns_except.py deleted file mode 100644 index 2793e7748..000000000 --- a/tests/safeds/data/tabular/containers/_table/test_remove_columns_except.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest - -from safeds.data.tabular.containers import Table -from safeds.exceptions import ColumnNotFoundError - - -@pytest.mark.parametrize( - ("table", "column_names", "expected"), - [ - ( - Table({"A": [1], "B": [2]}), - [], - Table({}), - ), - ( - Table({"A": [1], "B": [2]}), - ["A"], - Table({"A": [1]}), - ), - ( - Table({"A": [1], "B": [2]}), - ["B"], - Table({"B": [2]}), - ), - ( - Table({"A": [1], "B": [2]}), - ["A", "B"], - Table({"A": [1], "B": [2]}), - ), - # Related to https://github.com/Safe-DS/Library/issues/115 - ( - Table({"A": [1], "B": [2], "C": [3]}), - ["C", "A"], - Table({"C": [3], "A": [1]}), - ), - ( - Table({}), - [], - Table({}), - ), - ], - ids=["No Column Name", "First Column", "Second Column", "All columns", "Last and first columns", "empty"], -) -def test_should_remove_all_except_listed_columns(table: Table, column_names: list[str], expected: Table) -> None: - transformed_table = table.remove_columns_except(column_names) - assert transformed_table.schema == expected.schema - assert transformed_table == expected - if len(column_names) == 0: - assert expected.row_count == 0 - - -@pytest.mark.parametrize("table", [Table({"A": [1], "B": [2]}), Table({})], ids=["table", "empty"]) -def test_should_raise_error_if_column_name_unknown(table: Table) -> None: - with pytest.raises(ColumnNotFoundError): - table.remove_columns_except(["C"]) diff --git a/tests/safeds/data/tabular/containers/_table/test_remove_columns_with_missing_values.py b/tests/safeds/data/tabular/containers/_table/test_remove_columns_with_missing_values.py index 4ae52436d..a6a302326 100644 --- a/tests/safeds/data/tabular/containers/_table/test_remove_columns_with_missing_values.py +++ b/tests/safeds/data/tabular/containers/_table/test_remove_columns_with_missing_values.py @@ -1,34 +1,74 @@ import pytest from safeds.data.tabular.containers import Table +from safeds.exceptions import OutOfBoundsError @pytest.mark.parametrize( - ("table", "expected"), + ("table", "missing_value_ratio_threshold", "expected"), [ ( - ( - Table( - { - "col1": [None, None, None, None], - "col2": [1, 2, 3, None], - "col3": [1, 2, 3, 4], - "col4": [2, 3, 1, 4], - }, - ), - Table( - { - "col3": [1, 2, 3, 4], - "col4": [2, 3, 1, 4], - }, - ), - ) + Table({}), + 0, + Table({}), ), - (Table({}), Table({})), + ( + Table({"col1": []}), + 0, + Table({}), # All values in the column are missing + ), + ( + Table({"col1": [1, 2], "col2": [3, 4]}), + 0, + Table({"col1": [1, 2], "col2": [3, 4]}), + ), + ( + Table({"col1": [1, 2, 3], "col2": [1, 2, None], "col3": [1, None, None]}), + 0, + Table({"col1": [1, 2, 3]}), + ), + ( + Table({"col1": [1, 2, 3], "col2": [1, 2, None], "col3": [1, None, None]}), + 0.5, + Table({"col1": [1, 2, 3], "col2": [1, 2, None]}), + ), + ( + Table({"col1": [1, 2, 3], "col2": [1, 2, None], "col3": [1, None, None]}), + 1, + Table({"col1": [1, 2, 3], "col2": [1, 2, None], "col3": [1, None, None]}), + ), + ], + ids=[ + "empty", + "no rows", + "no missing values", + "some missing values (missing_value_ratio_threshold=0)", + "some missing values (missing_value_ratio_threshold=0.5)", + "some missing values (missing_value_ratio_threshold=1)", ], - ids=["some missing values", "empty"], ) -def test_should_remove_columns_with_missing_values(table: Table, expected: Table) -> None: - updated_table = table.remove_columns_with_missing_values() - assert updated_table.schema == expected.schema +def test_should_remove_columns_with_missing_values( + table: Table, + missing_value_ratio_threshold: int, + expected: Table, +) -> None: + updated_table = table.remove_columns_with_missing_values( + missing_value_ratio_threshold=missing_value_ratio_threshold, + ) assert updated_table == expected + + +@pytest.mark.parametrize( + "missing_value_ratio_threshold", + [ + -1, + 2, + ], + ids=[ + "too low", + "too high", + ], +) +def test_should_raise_if_missing_value_ratio_threshold_out_of_bounds(missing_value_ratio_threshold: float) -> None: + with pytest.raises(OutOfBoundsError): + Table({}).remove_columns_with_missing_values(missing_value_ratio_threshold=missing_value_ratio_threshold) diff --git a/tests/safeds/data/tabular/containers/_table/test_remove_duplicate_rows.py b/tests/safeds/data/tabular/containers/_table/test_remove_duplicate_rows.py index c577f17de..b8f577a7f 100644 --- a/tests/safeds/data/tabular/containers/_table/test_remove_duplicate_rows.py +++ b/tests/safeds/data/tabular/containers/_table/test_remove_duplicate_rows.py @@ -6,21 +6,20 @@ @pytest.mark.parametrize( ("table", "expected"), [ + (Table({}), Table({})), + (Table({"col1": []}), Table({"col1": []})), ( Table( { - "A": [1, 1, 1, 4], - "B": [2, 2, 2, 5], + "col1": [0, 1, 2, 1, 3], + "col2": [0, -1, -2, -1, -3], }, ), - Table({"A": [1, 4], "B": [2, 5]}), + Table({"col1": [0, 1, 2, 3], "col2": [0, -1, -2, -3]}), ), - (Table({}), Table({})), - (Table({"col1": []}), Table({"col1": []})), ], - ids=["duplicate rows", "empty", "no rows"], + ids=["empty", "no rows", "duplicate rows"], ) def test_should_remove_duplicate_rows(table: Table, expected: Table) -> None: - result_table = table.remove_duplicate_rows() - assert result_table.schema == expected.schema - assert result_table == expected + actual = table.remove_duplicate_rows() + assert actual == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_remove_non_numeric_columns.py b/tests/safeds/data/tabular/containers/_table/test_remove_non_numeric_columns.py index 7158833dd..56597b72d 100644 --- a/tests/safeds/data/tabular/containers/_table/test_remove_non_numeric_columns.py +++ b/tests/safeds/data/tabular/containers/_table/test_remove_non_numeric_columns.py @@ -1,29 +1,56 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Table @pytest.mark.parametrize( - ("table", "expected"), + ("table_factory", "expected"), [ ( - Table( - { - "col1": ["text", "text", "word", "word"], - "col3": [2, 3, 1, 4], - }, - ), - Table( - { - "col3": [2, 3, 1, 4], - }, - ), + lambda: Table({}), + Table({}), + ), + ( + lambda: Table({"col1": []}), + Table({}), + ), + ( + lambda: Table({"col1": [1, 2], "col2": [4, 5]}), + Table({"col1": [1, 2], "col2": [4, 5]}), + ), + ( + lambda: Table({"col1": [1, 2], "col2": ["a", "b"]}), + Table({"col1": [1, 2]}), + ), + ( + lambda: Table({"col1": ["a", "b"], "col2": ["a", "b"]}), + Table({}), ), - (Table({}), Table({})), ], - ids=["numerical values", "empty"], + ids=[ + "empty", + "no rows", + "only numeric columns", + "some non-numeric columns", + "only non-numeric columns", + ], ) -def test_should_remove_non_numeric_columns(table: Table, expected: Table) -> None: - updated_table = table.remove_non_numeric_columns() - assert updated_table.schema == expected.schema - assert updated_table == expected +class TestHappyPath: + def test_should_remove_non_numeric_columns( + self, + table_factory: Callable[[], Table], + expected: Table, + ) -> None: + actual = table_factory().remove_non_numeric_columns() + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.remove_non_numeric_columns() + assert original == table_factory() diff --git a/tests/safeds/data/tabular/containers/_table/test_remove_rows.py b/tests/safeds/data/tabular/containers/_table/test_remove_rows.py index 928663cb2..3bd524981 100644 --- a/tests/safeds/data/tabular/containers/_table/test_remove_rows.py +++ b/tests/safeds/data/tabular/containers/_table/test_remove_rows.py @@ -1,32 +1,63 @@ -from typing import Any +from collections.abc import Callable import pytest -from safeds.data.tabular.containers import Table +from safeds.data.tabular.containers import Cell, Row, Table @pytest.mark.parametrize( - ("table1", "filter_column", "filter_value", "table2"), + ("table_factory", "predicate", "expected"), [ ( - Table({"col1": [3, 2, 4], "col2": [1, 2, 4]}), - "col1", - 1, - Table({"col1": [3, 2, 4], "col2": [1, 2, 4]}), + lambda: Table({}), + lambda _: Cell.from_literal(False), # noqa: FBT003 + Table({}), ), ( - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), - "col1", - 1, - Table({"col1": [2], "col2": [2]}), + lambda: Table({"col1": []}), + lambda _: Cell.from_literal(False), # noqa: FBT003 + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [1, 2]}), + lambda row: row["col1"] <= 0, + Table({"col1": [1, 2]}), + ), + ( + lambda: Table({"col1": [1, 2]}), + lambda row: row["col1"] <= 1, + Table({"col1": [2]}), + ), + ( + lambda: Table({"col1": [1, 2]}), + lambda row: row["col1"] <= 2, + Table({"col1": []}), ), ], ids=[ - "no match", - "matches", + "empty", + "no rows", + "no matches", + "some matches", + "only matches", ], ) -def test_should_remove_rows(table1: Table, filter_column: str, filter_value: Any, table2: Table) -> None: - table1 = table1.remove_rows(lambda row: row[filter_column] == filter_value) - assert table1.schema == table2.schema - assert table2 == table1 +class TestHappyPath: + def test_should_remove_rows( + self, + table_factory: Callable[[], Table], + predicate: Callable[[Row], Cell[bool]], + expected: Table, + ) -> None: + actual = table_factory().remove_rows(predicate) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + predicate: Callable[[Row], Cell[bool]], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.remove_rows(predicate) + assert original == table_factory() diff --git a/tests/safeds/data/tabular/containers/_table/test_remove_rows_by_column.py b/tests/safeds/data/tabular/containers/_table/test_remove_rows_by_column.py new file mode 100644 index 000000000..6b3061bb9 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_remove_rows_by_column.py @@ -0,0 +1,69 @@ +from collections.abc import Callable + +import pytest + +from safeds.data.tabular.containers import Cell, Table +from safeds.exceptions import ColumnNotFoundError + + +@pytest.mark.parametrize( + ("table_factory", "name", "predicate", "expected"), + [ + ( + lambda: Table({"col1": [], "col2": []}), + "col1", + lambda _: Cell.from_literal(False), # noqa: FBT003 + Table({"col1": [], "col2": []}), + ), + ( + lambda: Table({"col1": [1, 2], "col2": [-1, -2]}), + "col1", + lambda cell: cell <= 0, + Table({"col1": [1, 2], "col2": [-1, -2]}), + ), + ( + lambda: Table({"col1": [1, 2], "col2": [-1, -2]}), + "col1", + lambda cell: cell <= 1, + Table({"col1": [2], "col2": [-2]}), + ), + ( + lambda: Table({"col1": [1, 2], "col2": [-1, -2]}), + "col1", + lambda cell: cell <= 2, + Table({"col1": [], "col2": []}), + ), + ], + ids=[ + "no rows", + "no matches", + "some matches", + "only matches", + ], +) +class TestHappyPath: + def test_should_remove_rows( + self, + table_factory: Callable[[], Table], + name: str, + predicate: Callable[[Cell], Cell[bool]], + expected: Table, + ) -> None: + actual = table_factory().remove_rows_by_column(name, predicate) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + name: str, + predicate: Callable[[Cell], Cell[bool]], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.remove_rows_by_column(name, predicate) + assert original == table_factory() + + +def test_should_raise_if_column_does_not_exist() -> None: + with pytest.raises(ColumnNotFoundError): + Table({}).remove_rows_by_column("col1", lambda cell: cell <= 0) diff --git a/tests/safeds/data/tabular/containers/_table/test_remove_rows_with_missing_values.py b/tests/safeds/data/tabular/containers/_table/test_remove_rows_with_missing_values.py index a30bbf9a7..0e1e1e39c 100644 --- a/tests/safeds/data/tabular/containers/_table/test_remove_rows_with_missing_values.py +++ b/tests/safeds/data/tabular/containers/_table/test_remove_rows_with_missing_values.py @@ -1,31 +1,82 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Table @pytest.mark.parametrize( - ("table", "expected"), + ("table_factory", "column_names", "expected"), [ + # empty + ( + lambda: Table({}), + None, + Table({}), + ), + # no rows + ( + lambda: Table({"col1": []}), + None, + Table({"col1": []}), + ), + # no missing values + ( + lambda: Table({"col1": [1, 2]}), + None, + Table({"col1": [1, 2]}), + ), + # missing values (all columns selected) ( - Table( - { - "col1": [None, None, "C", "A"], - "col2": [None, "2", "3", "4"], - }, - ), - Table( - { - "col1": ["C", "A"], - "col2": ["3", "4"], - }, - ), + lambda: Table({"col1": [1, 2, None], "col2": [1, None, 3], "col3": [None, 2, 3]}), + None, + Table({"col1": [], "col2": [], "col3": []}), ), - (Table({}), Table({})), + # missing values (several columns selected) + ( + lambda: Table({"col1": [1, 2, None], "col2": [1, None, 3], "col3": [None, 2, 3]}), + ["col1", "col2"], + Table({"col1": [1], "col2": [1], "col3": [None]}), + ), + # missing values (one column selected) + ( + lambda: Table({"col1": [1, 2, None], "col2": [1, None, 3], "col3": [None, 2, 3]}), + "col1", + Table({"col1": [1, 2], "col2": [1, None], "col3": [None, 2]}), + ), + # missing values (no columns selected) + ( + lambda: Table({"col1": [1, 2, None], "col2": [1, None, 3], "col3": [None, 2, 3]}), + [], + Table({"col1": [1, 2, None], "col2": [1, None, 3], "col3": [None, 2, 3]}), + ), + ], + ids=[ + "empty", + "no rows", + "no missing values", + "missing values (all columns selected)", + "missing values (several columns selected)", + "missing values (one column selected)", + "missing values (no columns selected)", ], - ids=["some missing values", "empty"], ) -def test_should_remove_rows_with_missing_values(table: Table, expected: Table) -> None: - updated_table = table.remove_rows_with_missing_values() - assert updated_table.schema == expected.schema - assert updated_table.column_count == expected.column_count - assert updated_table == expected +class TestHappyPath: + def test_should_remove_rows_with_missing_values( + self, + table_factory: Callable[[], Table], + column_names: str | list[str] | None, + expected: Table, + ) -> None: + actual = table_factory().remove_rows_with_missing_values(column_names=column_names) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + column_names: str | list[str] | None, + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.remove_rows_with_missing_values(column_names=column_names) + assert original == table_factory() diff --git a/tests/safeds/data/tabular/containers/_table/test_remove_rows_with_outliers.py b/tests/safeds/data/tabular/containers/_table/test_remove_rows_with_outliers.py index 180e2e756..db08bb701 100644 --- a/tests/safeds/data/tabular/containers/_table/test_remove_rows_with_outliers.py +++ b/tests/safeds/data/tabular/containers/_table/test_remove_rows_with_outliers.py @@ -1,265 +1,119 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Table +from safeds.exceptions import OutOfBoundsError @pytest.mark.parametrize( - ("table", "expected"), + ("table_factory", "column_names", "z_score_threshold", "expected"), [ + # empty + ( + lambda: Table({}), + None, + 1, + Table({}), + ), + # no rows + ( + lambda: Table({"col1": []}), + None, + 1, + Table({"col1": []}), + ), + # only missing values + ( + lambda: Table({"col1": [None, None]}), + None, + 1, + Table({"col1": [None, None]}), + ), + # no outliers (low threshold) ( - Table( - { - "col1": ["A", "B", "C"], - "col2": [1.0, 2.0, 3.0], - "col3": [2, 3, 1], - }, - ), - Table( - { - "col1": ["A", "B", "C"], - "col2": [1.0, 2.0, 3.0], - "col3": [2, 3, 1], - }, - ), + lambda: Table({"col1": [1, 1, 1]}), + None, + 1, + Table({"col1": [1, 1, 1]}), ), + # no outliers (high threshold) ( - Table( - { - "col1": [ - "A", - "B", - "A", - "outlier", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - ], - "col2": [1.0, 2.0, 3.0, 4.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, None], - "col3": [2, 3, 1, 1_000_000_000, 1, 1, 1, 1, 1, 1, 1, 1], - }, - ), - Table( - { - "col1": [ - "A", - "B", - "A", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - ], - "col2": [1.0, 2.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, None], - "col3": [2, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1], - }, - ), + lambda: Table({"col1": [1, 1000]}), + None, + 3, + Table({"col1": [1, 1000]}), ), + # outliers (all columns selected) ( - Table( - { - "col1": [ - "A", - "B", - "A", - "outlier_col3", - "a", - "a", - "a", - "a", - "a", - "a", - "outlier_col2", - "a", - ], - "col2": [1.0, 2.0, 3.0, 4.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1000.0, None], - "col3": [2, 3, 1, 1_000_000_000, 1, 1, 1, 1, 1, 1, 1, 1], - }, - ), - Table( - { - "col1": [ - "A", - "B", - "A", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - ], - "col2": [1.0, 2.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, None], - "col3": [2, 3, 1, 1, 1, 1, 1, 1, 1, 1], - }, - ), + lambda: Table({"col1": [1, 1, 1000], "col2": [1, 1000, 1], "col3": [1000, 1, 1]}), + None, + 1, + Table({"col1": [], "col2": [], "col3": []}), ), + # outliers (several columns selected) ( - Table( - { - "col1": [ - "A", - "B", - "A", - "positive_outlier", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "negative_outlier", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - ], - "col2": [ - 1.0, - 2.0, - 3.0, - 4.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - None, - 1.0, - 2.0, - 1.0, - 4.0, - 1.0, - 3.0, - 1.0, - 2.0, - 1.0, - 4.0, - 1.0, - ], - "col3": [ - 2, - 3, - 1, - 1_000_000_000_000, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - -1_000_000_000_000, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - ], - }, - ), - Table( - { - "col1": [ - "A", - "B", - "A", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - "a", - ], - "col2": [ - 1.0, - 2.0, - 3.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - None, - 2.0, - 1.0, - 4.0, - 1.0, - 3.0, - 1.0, - 2.0, - 1.0, - 4.0, - 1.0, - ], - "col3": [2, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], - }, - ), + lambda: Table({"col1": [1, 1, 1000], "col2": [1, 1000, 1], "col3": [1000, 1, 1]}), + ["col1", "col2"], + 1, + Table({"col1": [1], "col2": [1], "col3": [1000]}), ), + # outliers (one column selected) ( - Table( - { - "col1": [], - "col2": [], - }, - ), - Table( - { - "col1": [], - "col2": [], - }, - ), + lambda: Table({"col1": [1, 1, 1000], "col2": [1, 1000, 1], "col3": [1000, 1, 1]}), + "col1", + 1, + Table({"col1": [1, 1], "col2": [1, 1000], "col3": [1000, 1]}), + ), + # outliers (no columns selected) + ( + lambda: Table({"col1": [1, 1, 1000], "col2": [1, 1000, 1], "col3": [1000, 1, 1]}), + [], + 1, + Table({"col1": [1, 1, 1000], "col2": [1, 1000, 1], "col3": [1000, 1, 1]}), ), - (Table({}), Table({})), ], ids=[ - "no outliers", - "one outlier", - "outliers in two different columns", - "multiple outliers in one column", - "no rows", "empty", + "no rows", + "only missing values", + "no outliers (low threshold)", + "no outliers (high threshold)", + "outliers (all columns selected)", + "outliers (several columns selected)", + "outliers (one column selected)", + "outliers (no columns selected)", ], ) -def test_should_remove_rows_with_outliers(table: Table, expected: Table) -> None: - updated_table = table.remove_rows_with_outliers() - assert updated_table == expected +class TestHappyPath: + def test_should_remove_rows_with_outliers( + self, + table_factory: Callable[[], Table], + column_names: str | list[str] | None, + z_score_threshold: float, + expected: Table, + ) -> None: + actual = table_factory().remove_rows_with_outliers( + column_names=column_names, + z_score_threshold=z_score_threshold, + ) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + column_names: str | list[str] | None, + z_score_threshold: float, + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.remove_rows_with_outliers( + column_names=column_names, + z_score_threshold=z_score_threshold, + ) + assert original == table_factory() + + +def test_should_raise_if_z_score_threshold_is_negative() -> None: + with pytest.raises(OutOfBoundsError): + Table({}).remove_rows_with_outliers(z_score_threshold=-1.0) diff --git a/tests/safeds/data/tabular/containers/_table/test_rename_column.py b/tests/safeds/data/tabular/containers/_table/test_rename_column.py index 80217c667..046fdecdf 100644 --- a/tests/safeds/data/tabular/containers/_table/test_rename_column.py +++ b/tests/safeds/data/tabular/containers/_table/test_rename_column.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Table @@ -5,21 +7,49 @@ @pytest.mark.parametrize( - ("name_from", "name_to", "column_one", "column_two"), - [("A", "D", "D", "B"), ("A", "A", "A", "B")], - ids=["column renamed", "column not renamed"], + ("table_factory", "old_name", "new_name", "expected"), + [ + ( + lambda: Table({"A": [1], "B": [2]}), + "A", + "C", + Table({"C": [1], "B": [2]}), + ), + ( + lambda: Table({"A": [1], "B": [2]}), + "A", + "A", + Table({"A": [1], "B": [2]}), + ), + ], + ids=["name changed", "name unchanged"], ) -def test_should_rename_column(name_from: str, name_to: str, column_one: str, column_two: str) -> None: - table: Table = Table({"A": [1], "B": [2]}) - renamed_table = table.rename_column(name_from, name_to) - assert renamed_table.schema.column_names == [column_one, column_two] - assert renamed_table.column_names == [column_one, column_two] +class TestHappyPath: + def test_should_rename_column( + self, + table_factory: Callable[[], Table], + old_name: str, + new_name: str, + expected: Table, + ) -> None: + actual = table_factory().rename_column(old_name, new_name) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + old_name: str, + new_name: str, + expected: Table, # noqa: ARG002 + ) -> None: + original: Table = table_factory() + original.rename_column(old_name, new_name) + assert original == table_factory() -@pytest.mark.parametrize("table", [Table({"A": [1], "B": [2]}), Table({})], ids=["normal", "empty"]) -def test_should_raise_if_old_column_does_not_exist(table: Table) -> None: +def test_should_raise_if_old_column_does_not_exist() -> None: with pytest.raises(ColumnNotFoundError): - table.rename_column("C", "D") + Table({}).rename_column("A", "B") def test_should_raise_if_new_column_exists_already() -> None: diff --git a/tests/safeds/data/tabular/containers/_table/test_replace_column.py b/tests/safeds/data/tabular/containers/_table/test_replace_column.py index 38e4be07c..9a9301edf 100644 --- a/tests/safeds/data/tabular/containers/_table/test_replace_column.py +++ b/tests/safeds/data/tabular/containers/_table/test_replace_column.py @@ -1,120 +1,154 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Column, Table from safeds.exceptions import ( ColumnNotFoundError, DuplicateColumnError, + LengthMismatchError, ) @pytest.mark.parametrize( - ("table", "column_name", "columns", "expected"), + ("table_factory", "old_name", "new_columns", "expected"), [ ( - Table( - { - "A": [1, 2, 3], - "B": [4, 5, 6], - "C": ["a", "b", "c"], - }, - ), - "B", - [Column("B", ["d", "e", "f"]), Column("D", [3, 4, 5])], - Table( - { - "A": [1, 2, 3], - "B": ["d", "e", "f"], - "D": [3, 4, 5], - "C": ["a", "b", "c"], - }, - ), + lambda: Table({"before": [], "col1": [], "after": []}), + "col1", + Column("col2", []), + Table({"before": [], "col2": [], "after": []}), ), ( - Table( - { - "A": [1, 2, 3], - "B": [4, 5, 6], - "C": ["a", "b", "c"], - }, - ), - "C", - [Column("D", ["d", "e", "f"])], - Table( - { - "A": [1, 2, 3], - "B": [4, 5, 6], - "D": ["d", "e", "f"], - }, - ), + lambda: Table({"before": [], "col1": [], "after": []}), + "col1", + [], + Table({"before": [], "after": []}), ), ( - Table( - { - "A": [1, 2, 3], - "B": [4, 5, 6], - "C": ["a", "b", "c"], - }, - ), - "C", - [], - Table( - { - "A": [1, 2, 3], - "B": [4, 5, 6], - }, - ), + lambda: Table({"before": [], "col1": [], "after": []}), + "col1", + [Column("col3", []), Column("col4", [])], + Table({"before": [], "col3": [], "col4": [], "after": []}), + ), + ( + lambda: Table({"before": [], "col1": [], "after": []}), + "col1", + Table({"col3": [], "col4": []}), + Table({"before": [], "col3": [], "col4": [], "after": []}), ), + ( + lambda: Table({"col1": []}), + "col1", + Column("col1", []), + Table({"col1": []}), + ), + ], + ids=[ + "single new column", + "empty list of new columns", + "non-empty list of new columns", + "new table", + "reusing the old name", ], - ids=["multiple Columns", "one Column", "empty"], ) -def test_should_replace_column(table: Table, column_name: str, columns: list[Column], expected: Table) -> None: - result = table.replace_column(column_name, columns) - assert result.schema == expected.schema - assert result == expected +class TestHappyPath: + def test_should_replace_column( + self, + table_factory: Callable[[], Table], + old_name: str, + new_columns: list[Column], + expected: Table, + ) -> None: + actual = table_factory().replace_column(old_name, new_columns) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + old_name: str, + new_columns: list[Column], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.replace_column(old_name, new_columns) + assert original == table_factory() + + +def test_should_raise_if_old_name_is_unknown() -> None: + with pytest.raises(ColumnNotFoundError): + Table({}).replace_column("col1", [Column("a", [1, 2])]) @pytest.mark.parametrize( - ("old_column_name", "column", "error", "error_message"), + ("table", "old_name", "new_columns"), [ - ("D", [Column("C", ["d", "e", "f"])], ColumnNotFoundError, None), ( - "C", - [Column("B", ["d", "e", "f"]), Column("D", [3, 2, 1])], - DuplicateColumnError, - None, + Table({"col1": [], "col2": []}), + "col1", + Column("col2", []), + ), + ( + Table({"col1": [], "col2": []}), + "col1", + [Column("col2", [])], + ), + ( + Table({"col1": [], "col2": []}), + "col1", + Table({"col2": []}), + ), + ( + Table({"col1": []}), + "col1", + [Column("col2", []), Column("col2", [])], ), - # TODO - # ( - # "C", - # [Column("D", [7, 8]), Column("E", ["c", "b"])], - # ColumnSizeError, - # r"Expected a column of size 3 but got column of size 2.", - # ), ], ids=[ - "ColumnNotFoundError", - "DuplicateColumnError", - # "ColumnSizeError", + "single new column", + "list of new columns", + "new table", + "duplicate new names", ], ) -def test_should_raise_error( - old_column_name: str, - column: list[Column], - error: type[Exception], - error_message: str | None, +def test_should_raise_if_new_column_exists_already( + table: Table, + old_name: str, + new_columns: Column | list[Column], ) -> None: - input_table: Table = Table( - { - "A": [1, 2, 3], - "B": [4, 5, 6], - "C": ["a", "b", "c"], - }, - ) - - with pytest.raises(error, match=error_message): - input_table.replace_column(old_column_name, column) + with pytest.raises(DuplicateColumnError): + table.replace_column(old_name, new_columns) -def test_should_fail_on_empty_table() -> None: - with pytest.raises(ColumnNotFoundError): - Table({}).replace_column("col", [Column("a", [1, 2])]) +@pytest.mark.parametrize( + ("table", "old_name", "new_columns"), + [ + ( + Table({"col1": []}), + "col1", + Column("col2", [1]), + ), + ( + Table({"col1": []}), + "col1", + [Column("col2", [1])], + ), + ( + Table({"col1": []}), + "col1", + Table({"col2": [1]}), + ), + ], + ids=[ + "single new column", + "list of new columns", + "new table", + ], +) +def test_should_raise_if_row_counts_differ( + table: Table, + old_name: str, + new_columns: Column | list[Column] | Table, +) -> None: + with pytest.raises(LengthMismatchError): + table.replace_column(old_name, new_columns) diff --git a/tests/safeds/data/tabular/containers/_table/test_repr.py b/tests/safeds/data/tabular/containers/_table/test_repr.py index f811cd689..422c58af8 100644 --- a/tests/safeds/data/tabular/containers/_table/test_repr.py +++ b/tests/safeds/data/tabular/containers/_table/test_repr.py @@ -7,42 +7,30 @@ ("table", "expected"), [ ( - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), - "+------+------+\n" - "| col1 | col2 |\n" - "| --- | --- |\n" - "| i64 | i64 |\n" - "+=============+\n" - "| 1 | 1 |\n" - "| 2 | 2 |\n" - "| 1 | 4 |\n" - "+------+------+", + Table({}), + "++\n++\n++", ), ( Table({"col1": [], "col2": []}), - "+------+------+\n" - "| col1 | col2 |\n" - "| --- | --- |\n" - "| null | null |\n" - "+=============+\n" - "+------+------+", - ), - ( - Table({}), - "++\n++\n++", + "+------+------+\n| col1 | col2 |\n| --- | --- |\n| null | null |\n+=============+\n+------+------+", ), ( - Table({"col1": [1], "col2": [1]}), + Table({"col1": [1, 2], "col2": [3, 4]}), "+------+------+\n" "| col1 | col2 |\n" "| --- | --- |\n" "| i64 | i64 |\n" "+=============+\n" - "| 1 | 1 |\n" + "| 1 | 3 |\n" + "| 2 | 4 |\n" "+------+------+", ), ], - ids=["multiple rows", "rowless table", "empty table", "one row"], + ids=[ + "empty", + "no rows", + "with data", + ], ) def test_should_return_a_string_representation(table: Table, expected: str) -> None: assert repr(table) == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_repr_html.py b/tests/safeds/data/tabular/containers/_table/test_repr_html.py index c272d04d6..9155f9f81 100644 --- a/tests/safeds/data/tabular/containers/_table/test_repr_html.py +++ b/tests/safeds/data/tabular/containers/_table/test_repr_html.py @@ -9,46 +9,28 @@ "table", [ Table({}), - Table({"a": [1, 2], "b": [3, 4]}), + Table({"col1": []}), + Table({"col1": [1, 2], "col2": [3, 4]}), ], ids=[ "empty", - "non-empty", + "no rows", + "with data", ], ) -def test_should_contain_table_element(table: Table) -> None: - pattern = r".*?" - assert re.search(pattern, table._repr_html_(), flags=re.S) is not None - - -@pytest.mark.parametrize( - "table", - [ - Table({}), - Table({"a": [1, 2], "b": [3, 4]}), - ], - ids=[ - "empty", - "non-empty", - ], -) -def test_should_contain_th_element_for_each_column_name(table: Table) -> None: - for column_name in table.column_names: - assert f"{column_name}" in table._repr_html_() - - -@pytest.mark.parametrize( - "table", - [ - Table({}), - Table({"a": [1, 2], "b": [3, 4]}), - ], - ids=[ - "empty", - "non-empty", - ], -) -def test_should_contain_td_element_for_each_value(table: Table) -> None: - for column in table.to_columns(): - for value in column: - assert f"{value}" in table._repr_html_() +class TestHtml: + def test_should_contain_table_element(self, table: Table) -> None: + actual = table._repr_html_() + pattern = r".*?" + assert re.search(pattern, actual, flags=re.S) is not None + + def test_should_contain_th_element_for_each_column_name(self, table: Table) -> None: + actual = table._repr_html_() + for column_name in table.column_names: + assert f"{column_name}" in actual + + def test_should_contain_td_element_for_each_value(self, table: Table) -> None: + actual = table._repr_html_() + for column in table.to_columns(): + for value in column: + assert f"{value}" in actual diff --git a/tests/safeds/data/tabular/containers/_table/test_row_count.py b/tests/safeds/data/tabular/containers/_table/test_row_count.py index 4adf0c71e..8378fd84a 100644 --- a/tests/safeds/data/tabular/containers/_table/test_row_count.py +++ b/tests/safeds/data/tabular/containers/_table/test_row_count.py @@ -10,7 +10,7 @@ (Table({"col1": [1]}), 1), (Table({"col1": [1, 2]}), 2), ], - ids=["empty", "a row", "2 rows"], + ids=["empty", "one row", "two rows"], ) def test_should_return_number_of_rows(table: Table, expected: int) -> None: assert table.row_count == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_select_columns.py b/tests/safeds/data/tabular/containers/_table/test_select_columns.py new file mode 100644 index 000000000..324a5ca69 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_select_columns.py @@ -0,0 +1,82 @@ +from collections.abc import Callable + +import pytest + +from safeds.data.tabular.containers import Table +from safeds.exceptions import ColumnNotFoundError + + +@pytest.mark.parametrize( + ("table_factory", "names", "expected"), + [ + ( + lambda: Table({}), + [], + Table({}), + ), + ( + lambda: Table({}), + lambda column: column.name.endswith("1"), + Table({}), + ), + ( + lambda: Table({"col1": [], "col2": []}), + [], + Table({}), + ), + ( + lambda: Table({"col1": [], "col2": []}), + "col2", + Table({"col2": []}), + ), + ( + lambda: Table({"col1": [], "col2": []}), + ["col1", "col2"], + Table({"col1": [], "col2": []}), + ), + ( + lambda: Table({"col1": [], "col2": []}), + lambda column: column.name.endswith("1"), + Table({"col1": []}), + ), + # Related to https://github.com/Safe-DS/Library/issues/115 + ( + lambda: Table({"A": [1], "B": [2], "C": [3]}), + ["C", "A"], + Table({"C": [3], "A": [1]}), + ), + ], + ids=[ + "empty table, empty list", + "empty table, predicate", + "non-empty table, empty list", + "non-empty table, single column", + "non-empty table, multiple columns", + "non-empty table, predicate", + "swapped order", + ], +) +class TestHappyPath: + def test_should_select_columns( + self, + table_factory: Callable[[], Table], + names: str | list[str], + expected: Table, + ) -> None: + actual = table_factory().select_columns(names) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + names: str | list[str], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.select_columns(names) + assert original == table_factory() + + +def test_should_raise_for_unknown_columns() -> None: + with pytest.raises(ColumnNotFoundError): + Table({}).select_columns(["col1"]) diff --git a/tests/safeds/data/tabular/containers/_table/test_shuffle_rows.py b/tests/safeds/data/tabular/containers/_table/test_shuffle_rows.py index 616c9044f..8852ec3ed 100644 --- a/tests/safeds/data/tabular/containers/_table/test_shuffle_rows.py +++ b/tests/safeds/data/tabular/containers/_table/test_shuffle_rows.py @@ -1,20 +1,51 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Table @pytest.mark.parametrize( - ("table", "expected"), + ("table_factory", "expected"), [ - (Table({}), Table({})), - (Table({"col1": [1, 2, 3]}), Table({"col1": [3, 2, 1]})), - (Table({"col1": [1, 2, 3], "col2": [4, 5, 6]}), Table({"col1": [3, 2, 1], "col2": [6, 5, 4]})), + ( + lambda: Table({}), + Table({}), + ), + ( + lambda: Table({"col1": []}), + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [1, 2, 3]}), + Table({"col1": [1, 3, 2]}), + ), + ( + lambda: Table({"col1": [1, 2, 3], "col2": [4, 5, 6]}), + Table({"col1": [1, 3, 2], "col2": [4, 6, 5]}), + ), ], ids=[ "empty", + "no rows", "one column", "multiple columns", ], ) -def test_should_shuffle_rows(table: Table, expected: Table) -> None: - assert table.shuffle_rows() == expected +class TestHappyPath: + def test_should_shuffle_rows( + self, + table_factory: Callable[[], Table], + expected: Table, + ) -> None: + actual = table_factory().shuffle_rows() + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.shuffle_rows() + assert original == table_factory() diff --git a/tests/safeds/data/tabular/containers/_table/test_sizeof.py b/tests/safeds/data/tabular/containers/_table/test_sizeof.py index 0f9bb8c27..b5f7dccef 100644 --- a/tests/safeds/data/tabular/containers/_table/test_sizeof.py +++ b/tests/safeds/data/tabular/containers/_table/test_sizeof.py @@ -9,13 +9,13 @@ "table", [ Table({}), - Table({"col1": [0]}), + Table({"col1": []}), Table({"col1": [0, 1], "col2": ["a", "b"]}), ], ids=[ - "empty table", - "table with one row", - "table with multiple rows", + "empty", + "no rows", + "with data", ], ) def test_should_size_be_greater_than_normal_object(table: Table) -> None: diff --git a/tests/safeds/data/tabular/containers/_table/test_slice_rows.py b/tests/safeds/data/tabular/containers/_table/test_slice_rows.py index c9ce9035f..046118da9 100644 --- a/tests/safeds/data/tabular/containers/_table/test_slice_rows.py +++ b/tests/safeds/data/tabular/containers/_table/test_slice_rows.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Table @@ -5,58 +7,58 @@ @pytest.mark.parametrize( - ("table", "start", "length", "expected"), + ("table_factory", "start", "length", "expected"), [ ( - Table({}), + lambda: Table({}), 0, None, Table({}), ), ( - Table({"col1": []}), + lambda: Table({"col1": []}), 0, None, Table({"col1": []}), ), ( - Table({"col1": [1, 2, 3]}), + lambda: Table({"col1": [1, 2, 3]}), 0, None, Table({"col1": [1, 2, 3]}), ), ( - Table({"col1": [1, 2, 3]}), + lambda: Table({"col1": [1, 2, 3]}), 1, None, Table({"col1": [2, 3]}), ), ( - Table({"col1": [1, 2, 3]}), + lambda: Table({"col1": [1, 2, 3]}), 10, None, Table({"col1": []}), ), ( - Table({"col1": [1, 2, 3]}), + lambda: Table({"col1": [1, 2, 3]}), -1, None, Table({"col1": [3]}), ), ( - Table({"col1": [1, 2, 3]}), + lambda: Table({"col1": [1, 2, 3]}), -10, None, Table({"col1": [1, 2, 3]}), ), ( - Table({"col1": [1, 2, 3]}), + lambda: Table({"col1": [1, 2, 3]}), 0, 1, Table({"col1": [1]}), ), ( - Table({"col1": [1, 2, 3]}), + lambda: Table({"col1": [1, 2, 3]}), 0, 10, Table({"col1": [1, 2, 3]}), @@ -74,11 +76,30 @@ "positive length out of bounds", ], ) -def test_should_slice_rows(table: Table, start: int, length: int | None, expected: Table) -> None: - assert table.slice_rows(start, length) == expected +class TestHappyPath: + def test_should_slice_rows( + self, + table_factory: Callable[[], Table], + start: int, + length: int | None, + expected: Table, + ) -> None: + actual = table_factory().slice_rows(start=start, length=length) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + start: int, + length: int | None, + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.slice_rows(start=start, length=length) + assert original == table_factory() def test_should_raise_for_negative_length() -> None: table: Table = Table({}) with pytest.raises(OutOfBoundsError): - table.slice_rows(0, -1) + table.slice_rows(length=-1) diff --git a/tests/safeds/data/tabular/containers/_table/test_sort_rows.py b/tests/safeds/data/tabular/containers/_table/test_sort_rows.py index 897e305da..7352398a6 100644 --- a/tests/safeds/data/tabular/containers/_table/test_sort_rows.py +++ b/tests/safeds/data/tabular/containers/_table/test_sort_rows.py @@ -6,51 +6,51 @@ @pytest.mark.parametrize( - ("table", "key_selector", "expected"), + ("table_factory", "key_selector", "descending", "expected"), [ ( - Table({}), + lambda: Table({"col1": [], "col2": []}), lambda row: row["col1"], - Table({}), + False, + Table({"col1": [], "col2": []}), ), ( - Table({"col1": [3, 2, 1]}), + lambda: Table({"col1": [2, 3, 1], "col2": [5, 6, 4]}), lambda row: row["col1"], - Table({"col1": [1, 2, 3]}), + False, + Table({"col1": [1, 2, 3], "col2": [4, 5, 6]}), ), - ], - ids=["empty", "3 rows"], -) -def test_should_return_sorted_table( - table: Table, - key_selector: Callable[[Row], Cell], - expected: Table, -) -> None: - assert table.sort_rows(key_selector).schema == expected.schema - assert table.sort_rows(key_selector) == expected - - -@pytest.mark.parametrize( - ("table", "key_selector", "expected"), - [ ( - Table({}), + lambda: Table({"col1": [2, 3, 1], "col2": [5, 6, 4]}), lambda row: row["col1"], - Table({}), - ), - ( - Table({"col1": [3, 2, 1]}), - lambda row: row["col1"], - Table({"col1": [3, 2, 1]}), + True, + Table({"col1": [3, 2, 1], "col2": [6, 5, 4]}), ), ], - ids=["empty", "3 rows"], + ids=[ + "no rows", + "non-empty, ascending", + "non-empty, descending", + ], ) -def test_should_not_modify_original_table( - table: Table, - key_selector: Callable[[Row], Cell], - expected: Table, -) -> None: - table.sort_rows(key_selector) - assert table.schema == expected.schema - assert table == expected +class TestHappyPath: + def test_should_return_sorted_table( + self, + table_factory: Callable[[], Table], + key_selector: Callable[[Row], Cell], + descending: bool, + expected: Table, + ) -> None: + actual = table_factory().sort_rows(key_selector, descending=descending) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + key_selector: Callable[[Row], Cell], + descending: bool, + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.sort_rows(key_selector, descending=descending) + assert original == table_factory() diff --git a/tests/safeds/data/tabular/containers/_table/test_sort_rows_by_column.py b/tests/safeds/data/tabular/containers/_table/test_sort_rows_by_column.py new file mode 100644 index 000000000..044b3ffb6 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_sort_rows_by_column.py @@ -0,0 +1,62 @@ +from collections.abc import Callable + +import pytest + +from safeds.data.tabular.containers import Table +from safeds.exceptions import ColumnNotFoundError + + +@pytest.mark.parametrize( + ("table_factory", "name", "descending", "expected"), + [ + ( + lambda: Table({"col1": [], "col2": []}), + "col1", + False, + Table({"col1": [], "col2": []}), + ), + ( + lambda: Table({"col1": [2, 3, 1], "col2": [5, 6, 4]}), + "col1", + False, + Table({"col1": [1, 2, 3], "col2": [4, 5, 6]}), + ), + ( + lambda: Table({"col1": [2, 3, 1], "col2": [5, 6, 4]}), + "col1", + True, + Table({"col1": [3, 2, 1], "col2": [6, 5, 4]}), + ), + ], + ids=[ + "no rows", + "non-empty, ascending", + "non-empty, descending", + ], +) +class TestHappyPath: + def test_should_return_sorted_table( + self, + table_factory: Callable[[], Table], + name: str, + descending: bool, + expected: Table, + ) -> None: + actual = table_factory().sort_rows_by_column(name, descending=descending) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + name: str, + descending: bool, + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.sort_rows_by_column(name, descending=descending) + assert original == table_factory() + + +def test_should_raise_if_name_is_unknown() -> None: + with pytest.raises(ColumnNotFoundError): + Table({}).sort_rows_by_column("col1") diff --git a/tests/safeds/data/tabular/containers/_table/test_split_rows.py b/tests/safeds/data/tabular/containers/_table/test_split_rows.py index 72d0b449c..1545d38e3 100644 --- a/tests/safeds/data/tabular/containers/_table/test_split_rows.py +++ b/tests/safeds/data/tabular/containers/_table/test_split_rows.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Table @@ -5,38 +7,100 @@ @pytest.mark.parametrize( - ("table", "result_train_table", "result_test_table", "percentage_in_first"), + ("table_factory", "percentage_in_first", "shuffle", "expected_1", "expected_2"), [ ( - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), - Table({"col1": [1, 2], "col2": [4, 2]}), - Table({"col1": [1], "col2": [1]}), - 2 / 3, + lambda: Table({}), + 0.0, + False, + Table({}), + Table({}), + ), + ( + lambda: Table({"col1": []}), + 0.0, + False, + Table({"col1": []}), + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [1, 2, 3, 4]}), + 1.0, + False, + Table({"col1": [1, 2, 3, 4]}), + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [1, 2, 3, 4]}), + 1.0, + True, + Table({"col1": [4, 1, 2, 3]}), + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [1, 2, 3, 4]}), + 0.0, + False, + Table({"col1": []}), + Table({"col1": [1, 2, 3, 4]}), + ), + ( + lambda: Table({"col1": [1, 2, 3, 4]}), + 0.0, + True, + Table({"col1": []}), + Table({"col1": [4, 1, 2, 3]}), ), ( - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), - Table({"col1": [], "col2": []}), - Table({"col1": [1, 2, 1], "col2": [4, 2, 1]}), - 0, + lambda: Table({"col1": [1, 2, 3, 4]}), + 0.5, + False, + Table({"col1": [1, 2]}), + Table({"col1": [3, 4]}), ), ( - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), - Table({"col1": [1, 2, 1], "col2": [4, 2, 1]}), - Table({"col1": [], "col2": []}), - 1, + lambda: Table({"col1": [1, 2, 3, 4]}), + 0.5, + True, + Table({"col1": [4, 1]}), + Table({"col1": [2, 3]}), ), ], - ids=["2/3%", "0%", "100%"], + ids=[ + "empty", + "no rows", + "all in first, no shuffle", + "all in first, shuffle", + "all in second, no shuffle", + "all in second, shuffle", + "even split, no shuffle", + "even split, shuffle", + ], ) -def test_should_split_table( - table: Table, - result_test_table: Table, - result_train_table: Table, - percentage_in_first: int, -) -> None: - train_table, test_table = table.split_rows(percentage_in_first) - assert result_test_table == test_table - assert result_train_table == train_table +class TestHappyPath: + def test_should_split_rows( + self, + table_factory: Callable[[], Table], + percentage_in_first: float, + shuffle: bool, + expected_1: Table, + expected_2: Table, + ) -> None: + actual_1, actual_2 = table_factory().split_rows(percentage_in_first, shuffle=shuffle) + assert actual_1 == expected_1 + assert actual_2 == expected_2 + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + percentage_in_first: float, + shuffle: bool, + expected_1: Table, # noqa: ARG002 + expected_2: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.split_rows(percentage_in_first, shuffle=shuffle) + assert original == table_factory() @pytest.mark.parametrize( @@ -45,15 +109,11 @@ def test_should_split_table( -1.0, 2.0, ], - ids=["-100%", "200%"], + ids=[ + "too low", + "too high", + ], ) -def test_should_raise_if_value_not_in_range(percentage_in_first: float) -> None: - table = Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}) +def test_should_raise_if_percentage_in_first_is_out_of_bounds(percentage_in_first: float) -> None: with pytest.raises(OutOfBoundsError): - table.split_rows(percentage_in_first) - - -def test_should_split_empty_table() -> None: - t1, t2 = Table({}).split_rows(0.4) - assert t1.row_count == 0 - assert t2.row_count == 0 + Table({}).split_rows(percentage_in_first) diff --git a/tests/safeds/data/tabular/containers/_table/test_str.py b/tests/safeds/data/tabular/containers/_table/test_str.py index 3c2306fd6..8cbc55765 100644 --- a/tests/safeds/data/tabular/containers/_table/test_str.py +++ b/tests/safeds/data/tabular/containers/_table/test_str.py @@ -7,42 +7,30 @@ ("table", "expected"), [ ( - Table({"col1": [1, 2, 1], "col2": [1, 2, 4]}), - "+------+------+\n" - "| col1 | col2 |\n" - "| --- | --- |\n" - "| i64 | i64 |\n" - "+=============+\n" - "| 1 | 1 |\n" - "| 2 | 2 |\n" - "| 1 | 4 |\n" - "+------+------+", + Table({}), + "++\n++\n++", ), ( Table({"col1": [], "col2": []}), - "+------+------+\n" - "| col1 | col2 |\n" - "| --- | --- |\n" - "| null | null |\n" - "+=============+\n" - "+------+------+", - ), - ( - Table({}), - "++\n++\n++", + "+------+------+\n| col1 | col2 |\n| --- | --- |\n| null | null |\n+=============+\n+------+------+", ), ( - Table({"col1": [1], "col2": [1]}), + Table({"col1": [1, 2], "col2": [3, 4]}), "+------+------+\n" "| col1 | col2 |\n" "| --- | --- |\n" "| i64 | i64 |\n" "+=============+\n" - "| 1 | 1 |\n" + "| 1 | 3 |\n" + "| 2 | 4 |\n" "+------+------+", ), ], - ids=["multiple rows", "rowless table", "empty table", "one row"], + ids=[ + "empty", + "no rows", + "with data", + ], ) def test_should_return_a_string_representation(table: Table, expected: str) -> None: assert str(table) == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_summarize_statistics.py b/tests/safeds/data/tabular/containers/_table/test_summarize_statistics.py index eaf8390d7..53b19a537 100644 --- a/tests/safeds/data/tabular/containers/_table/test_summarize_statistics.py +++ b/tests/safeds/data/tabular/containers/_table/test_summarize_statistics.py @@ -1,123 +1,201 @@ +import datetime from statistics import stdev import pytest from safeds.data.tabular.containers import Table +_HEADERS = [ + "min", + "max", + "mean", + "median", + "standard deviation", + "missing value ratio", + "stability", + "idness", +] +_EMPTY_COLUMN_RESULT = [ + None, + None, + None, + None, + None, + 1.0, + 1.0, + 1.0, +] + @pytest.mark.parametrize( ("table", "expected"), [ + # empty + ( + Table({}), + Table({}), + ), + # no rows, multiple columns + ( + Table({"col1": [], "col2": []}), + Table( + { + "statistic": _HEADERS, + "col1": _EMPTY_COLUMN_RESULT, + "col2": _EMPTY_COLUMN_RESULT, + }, + ), + ), + # null column ( - Table({"col1": [1, 2, 1], "col2": ["a", "b", "c"]}), + Table({"col1": [None, None, None]}), Table( { - "metric": [ - "min", - "max", - "mean", - "median", - "standard deviation", - "distinct value count", - "idness", - "missing value ratio", - "stability", + "statistic": _HEADERS, + "col1": [ + None, + None, + None, + None, + None, + 1.0, + 1.0, + 1 / 3, ], + }, + ), + ), + # numeric column + ( + Table({"col1": [1, 2, 1, None]}), + Table( + { + "statistic": _HEADERS, "col1": [ 1, 2, 4 / 3, 1, stdev([1, 2, 1]), - 2, - 2 / 3, - 0, + 1 / 4, 2 / 3, - ], - "col2": [ - "a", - "c", - "-", - "-", - "-", - "3", - "1.0", - "0.0", - "0.3333333333333333", + 3 / 4, ], }, ), ), + # temporal column ( - Table({}), - Table({}), - ), - ( - Table({"col": [], "gg": []}), Table( { - "metric": [ - "min", - "max", - "mean", - "median", - "standard deviation", - "distinct value count", - "idness", - "missing value ratio", - "stability", + "col1": [ + datetime.time(1, 2, 3), + datetime.time(4, 5, 6), + datetime.time(7, 8, 9), + None, ], - "col": [ - "-", - "-", - "-", - "-", - "-", - "0", - "1.0", - "1.0", + }, + ), + Table( + { + "statistic": _HEADERS, + "col1": [ + "01:02:03", + "07:08:09", + None, + None, + None, + "0.25", + "0.3333333333333333", "1.0", ], - "gg": [ - "-", - "-", - "-", - "-", - "-", - "0", - "1.0", - "1.0", + }, + ), + ), + # string column + ( + Table({"col1": ["a", "b", "c", None]}), + Table( + { + "statistic": _HEADERS, + "col1": [ + "a", + "c", + None, + None, + None, + "0.25", + "0.3333333333333333", "1.0", ], }, ), ), + # boolean column ( - Table({"col": [None, None]}), + Table({"col1": [True, False, True, None]}), Table( { - "metric": [ - "min", - "max", - "mean", - "median", - "standard deviation", - "distinct value count", - "idness", - "missing value ratio", - "stability", + "statistic": _HEADERS, + "col1": [ + "false", + "true", + None, + None, + None, + "0.25", + "0.6666666666666666", + "0.75", ], - "col": ["-", "-", "-", "-", "-", "0", "0.5", "1.0", "1.0"], }, ), ), ], ids=[ - "Column of integers and Column of characters", "empty", - "empty with columns", - "Column of None", + "no rows, multiple columns", + "null column", + "numeric column", + "temporal column", + "string column", + "boolean column", ], ) def test_should_summarize_statistics(table: Table, expected: Table) -> None: - assert table.summarize_statistics() == expected + actual = table.summarize_statistics() + assert actual == expected + + +@pytest.mark.parametrize( + ("table", "expected"), + [ + # has statistic column + ( + Table({"statistic": []}), + Table( + { + "statistic_": _HEADERS, + "statistic": _EMPTY_COLUMN_RESULT, + }, + ), + ), + # has statistic_ column + ( + Table({"statistic": [], "statistic_": []}), + Table( + { + "statistic__": _HEADERS, + "statistic": _EMPTY_COLUMN_RESULT, + "statistic_": _EMPTY_COLUMN_RESULT, + }, + ), + ), + ], + ids=[ + "has statistic column", + "has statistic_ column", + ], +) +def test_should_ensure_new_column_has_unique_name(table: Table, expected: Table) -> None: + actual = table.summarize_statistics() + assert actual == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_to_columns.py b/tests/safeds/data/tabular/containers/_table/test_to_columns.py index 20c7b9532..52dff6d9b 100644 --- a/tests/safeds/data/tabular/containers/_table/test_to_columns.py +++ b/tests/safeds/data/tabular/containers/_table/test_to_columns.py @@ -5,8 +5,31 @@ @pytest.mark.parametrize( ("table", "expected"), - [(Table({"A": [54, 74], "B": [90, 2010]}), [Column("A", [54, 74]), Column("B", [90, 2010])]), (Table({}), [])], - ids=["normal", "empty"], + [ + ( + Table({}), + [], + ), + ( + Table({"col1": [], "col2": []}), + [ + Column("col1", []), + Column("col2", []), + ], + ), + ( + Table({"col1": [1, 2], "col2": [3, 4]}), + [ + Column("col1", [1, 2]), + Column("col2", [3, 4]), + ], + ), + ], + ids=[ + "empty", + "no rows", + "with data", + ], ) def test_should_return_list_of_columns(table: Table, expected: list[Column]) -> None: assert table.to_columns() == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_to_csv_file.py b/tests/safeds/data/tabular/containers/_table/test_to_csv_file.py index db5d54615..809070d4c 100644 --- a/tests/safeds/data/tabular/containers/_table/test_to_csv_file.py +++ b/tests/safeds/data/tabular/containers/_table/test_to_csv_file.py @@ -1,59 +1,50 @@ from pathlib import Path -from tempfile import NamedTemporaryFile import pytest from safeds.data.tabular.containers import Table from safeds.exceptions import FileExtensionError -from tests.helpers import resolve_resource_path @pytest.mark.parametrize( "table", [ - (Table({"col1": ["col1_1"], "col2": ["col2_1"]})), - (Table({})), + Table({}), + Table({"col1": []}), + Table({"col1": [0, 1], "col2": ["a", "b"]}), + ], + ids=[ + "empty", + "no rows", + "with data", ], - ids=["by String", "empty"], ) -def test_should_create_csv_file_from_table_by_str(table: Table) -> None: - with NamedTemporaryFile(suffix=".csv") as tmp_table_file: - tmp_table_file.close() - with Path(tmp_table_file.name).open("w", encoding="utf-8") as tmp_file: - table.to_csv_file(resolve_resource_path(tmp_file.name)) - with Path(tmp_table_file.name).open("r", encoding="utf-8") as tmp_file: - table_r = Table.from_csv_file(tmp_file.name) +class TestShouldCreateCsvFile: + def test_path_as_string(self, table: Table, tmp_path: Path) -> None: + path_as_string = str(tmp_path / "table.csv") - # We test all since polars might raise if files are empty - # TODO: extract to tests for schema, number of columns etc. - assert table.schema == table_r.schema - assert table.column_count == table_r.column_count - assert table_r == table + table.to_csv_file(path_as_string) + restored = Table.from_csv_file(path_as_string) + assert restored == table + def test_path_as_path_object(self, table: Table, tmp_path: Path) -> None: + path_as_path_object = tmp_path / "table.csv" -@pytest.mark.parametrize( - "table", - [ - (Table({"col1": ["col1_1"], "col2": ["col2_1"]})), - (Table({})), - ], - ids=["by String", "empty"], -) -def test_should_create_csv_file_from_table_by_path(table: Table) -> None: - with NamedTemporaryFile(suffix=".csv") as tmp_table_file: - tmp_table_file.close() - with Path(tmp_table_file.name).open("w", encoding="utf-8") as tmp_file: - table.to_csv_file(Path(tmp_file.name)) - with Path(tmp_table_file.name).open("r", encoding="utf-8") as tmp_file: - table_r = Table.from_csv_file(Path(tmp_file.name)) - assert table.schema == table_r.schema - assert table.column_count == table_r.column_count - assert table == table_r - - -def test_should_raise_error_if_wrong_file_extension() -> None: - table = Table({"col1": ["col1_1"], "col2": ["col2_1"]}) - with NamedTemporaryFile(suffix=".invalid_file_extension") as tmp_table_file: - tmp_table_file.close() - with Path(tmp_table_file.name).open("w", encoding="utf-8") as tmp_file, pytest.raises(FileExtensionError): - table.to_csv_file(Path(tmp_file.name)) + table.to_csv_file(path_as_path_object) + restored = Table.from_csv_file(path_as_path_object) + assert restored == table + + +def test_should_add_missing_extension(tmp_path: Path) -> None: + write_path = tmp_path / "table" + read_path = tmp_path / "table.csv" + + table = Table({}) + table.to_csv_file(write_path) + restored = Table.from_csv_file(read_path) + assert restored == table + + +def test_should_raise_if_wrong_file_extension(tmp_path: Path) -> None: + with pytest.raises(FileExtensionError): + Table({}).to_csv_file(tmp_path / "table.txt") diff --git a/tests/safeds/data/tabular/containers/_table/test_to_dict.py b/tests/safeds/data/tabular/containers/_table/test_to_dict.py index 55f249051..bcfdc4153 100644 --- a/tests/safeds/data/tabular/containers/_table/test_to_dict.py +++ b/tests/safeds/data/tabular/containers/_table/test_to_dict.py @@ -15,17 +15,33 @@ ( Table( { - "a": [1], - "b": [2], + "col1": [], + "col2": [], }, ), { - "a": [1], - "b": [2], + "col1": [], + "col2": [], }, ), + ( + Table( + { + "col1": [1, 2], + "col2": [3, 4], + }, + ), + { + "col1": [1, 2], + "col2": [3, 4], + }, + ), + ], + ids=[ + "empty", + "no rows", + "with data", ], - ids=["Empty table", "Table with one row"], ) -def test_should_return_dict_for_table(table: Table, expected: dict[str, list[Any]]) -> None: +def test_should_return_dictionary(table: Table, expected: dict[str, list[Any]]) -> None: assert table.to_dict() == expected diff --git a/tests/safeds/data/tabular/containers/_table/test_to_json_file.py b/tests/safeds/data/tabular/containers/_table/test_to_json_file.py index 4569fc185..db10b581b 100644 --- a/tests/safeds/data/tabular/containers/_table/test_to_json_file.py +++ b/tests/safeds/data/tabular/containers/_table/test_to_json_file.py @@ -1,5 +1,4 @@ from pathlib import Path -from tempfile import NamedTemporaryFile import pytest @@ -10,44 +9,41 @@ @pytest.mark.parametrize( "table", [ - (Table({"col1": ["col1_1"], "col2": ["col2_1"]})), - (Table({})), + Table({}), + Table({"col1": [0, 1], "col2": ["a", "b"]}), + ], + ids=[ + "empty", + # "no rows", # This test fails, because polars creates row-oriented JSON. + "with data", ], - ids=["by String", "empty"], ) -def test_should_create_json_file_from_table_by_str(table: Table) -> None: - with NamedTemporaryFile(suffix=".json") as tmp_table_file: - tmp_table_file.close() - with Path(tmp_table_file.name).open("w", encoding="utf-8") as tmp_file: - table.to_json_file(tmp_file.name) - with Path(tmp_table_file.name).open("r", encoding="utf-8") as tmp_file: - table_r = Table.from_json_file(tmp_file.name) - assert table.schema == table_r.schema - assert table == table_r +class TestShouldCreateJsonFile: + def test_path_as_string(self, table: Table, tmp_path: Path) -> None: + path_as_string = str(tmp_path / "table.json") + table.to_json_file(path_as_string) + restored = Table.from_json_file(path_as_string) + assert restored == table -@pytest.mark.parametrize( - "table", - [ - (Table({"col1": ["col1_1"], "col2": ["col2_1"]})), - (Table({})), - ], - ids=["by String", "empty"], -) -def test_should_create_json_file_from_table_by_path(table: Table) -> None: - with NamedTemporaryFile(suffix=".json") as tmp_table_file: - tmp_table_file.close() - with Path(tmp_table_file.name).open("w", encoding="utf-8") as tmp_file: - table.to_json_file(Path(tmp_file.name)) - with Path(tmp_table_file.name).open("r", encoding="utf-8") as tmp_file: - table_r = Table.from_json_file(Path(tmp_file.name)) - assert table.schema == table_r.schema - assert table == table_r - - -def test_should_raise_error_if_wrong_file_extension() -> None: - table = Table({"col1": ["col1_1"], "col2": ["col2_1"]}) - with NamedTemporaryFile(suffix=".invalid_file_extension") as tmp_table_file: - tmp_table_file.close() - with Path(tmp_table_file.name).open("w", encoding="utf-8") as tmp_file, pytest.raises(FileExtensionError): - table.to_json_file(Path(tmp_file.name)) + def test_path_as_path_object(self, table: Table, tmp_path: Path) -> None: + path_as_path_object = tmp_path / "table.json" + + table.to_json_file(path_as_path_object) + restored = Table.from_json_file(path_as_path_object) + assert restored == table + + +def test_should_add_missing_extension(tmp_path: Path) -> None: + write_path = tmp_path / "table" + read_path = tmp_path / "table.json" + + table = Table({}) + table.to_json_file(write_path) + restored = Table.from_json_file(read_path) + assert restored == table + + +def test_should_raise_if_wrong_file_extension(tmp_path: Path) -> None: + with pytest.raises(FileExtensionError): + Table({}).to_json_file(tmp_path / "table.txt") diff --git a/tests/safeds/data/tabular/containers/_table/test_to_parquet_file.py b/tests/safeds/data/tabular/containers/_table/test_to_parquet_file.py new file mode 100644 index 000000000..27236f34b --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_to_parquet_file.py @@ -0,0 +1,50 @@ +from pathlib import Path + +import pytest + +from safeds.data.tabular.containers import Table +from safeds.exceptions import FileExtensionError + + +@pytest.mark.parametrize( + "table", + [ + Table({}), + Table({"col1": []}), + Table({"col1": [0, 1], "col2": ["a", "b"]}), + ], + ids=[ + "empty", + "no rows", + "with data", + ], +) +class TestShouldCreateParquetFile: + def test_path_as_string(self, table: Table, tmp_path: Path) -> None: + path_as_string = str(tmp_path / "table.parquet") + + table.to_parquet_file(path_as_string) + restored = Table.from_parquet_file(path_as_string) + assert restored == table + + def test_path_as_path_object(self, table: Table, tmp_path: Path) -> None: + path_as_path_object = tmp_path / "table.parquet" + + table.to_parquet_file(path_as_path_object) + restored = Table.from_parquet_file(path_as_path_object) + assert restored == table + + +def test_should_add_missing_extension(tmp_path: Path) -> None: + write_path = tmp_path / "table" + read_path = tmp_path / "table.parquet" + + table = Table({}) + table.to_parquet_file(write_path) + restored = Table.from_parquet_file(read_path) + assert restored == table + + +def test_should_raise_if_wrong_file_extension(tmp_path: Path) -> None: + with pytest.raises(FileExtensionError): + Table({}).to_parquet_file(tmp_path / "table.txt") diff --git a/tests/safeds/data/tabular/containers/_table/test_to_tabular_dataset.py b/tests/safeds/data/tabular/containers/_table/test_to_tabular_dataset.py new file mode 100644 index 000000000..e6c01d741 --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_to_tabular_dataset.py @@ -0,0 +1,151 @@ +import pytest + +from safeds.data.tabular.containers import Table +from safeds.exceptions import ColumnNotFoundError + + +@pytest.mark.parametrize( + ("table", "target_name", "extra_names", "expected_extra_names", "expected_feature_names"), + [ + ( + Table({"a": [1, 2], "b": [3, 4], "c": [5, 6], "d": [7, 8]}), + "a", + None, + [], + ["b", "c", "d"], + ), + ( + Table({"a": [1, 2], "b": [3, 4], "c": [5, 6], "d": [7, 8]}), + "a", + [], + [], + ["b", "c", "d"], + ), + ( + Table({"a": [1, 2], "b": [3, 4], "c": [5, 6], "d": [7, 8]}), + "a", + "b", + ["b"], + ["c", "d"], + ), + ( + Table({"a": [1, 2], "b": [3, 4], "c": [5, 6], "d": [7, 8]}), + "a", + ["b", "c"], + ["b", "c"], + ["d"], + ), + ], + ids=[ + "no extras, implicit", + "no extras, explicit", + "one extra", + "multiple extras", + ], +) +class TestHappyPath: + def test_should_have_correct_targets( + self, + table: Table, + target_name: str, + extra_names: str | list[str] | None, + expected_extra_names: list[str], # noqa: ARG002 + expected_feature_names: list[str], # noqa: ARG002 + ) -> None: + actual = table.to_tabular_dataset(target_name, extra_names=extra_names) + assert actual.target.name == target_name + + def test_should_have_correct_extras( + self, + table: Table, + target_name: str, + extra_names: str | list[str] | None, + expected_extra_names: list[str], + expected_feature_names: list[str], # noqa: ARG002 + ) -> None: + actual = table.to_tabular_dataset(target_name, extra_names=extra_names) + assert actual.extras.column_names == expected_extra_names + + def test_should_have_correct_features( + self, + table: Table, + target_name: str, + extra_names: str | list[str] | None, + expected_extra_names: list[str], # noqa: ARG002 + expected_feature_names: list[str], + ) -> None: + actual = table.to_tabular_dataset(target_name, extra_names=extra_names) + assert actual.features.column_names == expected_feature_names + + def test_should_have_correct_data( + self, + table: Table, + target_name: str, + extra_names: str | list[str] | None, + expected_extra_names: list[str], # noqa: ARG002 + expected_feature_names: list[str], # noqa: ARG002 + ) -> None: + actual = table.to_tabular_dataset(target_name, extra_names=extra_names) + assert actual.to_table() == table + + +@pytest.mark.parametrize( + ("table", "target_name", "extra_names"), + [ + ( + Table({"a": [], "b": []}), + "unknown", + None, + ), + ( + Table({"a": [], "b": []}), + "a", + "unknown", + ), + ], + ids=[ + "unknown target", + "unknown extra", + ], +) +def test_should_raise_if_column_not_found( + table: Table, + target_name: str, + extra_names: str | list[str] | None, +) -> None: + with pytest.raises(ColumnNotFoundError): + table.to_tabular_dataset(target_name, extra_names=extra_names) + + +def test_should_raise_if_target_is_extra() -> None: + table = Table({"a": [], "b": []}) + with pytest.raises(ValueError, match=r"Column 'a' cannot be both target and extra column\."): + table.to_tabular_dataset("a", extra_names="a") + + +@pytest.mark.parametrize( + ("table", "target_name", "extra_names"), + [ + ( + Table({"a": []}), + "a", + None, + ), + ( + Table({"a": [], "b": []}), + "a", + "b", + ), + ], + ids=[ + "without extras", + "with extras", + ], +) +def test_should_raise_if_no_feature_columns_remain( + table: Table, + target_name: str, + extra_names: str | list[str] | None, +) -> None: + with pytest.raises(ValueError, match=r"At least one feature column must remain\."): + table.to_tabular_dataset(target_name, extra_names=extra_names) diff --git a/tests/safeds/data/tabular/containers/_table/test_to_time_series_dataset.py b/tests/safeds/data/tabular/containers/_table/test_to_time_series_dataset.py new file mode 100644 index 000000000..bbf4e0fed --- /dev/null +++ b/tests/safeds/data/tabular/containers/_table/test_to_time_series_dataset.py @@ -0,0 +1 @@ +# TODO: test once the method is added back in diff --git a/tests/safeds/data/tabular/containers/_table/test_transform_column.py b/tests/safeds/data/tabular/containers/_table/test_transform_column.py index 9ce90cde3..3391a44c0 100644 --- a/tests/safeds/data/tabular/containers/_table/test_transform_column.py +++ b/tests/safeds/data/tabular/containers/_table/test_transform_column.py @@ -1,40 +1,69 @@ +from collections.abc import Callable + import pytest -from safeds.data.tabular.containers import Table +from safeds.data.tabular.containers import Cell, Table from safeds.exceptions import ColumnNotFoundError @pytest.mark.parametrize( - ("table", "table_transformed"), + ("table_factory", "name", "transformer", "expected"), [ ( - Table({"A": [1, 2, 3], "B": [4, 5, 6], "C": ["a", "b", "c"]}), - Table({"A": [2, 4, 6], "B": [4, 5, 6], "C": ["a", "b", "c"]}), + lambda: Table({"col1": []}), + "col1", + lambda _: Cell.from_literal(None), + Table({"col1": []}), + ), + ( + lambda: Table({"col1": []}), + "col1", + lambda cell: 2 * cell, + Table({"col1": []}), + ), + ( + lambda: Table({"col1": [1, 2, 3]}), + "col1", + lambda _: Cell.from_literal(None), + Table({"col1": [None, None, None]}), + ), + ( + lambda: Table({"col1": [1, 2, 3]}), + "col1", + lambda cell: 2 * cell, + Table({"col1": [2, 4, 6]}), ), ], - ids=["multiply by 2"], + ids=[ + "no rows (constant value)", + "no rows (computed value)", + "non-empty (constant value)", + "non-empty (computed value)", + ], ) -def test_should_transform_column(table: Table, table_transformed: Table) -> None: - result = table.transform_column("A", lambda cell: cell * 2) +class TestHappyPath: + def test_should_transform_column( + self, + table_factory: Callable[[], Table], + name: str, + transformer: Callable[[Cell], Cell], + expected: Table, + ) -> None: + actual = table_factory().transform_column(name, transformer) + assert actual == expected - assert result.schema == table_transformed.schema - assert result == table_transformed + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + name: str, + transformer: Callable[[Cell], Cell], + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + original.transform_column(name, transformer) + assert original == table_factory() -@pytest.mark.parametrize( - "table", - [ - Table( - { - "A": [1, 2, 3], - "B": [4, 5, 6], - "C": ["a", "b", "c"], - }, - ), - Table({}), - ], - ids=["column not found", "empty"], -) -def test_should_raise_if_column_not_found(table: Table) -> None: +def test_should_raise_if_column_not_found() -> None: with pytest.raises(ColumnNotFoundError): - table.transform_column("D", lambda cell: cell * 2) + Table({}).transform_column("col1", lambda cell: cell * 2) diff --git a/tests/safeds/data/tabular/containers/_table/test_transform_table.py b/tests/safeds/data/tabular/containers/_table/test_transform_table.py index fc12cfb76..c75af082d 100644 --- a/tests/safeds/data/tabular/containers/_table/test_transform_table.py +++ b/tests/safeds/data/tabular/containers/_table/test_transform_table.py @@ -1,128 +1,53 @@ +from collections.abc import Callable + import pytest from safeds.data.tabular.containers import Table -from safeds.data.tabular.transformation import OneHotEncoder -from safeds.exceptions import ColumnNotFoundError, TransformerNotFittedError +from safeds.data.tabular.transformation import RangeScaler, TableTransformer +from safeds.exceptions import NotFittedError +# We test the behavior of each transformer in their own test file. @pytest.mark.parametrize( - ("table", "column_names", "expected"), + ("table_factory", "transformer", "expected"), [ ( - Table( - { - "col1": ["a", "b", "b", "c"], - }, - ), - None, - Table( - { - "col1__a": [1.0, 0.0, 0.0, 0.0], - "col1__b": [0.0, 1.0, 1.0, 0.0], - "col1__c": [0.0, 0.0, 0.0, 1.0], - }, - ), - ), - ( - Table( - { - "col1": ["a", "b", "b", "c"], - "col2": ["a", "b", "b", "c"], - }, - ), - ["col1"], - Table( - { - "col2": ["a", "b", "b", "c"], - "col1__a": [1.0, 0.0, 0.0, 0.0], - "col1__b": [0.0, 1.0, 1.0, 0.0], - "col1__c": [0.0, 0.0, 0.0, 1.0], - }, - ), - ), - ( - Table( - { - "col1": ["a", "b", "b", "c"], - "col2": ["a", "b", "b", "c"], - }, - ), - ["col1", "col2"], - Table( - { - "col1__a": [1.0, 0.0, 0.0, 0.0], - "col1__b": [0.0, 1.0, 1.0, 0.0], - "col1__c": [0.0, 0.0, 0.0, 1.0], - "col2__a": [1.0, 0.0, 0.0, 0.0], - "col2__b": [0.0, 1.0, 1.0, 0.0], - "col2__c": [0.0, 0.0, 0.0, 1.0], - }, - ), - ), - ( - Table( - { - "col1": ["a", "b", "c"], - }, - ), - [], - Table( - { - "col1": ["a", "b", "c"], - }, - ), + lambda: Table({"col1": [1, 2, 3]}), + RangeScaler(), + Table({"col1": [0.0, 0.5, 1.0]}), ), ], - ids=["all columns", "one column", "multiple columns", "none"], -) -def test_should_return_transformed_table( - table: Table, - column_names: list[str] | None, - expected: Table, -) -> None: - transformer = OneHotEncoder(column_names=column_names).fit(table) - assert table.transform_table(transformer) == expected - - -@pytest.mark.parametrize( - "table_to_fit", - [ - Table( - { - "col1": ["a", "b", "c"], - }, - ), - Table({}), + ids=[ + "with data", ], - ids=["non-empty table", "empty table"], ) -def test_should_raise_if_column_not_found(table_to_fit: Table) -> None: - table_to_fit = Table( - { - "col1": ["a", "b", "c"], - }, - ) - - transformer = OneHotEncoder().fit(table_to_fit) - - table_to_transform = Table( - { - "col2": ["a", "b", "c"], - }, - ) - - with pytest.raises(ColumnNotFoundError): - table_to_transform.transform_table(transformer) +class TestHappyPath: + def test_should_return_transformed_table( + self, + table_factory: Callable[[], Table], + transformer: TableTransformer, + expected: Table, + ) -> None: + table = table_factory() + fitted_transformer = transformer.fit(table) + actual = table.transform_table(fitted_transformer) + assert actual == expected + + def test_should_not_mutate_receiver( + self, + table_factory: Callable[[], Table], + transformer: TableTransformer, + expected: Table, # noqa: ARG002 + ) -> None: + original = table_factory() + fitted_transformer = transformer.fit(original) + original.transform_table(fitted_transformer) + assert original == table_factory() def test_should_raise_if_not_fitted() -> None: - table = Table( - { - "col1": ["a", "b", "c"], - }, - ) - - transformer = OneHotEncoder() + table = Table({}) + transformer = RangeScaler() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError): table.transform_table(transformer) diff --git a/tests/safeds/data/tabular/containers/_temporal_cell/test_sizeof.py b/tests/safeds/data/tabular/containers/_temporal_cell/test_sizeof.py index 3483991d6..89ff99c06 100644 --- a/tests/safeds/data/tabular/containers/_temporal_cell/test_sizeof.py +++ b/tests/safeds/data/tabular/containers/_temporal_cell/test_sizeof.py @@ -1,6 +1,7 @@ import sys import polars as pl + from safeds.data.tabular.containers._lazy_temporal_cell import _LazyTemporalCell diff --git a/tests/safeds/data/tabular/containers/test_row.py b/tests/safeds/data/tabular/containers/test_row.py deleted file mode 100644 index ffd3b8417..000000000 --- a/tests/safeds/data/tabular/containers/test_row.py +++ /dev/null @@ -1,518 +0,0 @@ -# TODO -# import re -# import sys -# from typing import Any -# -# import pandas as pd -# import pytest -# from safeds.data.tabular.containers import Row, Table -# from safeds.data.tabular.typing import ColumnType, Integer, Schema, String -# from safeds.exceptions import ColumnNotFoundError -# -# -# class TestFromDict: -# @pytest.mark.parametrize( -# ("data", "expected"), -# [ -# ( -# {}, -# Row({}), -# ), -# ( -# { -# "a": 1, -# "b": 2, -# }, -# Row({"a": 1, "b": 2}), -# ), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_create_row_from_dict(self, data: dict[str, Any], expected: Row) -> None: -# assert Row.from_dict(data) == expected -# -# -# class TestFromPandasDataFrame: -# @pytest.mark.parametrize( -# ("dataframe", "schema", "expected"), -# [ -# ( -# pd.DataFrame({"col1": [0]}), -# Schema({"col1": String()}), -# Schema({"col1": String()}), -# ), -# ( -# pd.DataFrame({"col1": [0], "col2": ["a"]}), -# Schema({"col1": String(), "col2": String()}), -# Schema({"col1": String(), "col2": String()}), -# ), -# ], -# ids=[ -# "one column", -# "two columns", -# ], -# ) -# def test_should_use_the_schema_if_passed(self, dataframe: pd.DataFrame, schema: Schema, expected: Schema) -> None: -# row = Row._from_pandas_dataframe(dataframe, schema) -# assert row._schema == expected -# -# @pytest.mark.parametrize( -# ("dataframe", "expected"), -# [ -# ( -# pd.DataFrame({"col1": [0]}), -# Schema({"col1": Integer()}), -# ), -# ( -# pd.DataFrame({"col1": [0], "col2": ["a"]}), -# Schema({"col1": Integer(), "col2": String()}), -# ), -# ], -# ids=[ -# "one column", -# "two columns", -# ], -# ) -# def test_should_infer_the_schema_if_not_passed(self, dataframe: pd.DataFrame, expected: Schema) -> None: -# row = Row._from_pandas_dataframe(dataframe) -# assert row._schema == expected -# -# @pytest.mark.parametrize( -# "dataframe", -# [ -# pd.DataFrame(), -# pd.DataFrame({"col1": [0, 1]}), -# ], -# ids=[ -# "empty", -# "two rows", -# ], -# ) -# def test_should_raise_if_dataframe_does_not_contain_exactly_one_row(self, dataframe: pd.DataFrame) -> None: -# with pytest.raises(ValueError, match=re.escape("The dataframe has to contain exactly one row.")): -# Row._from_pandas_dataframe(dataframe) -# -# -# class TestInit: -# @pytest.mark.parametrize( -# ("row", "expected"), -# [ -# (Row(), Schema({})), -# (Row({}), Schema({})), -# (Row({"col1": 0}), Schema({"col1": Integer()})), -# ], -# ids=[ -# "empty", -# "empty (explicit)", -# "one column", -# ], -# ) -# def test_should_infer_the_schema(self, row: Row, expected: Schema) -> None: -# assert row._schema == expected -# -# -# class TestContains: -# @pytest.mark.parametrize( -# ("row", "column_name", "expected"), -# [ -# (Row({}), "col1", False), -# (Row({"col1": 0}), "col1", True), -# (Row({"col1": 0}), "col2", False), -# (Row({"col1": 0}), 1, False), -# ], -# ids=[ -# "empty row", -# "column exists", -# "column does not exist", -# "not a string", -# ], -# ) -# def test_should_return_whether_the_row_has_the_column(self, row: Row, column_name: str, expected: bool) -> None: -# assert (column_name in row) == expected -# -# -# class TestEq: -# @pytest.mark.parametrize( -# ("row1", "row2", "expected"), -# [ -# (Row(), Row(), True), -# (Row({"col1": 0}), Row({"col1": 0}), True), -# (Row({"col1": 0}), Row({"col1": 1}), False), -# (Row({"col1": 0}), Row({"col2": 0}), False), -# (Row({"col1": 0}), Row({"col1": "a"}), False), -# ], -# ids=[ -# "empty rows", -# "equal rows", -# "different values", -# "different columns", -# "different types", -# ], -# ) -# def test_should_return_whether_two_rows_are_equal(self, row1: Row, row2: Row, expected: bool) -> None: -# assert (row1.__eq__(row2)) == expected -# -# @pytest.mark.parametrize( -# "row", -# [ -# Row(), -# Row({"col1": 0}), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_return_true_if_objects_are_identical(self, row: Row) -> None: -# assert (row.__eq__(row)) is True -# -# @pytest.mark.parametrize( -# ("row", "other"), -# [ -# (Row({"col1": 0}), None), -# (Row({"col1": 0}), Table({})), -# ], -# ids=[ -# "Row vs. None", -# "Row vs. Table", -# ], -# ) -# def test_should_return_not_implemented_if_other_is_not_row(self, row: Row, other: Any) -> None: -# assert (row.__eq__(other)) is NotImplemented -# -# -# class TestHash: -# @pytest.mark.parametrize( -# ("row1", "row2"), -# [ -# (Row(), Row()), -# (Row({"col1": 0}), Row({"col1": 0})), -# ], -# ids=[ -# "empty rows", -# "equal rows", -# ], -# ) -# def test_should_return_same_hash_for_equal_rows(self, row1: Row, row2: Row) -> None: -# assert hash(row1) == hash(row2) -# -# @pytest.mark.parametrize( -# ("row1", "row2"), -# [ -# (Row({"col1": 0}), Row({"col1": 1})), -# (Row({"col1": 0}), Row({"col2": 0})), -# (Row({"col1": 0}), Row({"col1": "a"})), -# ], -# ids=[ -# "different values", -# "different columns", -# "different types", -# ], -# ) -# def test_should_return_different_hash_for_unequal_rows(self, row1: Row, row2: Row) -> None: -# assert hash(row1) != hash(row2) -# -# -# class TestGetitem: -# @pytest.mark.parametrize( -# ("row", "column_name", "expected"), -# [ -# (Row({"col1": 0}), "col1", 0), -# (Row({"col1": 0, "col2": "a"}), "col2", "a"), -# ], -# ids=[ -# "one column", -# "two columns", -# ], -# ) -# def test_should_return_the_value_in_the_column(self, row: Row, column_name: str, expected: Any) -> None: -# assert row[column_name] == expected -# -# @pytest.mark.parametrize( -# ("row", "column_name"), -# [ -# (Row(), "col1"), -# (Row({"col1": 0}), "col2"), -# ], -# ids=[ -# "empty row", -# "column does not exist", -# ], -# ) -# def test_should_raise_if_column_does_not_exist(self, row: Row, column_name: str) -> None: -# with pytest.raises(ColumnNotFoundError): -# # noinspection PyStatementEffect -# row[column_name] -# -# -# class TestIter: -# @pytest.mark.parametrize( -# ("row", "expected"), -# [ -# (Row(), []), -# (Row({"col1": 0}), ["col1"]), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_return_an_iterator_for_the_column_names(self, row: Row, expected: list[str]) -> None: -# assert list(row) == expected -# -# -# class TestLen: -# @pytest.mark.parametrize( -# ("row", "expected"), -# [ -# (Row(), 0), -# (Row({"col1": 0, "col2": "a"}), 2), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_return_the_number_of_columns(self, row: Row, expected: int) -> None: -# assert len(row) == expected -# -# -# class TestRepr: -# @pytest.mark.parametrize( -# ("row", "expected"), -# [ -# (Row(), "Row({})"), -# (Row({"col1": 0}), "Row({'col1': 0})"), -# (Row({"col1": 0, "col2": "a"}), "Row({\n 'col1': 0,\n 'col2': 'a'\n})"), -# ], -# ids=[ -# "empty", -# "single column", -# "multiple columns", -# ], -# ) -# def test_should_return_a_string_representation(self, row: Row, expected: str) -> None: -# assert repr(row) == expected -# -# -# class TestStr: -# @pytest.mark.parametrize( -# ("row", "expected"), -# [ -# (Row(), "{}"), -# (Row({"col1": 0}), "{'col1': 0}"), -# (Row({"col1": 0, "col2": "a"}), "{\n 'col1': 0,\n 'col2': 'a'\n}"), -# ], -# ids=[ -# "empty", -# "single column", -# "multiple columns", -# ], -# ) -# def test_should_return_a_string_representation(self, row: Row, expected: str) -> None: -# assert str(row) == expected -# -# -# class TestColumnNames: -# @pytest.mark.parametrize( -# ("row", "expected"), -# [ -# (Row(), []), -# (Row({"col1": 0}), ["col1"]), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_return_the_column_names(self, row: Row, expected: list[str]) -> None: -# assert row.column_names == expected -# -# -# class TestColumnCount: -# @pytest.mark.parametrize( -# ("row", "expected"), -# [ -# (Row(), 0), -# (Row({"col1": 0, "col2": "a"}), 2), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_return_the_number_of_columns(self, row: Row, expected: int) -> None: -# assert row.column_count == expected -# -# -# class TestGetValue: -# @pytest.mark.parametrize( -# ("row", "column_name", "expected"), -# [ -# (Row({"col1": 0}), "col1", 0), -# (Row({"col1": 0, "col2": "a"}), "col2", "a"), -# ], -# ids=[ -# "one column", -# "two columns", -# ], -# ) -# def test_should_return_the_value_in_the_column(self, row: Row, column_name: str, expected: Any) -> None: -# assert row.get_value(column_name) == expected -# -# @pytest.mark.parametrize( -# ("row", "column_name"), -# [ -# (Row({}), "col1"), -# (Row({"col1": 0}), "col2"), -# ], -# ids=[ -# "empty row", -# "column does not exist", -# ], -# ) -# def test_should_raise_if_column_does_not_exist(self, row: Row, column_name: str) -> None: -# with pytest.raises(ColumnNotFoundError): -# row.get_value(column_name) -# -# -# class TestHasColumn: -# @pytest.mark.parametrize( -# ("row", "column_name", "expected"), -# [ -# (Row(), "col1", False), -# (Row({"col1": 0}), "col1", True), -# (Row({"col1": 0}), "col2", False), -# ], -# ids=[ -# "empty row", -# "column exists", -# "column does not exist", -# ], -# ) -# def test_should_return_whether_the_row_has_the_column(self, row: Row, column_name: str, expected: bool) -> None: -# assert row.has_column(column_name) == expected -# -# -# class TestGetColumnType: -# @pytest.mark.parametrize( -# ("row", "column_name", "expected"), -# [ -# (Row({"col1": 0}), "col1", Integer()), -# (Row({"col1": 0, "col2": "a"}), "col2", String()), -# ], -# ids=[ -# "one column", -# "two columns", -# ], -# ) -# def test_should_return_the_type_of_the_column(self, row: Row, column_name: str, expected: ColumnType) -> None: -# assert row.get_column_type(column_name) == expected -# -# @pytest.mark.parametrize( -# ("row", "column_name"), -# [ -# (Row(), "col1"), -# (Row({"col1": 0}), "col2"), -# ], -# ids=[ -# "empty row", -# "column does not exist", -# ], -# ) -# def test_should_raise_if_column_does_not_exist(self, row: Row, column_name: str) -> None: -# with pytest.raises(ColumnNotFoundError): -# row.get_column_type(column_name) -# -# -# class TestToDict: -# @pytest.mark.parametrize( -# ("row", "expected"), -# [ -# ( -# Row(), -# {}, -# ), -# ( -# Row({"a": 1, "b": 2}), -# { -# "a": 1, -# "b": 2, -# }, -# ), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_return_dict_for_table(self, row: Row, expected: dict[str, Any]) -> None: -# assert row.to_dict() == expected -# -# -# class TestReprHtml: -# @pytest.mark.parametrize( -# "row", -# [ -# Row(), -# Row({"a": 1, "b": 2}), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_contain_table_element(self, row: Row) -> None: -# pattern = r".*?" -# assert re.search(pattern, row._repr_html_(), flags=re.S) is not None -# -# @pytest.mark.parametrize( -# "row", -# [ -# Row(), -# Row({"a": 1, "b": 2}), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_contain_th_element_for_each_column_name(self, row: Row) -> None: -# for column_name in row.column_names: -# assert f"{column_name}" in row._repr_html_() -# -# @pytest.mark.parametrize( -# "row", -# [ -# Row(), -# Row({"a": 1, "b": 2}), -# ], -# ids=[ -# "empty", -# "non-empty", -# ], -# ) -# def test_should_contain_td_element_for_each_value(self, row: Row) -> None: -# for value in row.values(): -# assert f"{value}" in row._repr_html_() -# -# -# class TestSizeof: -# @pytest.mark.parametrize( -# "row", -# [ -# Row(), -# Row({"col1": 0}), -# Row({"col1": 0, "col2": "a"}), -# ], -# ids=[ -# "empty", -# "single column", -# "multiple columns", -# ], -# ) -# def test_should_size_be_greater_than_normal_object(self, row: Row) -> None: -# assert sys.getsizeof(row) > sys.getsizeof(object()) diff --git a/tests/safeds/data/tabular/plotting/test_moving_average_plot.py b/tests/safeds/data/tabular/plotting/test_moving_average_plot.py index b99855108..42d56f2d8 100644 --- a/tests/safeds/data/tabular/plotting/test_moving_average_plot.py +++ b/tests/safeds/data/tabular/plotting/test_moving_average_plot.py @@ -1,9 +1,10 @@ import datetime import pytest +from syrupy import SnapshotAssertion + from safeds.data.tabular.containers import Table from safeds.exceptions import ColumnNotFoundError, ColumnTypeError -from syrupy import SnapshotAssertion @pytest.mark.parametrize( diff --git a/tests/safeds/data/tabular/transformation/test_discretizer.py b/tests/safeds/data/tabular/transformation/test_discretizer.py index 703f845e1..ffac3be94 100644 --- a/tests/safeds/data/tabular/transformation/test_discretizer.py +++ b/tests/safeds/data/tabular/transformation/test_discretizer.py @@ -6,8 +6,8 @@ ColumnNotFoundError, ColumnTypeError, NonNumericColumnError, + NotFittedError, OutOfBoundsError, - TransformerNotFittedError, ) @@ -115,7 +115,7 @@ class TestTransform: ), ["col1"], NonNumericColumnError, - "Tried to do a numerical operation on one or multiple non-numerical columns: \ncol1 is of type String.", + "Tried to do a numerical operation on one or multiple non-numerical columns: \ncol1 is of type string.", ), ], ids=["ColumnNotFoundError", "multiple missing columns", "ValueError", "NonNumericColumnError"], @@ -148,7 +148,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = Discretizer() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.transform(table) diff --git a/tests/safeds/data/tabular/transformation/test_functional_table_transformer.py b/tests/safeds/data/tabular/transformation/test_functional_table_transformer.py index 44f510438..a5c118389 100644 --- a/tests/safeds/data/tabular/transformation/test_functional_table_transformer.py +++ b/tests/safeds/data/tabular/transformation/test_functional_table_transformer.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import FunctionalTableTransformer from safeds.exceptions import ColumnNotFoundError diff --git a/tests/safeds/data/tabular/transformation/test_k_nearest_neighbors_imputer.py b/tests/safeds/data/tabular/transformation/test_k_nearest_neighbors_imputer.py index 07dbef0e3..53c11fb17 100644 --- a/tests/safeds/data/tabular/transformation/test_k_nearest_neighbors_imputer.py +++ b/tests/safeds/data/tabular/transformation/test_k_nearest_neighbors_imputer.py @@ -4,8 +4,8 @@ from safeds.data.tabular.transformation import KNearestNeighborsImputer from safeds.exceptions import ( ColumnNotFoundError, + NotFittedError, OutOfBoundsError, - TransformerNotFittedError, ) @@ -88,7 +88,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = KNearestNeighborsImputer(neighbor_count=5) - with pytest.raises(TransformerNotFittedError): + with pytest.raises(NotFittedError): transformer.transform(table) diff --git a/tests/safeds/data/tabular/transformation/test_label_encoder.py b/tests/safeds/data/tabular/transformation/test_label_encoder.py index 2d2fb05f9..b19a72cda 100644 --- a/tests/safeds/data/tabular/transformation/test_label_encoder.py +++ b/tests/safeds/data/tabular/transformation/test_label_encoder.py @@ -1,7 +1,8 @@ import pytest + from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import LabelEncoder -from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, TransformerNotFittedError +from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, NotFittedError class TestFit: @@ -73,7 +74,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = LabelEncoder() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.transform(table) @@ -202,7 +203,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = LabelEncoder() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.inverse_transform(table) def test_should_raise_if_column_not_found(self) -> None: diff --git a/tests/safeds/data/tabular/transformation/test_one_hot_encoder.py b/tests/safeds/data/tabular/transformation/test_one_hot_encoder.py index 9bd62a685..67431c63c 100644 --- a/tests/safeds/data/tabular/transformation/test_one_hot_encoder.py +++ b/tests/safeds/data/tabular/transformation/test_one_hot_encoder.py @@ -3,12 +3,13 @@ import pytest from polars.testing import assert_frame_equal + from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import OneHotEncoder from safeds.exceptions import ( ColumnNotFoundError, ColumnTypeError, - TransformerNotFittedError, + NotFittedError, ) @@ -91,7 +92,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = OneHotEncoder() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.transform(table) @@ -382,7 +383,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = OneHotEncoder() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.inverse_transform(table) def test_should_raise_if_column_not_found(self) -> None: diff --git a/tests/safeds/data/tabular/transformation/test_range_scaler.py b/tests/safeds/data/tabular/transformation/test_range_scaler.py index 3aaa4c889..85e4069b6 100644 --- a/tests/safeds/data/tabular/transformation/test_range_scaler.py +++ b/tests/safeds/data/tabular/transformation/test_range_scaler.py @@ -1,7 +1,8 @@ import pytest + from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import RangeScaler -from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, TransformerNotFittedError +from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, NotFittedError class TestInit: @@ -73,7 +74,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = RangeScaler() - with pytest.raises(TransformerNotFittedError): + with pytest.raises(NotFittedError): transformer.transform(table) def test_should_raise_if_table_contains_non_numerical_data(self) -> None: @@ -257,7 +258,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = RangeScaler() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.inverse_transform(table) def test_should_raise_if_column_not_found(self) -> None: diff --git a/tests/safeds/data/tabular/transformation/test_robust_scaler.py b/tests/safeds/data/tabular/transformation/test_robust_scaler.py index 6c18b4878..49e110c64 100644 --- a/tests/safeds/data/tabular/transformation/test_robust_scaler.py +++ b/tests/safeds/data/tabular/transformation/test_robust_scaler.py @@ -1,9 +1,9 @@ import pytest + from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import RobustScaler -from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, TransformerNotFittedError - -from tests.helpers import assert_tables_equal +from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, NotFittedError +from tests.helpers import assert_tables_are_equal class TestFit: @@ -94,7 +94,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = RobustScaler() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.transform(table) def test_should_raise_if_table_contains_non_numerical_data(self) -> None: @@ -151,7 +151,7 @@ def test_should_return_fitted_transformer_and_transformed_table( ) -> None: fitted_transformer, transformed_table = RobustScaler(column_names=column_names).fit_and_transform(table) assert fitted_transformer.is_fitted - assert_tables_equal(transformed_table, expected) + assert_tables_are_equal(transformed_table, expected) def test_should_not_change_original_table(self) -> None: table = Table( @@ -205,7 +205,7 @@ def test_should_not_change_transformed_table(self) -> None: }, ) - assert_tables_equal(transformed_table, expected) + assert_tables_are_equal(transformed_table, expected) def test_should_raise_if_not_fitted(self) -> None: table = Table( @@ -216,7 +216,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = RobustScaler() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.inverse_transform(table) def test_should_raise_if_column_not_found(self) -> None: diff --git a/tests/safeds/data/tabular/transformation/test_sequential_table_transformer.py b/tests/safeds/data/tabular/transformation/test_sequential_table_transformer.py index 634c59aeb..0fa39e612 100644 --- a/tests/safeds/data/tabular/transformation/test_sequential_table_transformer.py +++ b/tests/safeds/data/tabular/transformation/test_sequential_table_transformer.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import ( Discretizer, @@ -9,13 +10,11 @@ StandardScaler, TableTransformer, ) -from safeds.exceptions import TransformerNotFittedError, TransformerNotInvertibleError - -from tests.helpers import assert_tables_equal +from safeds.exceptions import NotFittedError, NotInvertibleError +from tests.helpers import assert_tables_are_equal class TestInit: - def test_should_warn_on_empty_list(self) -> None: with pytest.warns(UserWarning, match=("transformers should contain at least 1 transformer")): SequentialTableTransformer(transformers=[]) # type: ignore[attr-defined] @@ -64,7 +63,7 @@ def test_should_raise_if_not_fitted(self) -> None: }, ) sequential_table_transformer = SequentialTableTransformer(transformers) - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): sequential_table_transformer.transform(test_table) @pytest.mark.parametrize( @@ -89,7 +88,7 @@ def test_should_do_same_as_transformer_with_single_transformer(self, transformer transformer = transformer.fit(test_table) test_table_normal = transformer.transform(test_table) test_table_sequential = sequential_transformer.transform(test_table) - assert_tables_equal(test_table_normal, test_table_sequential) + assert_tables_are_equal(test_table_normal, test_table_sequential) def test_should_transform_with_multiple_transformers(self) -> None: one_hot = OneHotEncoder() @@ -110,7 +109,7 @@ def test_should_transform_with_multiple_transformers(self) -> None: imputer = imputer.fit(transformed_table_individual) transformed_table_individual = imputer.transform(transformed_table_individual) - assert_tables_equal(transformed_table_sequential, transformed_table_individual) + assert_tables_are_equal(transformed_table_sequential, transformed_table_individual) class TestIsFitted: @@ -137,7 +136,6 @@ def test_should_return_true_after_fitting(self) -> None: class TestInverseTransform: - @pytest.mark.parametrize( "transformers", [ @@ -165,7 +163,7 @@ def test_should_raise_transformer_not_invertible_error_on_non_invertible_transfo sequential_table_transformer = SequentialTableTransformer(transformers) sequential_table_transformer = sequential_table_transformer.fit(test_table) transformed_table = sequential_table_transformer.transform(test_table) - with pytest.raises(TransformerNotInvertibleError, match=r".*is not invertible."): + with pytest.raises(NotInvertibleError, match=r".*is not invertible."): sequential_table_transformer.inverse_transform(transformed_table) @pytest.mark.parametrize( @@ -195,9 +193,9 @@ def test_should_return_original_table(self, transformers: list[TableTransformer] sequential_table_transformer = sequential_table_transformer.fit(test_table) transformed_table = sequential_table_transformer.transform(test_table) inverse_transformed_table = sequential_table_transformer.inverse_transform(transformed_table) - assert_tables_equal(test_table, inverse_transformed_table, ignore_column_order=True, ignore_types=True) + assert_tables_are_equal(test_table, inverse_transformed_table, ignore_column_order=True, ignore_types=True) - def test_should_raise_transformer_not_fitted_error_if_not_fited(self) -> None: + def test_should_raise_transformer_not_fitted_error_if_not_fitted(self) -> None: one_hot = OneHotEncoder() imputer = SimpleImputer(SimpleImputer.Strategy.constant(0)) transformers = [one_hot, imputer] @@ -208,5 +206,5 @@ def test_should_raise_transformer_not_fitted_error_if_not_fited(self) -> None: "col2": ["a", "b", "a"], }, ) - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): sequential_table_transformer.inverse_transform(test_table) diff --git a/tests/safeds/data/tabular/transformation/test_simple_imputer.py b/tests/safeds/data/tabular/transformation/test_simple_imputer.py index b0cfb4fc1..047f68f0a 100644 --- a/tests/safeds/data/tabular/transformation/test_simple_imputer.py +++ b/tests/safeds/data/tabular/transformation/test_simple_imputer.py @@ -2,10 +2,11 @@ import warnings import pytest + from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import SimpleImputer from safeds.data.tabular.transformation._simple_imputer import _Mode -from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, TransformerNotFittedError +from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, NotFittedError def strategies() -> list[SimpleImputer.Strategy]: @@ -253,7 +254,7 @@ def test_should_raise_if_not_fitted(self, strategy: SimpleImputer.Strategy) -> N transformer = SimpleImputer(strategy) - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.transform(table) diff --git a/tests/safeds/data/tabular/transformation/test_standard_scaler.py b/tests/safeds/data/tabular/transformation/test_standard_scaler.py index 0e731fa15..fd355c92f 100644 --- a/tests/safeds/data/tabular/transformation/test_standard_scaler.py +++ b/tests/safeds/data/tabular/transformation/test_standard_scaler.py @@ -1,9 +1,9 @@ import pytest + from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import StandardScaler -from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, TransformerNotFittedError - -from tests.helpers import assert_tables_equal +from safeds.exceptions import ColumnNotFoundError, ColumnTypeError, NotFittedError +from tests.helpers import assert_tables_are_equal class TestFit: @@ -71,7 +71,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = StandardScaler() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.transform(table) def test_should_raise_if_table_contains_non_numerical_data(self) -> None: @@ -128,7 +128,7 @@ def test_should_return_fitted_transformer_and_transformed_table( ) -> None: fitted_transformer, transformed_table = StandardScaler(column_names=column_names).fit_and_transform(table) assert fitted_transformer.is_fitted - assert_tables_equal(transformed_table, expected) + assert_tables_are_equal(transformed_table, expected) def test_should_not_change_original_table(self) -> None: table = Table( @@ -182,7 +182,7 @@ def test_should_not_change_transformed_table(self) -> None: }, ) - assert_tables_equal(transformed_table, expected) + assert_tables_are_equal(transformed_table, expected) def test_should_raise_if_not_fitted(self) -> None: table = Table( @@ -193,7 +193,7 @@ def test_should_raise_if_not_fitted(self) -> None: transformer = StandardScaler() - with pytest.raises(TransformerNotFittedError, match=r"The transformer has not been fitted yet."): + with pytest.raises(NotFittedError, match=r"This transformer has not been fitted yet."): transformer.inverse_transform(table) def test_should_raise_if_column_not_found(self) -> None: diff --git a/tests/safeds/data/tabular/transformation/test_table_transformer.py b/tests/safeds/data/tabular/transformation/test_table_transformer.py index 9ac60afa3..c4113c53c 100644 --- a/tests/safeds/data/tabular/transformation/test_table_transformer.py +++ b/tests/safeds/data/tabular/transformation/test_table_transformer.py @@ -1,6 +1,7 @@ import itertools import pytest + from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import ( Discretizer, @@ -81,7 +82,7 @@ def transformers() -> list[TableTransformer]: ) -@pytest.fixture() +@pytest.fixture def valid_data_numeric() -> Table: return Table( { @@ -90,7 +91,7 @@ def valid_data_numeric() -> Table: ) -@pytest.fixture() +@pytest.fixture def valid_data_non_numeric() -> Table: return Table( { @@ -99,7 +100,7 @@ def valid_data_non_numeric() -> Table: ) -@pytest.fixture() +@pytest.fixture def valid_data_imputer() -> Table: return Table( { diff --git a/tests/safeds/data/tabular/typing/_column_type/__init__.py b/tests/safeds/data/tabular/typing/_column_type/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/safeds/data/tabular/typing/_column_type/__snapshots__/test_hash.ambr b/tests/safeds/data/tabular/typing/_column_type/__snapshots__/test_hash.ambr new file mode 100644 index 000000000..8b828b8fa --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/__snapshots__/test_hash.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: TestContract.test_should_return_same_hash_in_different_processes[binary] + 9114564521908245695 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[boolean] + 253312121440042057 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[date] + 1420129173814739165 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[datetime] + 5037917593634747378 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[duration] + 61885111053208984 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[float32] + 2208906555873308475 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[float64] + 7992127945058937926 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[int16] + 231909494783576033 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[int32] + 1374055615029084484 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[int64] + 1212246054477521308 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[int8] + 2269551742465714004 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[null] + 1357169886076964811 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[string] + 1529874693733179990 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[time] + 5418105405405492344 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[uint16] + 937488211706751048 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[uint32] + 859241000506496144 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[uint64] + 1728989395548489096 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[uint8] + 48638156943199247 +# --- diff --git a/tests/safeds/data/tabular/typing/_column_type/test_eq.py b/tests/safeds/data/tabular/typing/_column_type/test_eq.py new file mode 100644 index 000000000..2edeba7a2 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_eq.py @@ -0,0 +1,89 @@ +from typing import Any + +import pytest + +from safeds.data.tabular.containers import Column, Table +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + ("type_1", "type_2", "expected"), + [ + (ColumnType.float32(), ColumnType.float32(), True), + (ColumnType.float32(), ColumnType.float64(), False), + (ColumnType.float32(), ColumnType.int32(), False), + (ColumnType.int32(), ColumnType.uint32(), False), + (ColumnType.int32(), ColumnType.string(), False), + ], + ids=[ + "equal", + "not equal (different bit count)", + "not equal (float vs. int)", + "not equal (signed vs. unsigned)", + "not equal (numeric vs. non-numeric)", + ], +) +def test_should_return_whether_column_types_are_equal(type_1: Table, type_2: Table, expected: bool) -> None: + assert (type_1.__eq__(type_2)) == expected + + +@pytest.mark.parametrize( + "type_", + [ + ColumnType.float32(), + ColumnType.float64(), + ColumnType.int8(), + ColumnType.int16(), + ColumnType.int32(), + ColumnType.int64(), + ColumnType.uint8(), + ColumnType.uint16(), + ColumnType.uint32(), + ColumnType.uint64(), + ColumnType.date(), + ColumnType.datetime(), + ColumnType.duration(), + ColumnType.time(), + ColumnType.string(), + ColumnType.binary(), + ColumnType.boolean(), + ColumnType.null(), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_return_true_if_column_types_are_identical(type_: ColumnType) -> None: + assert (type_.__eq__(type_)) is True + + +@pytest.mark.parametrize( + ("type_", "other"), + [ + (ColumnType.null(), None), + (ColumnType.null(), Column("col1", [])), + ], + ids=[ + "ColumnType vs. None", + "ColumnType vs. Column", + ], +) +def test_should_return_not_implemented_if_other_is_not_column_type(type_: ColumnType, other: Any) -> None: + assert (type_.__eq__(other)) is NotImplemented diff --git a/tests/safeds/data/tabular/typing/_column_type/test_hash.py b/tests/safeds/data/tabular/typing/_column_type/test_hash.py new file mode 100644 index 000000000..c5c7c2c21 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_hash.py @@ -0,0 +1,86 @@ +from collections.abc import Callable + +import pytest +from syrupy import SnapshotAssertion + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + "type_factory", + [ + lambda: ColumnType.float32(), + lambda: ColumnType.float64(), + lambda: ColumnType.int8(), + lambda: ColumnType.int16(), + lambda: ColumnType.int32(), + lambda: ColumnType.int64(), + lambda: ColumnType.uint8(), + lambda: ColumnType.uint16(), + lambda: ColumnType.uint32(), + lambda: ColumnType.uint64(), + lambda: ColumnType.date(), + lambda: ColumnType.datetime(), + lambda: ColumnType.duration(), + lambda: ColumnType.time(), + lambda: ColumnType.string(), + lambda: ColumnType.binary(), + lambda: ColumnType.boolean(), + lambda: ColumnType.null(), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +class TestContract: + def test_should_return_same_hash_for_equal_objects(self, type_factory: Callable[[], ColumnType]) -> None: + type_1 = type_factory() + type_2 = type_factory() + assert hash(type_1) == hash(type_2) + + def test_should_return_same_hash_in_different_processes( + self, + type_factory: Callable[[], ColumnType], + snapshot: SnapshotAssertion, + ) -> None: + type_ = type_factory() + assert hash(type_) == snapshot + + +@pytest.mark.parametrize( + ("type_1", "type_2"), + [ + (ColumnType.float32(), ColumnType.float64()), + (ColumnType.float32(), ColumnType.int32()), + (ColumnType.int32(), ColumnType.uint32()), + (ColumnType.int32(), ColumnType.string()), + ], + ids=[ + "different bit count", + "float vs. int", + "signed vs. unsigned", + "numeric vs. non-numeric", + ], +) +def test_should_be_good_hash( + type_1: ColumnType, + type_2: ColumnType, +) -> None: + assert hash(type_1) != hash(type_2) diff --git a/tests/safeds/data/tabular/typing/_column_type/test_is_float.py b/tests/safeds/data/tabular/typing/_column_type/test_is_float.py new file mode 100644 index 000000000..a459c3f41 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_is_float.py @@ -0,0 +1,50 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + ("type_", "expected"), + [ + (ColumnType.float32(), True), + (ColumnType.float64(), True), + (ColumnType.int8(), False), + (ColumnType.int16(), False), + (ColumnType.int32(), False), + (ColumnType.int64(), False), + (ColumnType.uint8(), False), + (ColumnType.uint16(), False), + (ColumnType.uint32(), False), + (ColumnType.uint64(), False), + (ColumnType.date(), False), + (ColumnType.datetime(), False), + (ColumnType.duration(), False), + (ColumnType.time(), False), + (ColumnType.string(), False), + (ColumnType.binary(), False), + (ColumnType.boolean(), False), + (ColumnType.null(), False), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_return_whether_type_represents_floats(type_: ColumnType, expected: bool) -> None: + assert type_.is_float == expected diff --git a/tests/safeds/data/tabular/typing/_column_type/test_is_int.py b/tests/safeds/data/tabular/typing/_column_type/test_is_int.py new file mode 100644 index 000000000..48c7b74a5 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_is_int.py @@ -0,0 +1,50 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + ("type_", "expected"), + [ + (ColumnType.float32(), False), + (ColumnType.float64(), False), + (ColumnType.int8(), True), + (ColumnType.int16(), True), + (ColumnType.int32(), True), + (ColumnType.int64(), True), + (ColumnType.uint8(), True), + (ColumnType.uint16(), True), + (ColumnType.uint32(), True), + (ColumnType.uint64(), True), + (ColumnType.date(), False), + (ColumnType.datetime(), False), + (ColumnType.duration(), False), + (ColumnType.time(), False), + (ColumnType.string(), False), + (ColumnType.binary(), False), + (ColumnType.boolean(), False), + (ColumnType.null(), False), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_return_whether_type_represents_ints(type_: ColumnType, expected: bool) -> None: + assert type_.is_int == expected diff --git a/tests/safeds/data/tabular/typing/_column_type/test_is_numeric.py b/tests/safeds/data/tabular/typing/_column_type/test_is_numeric.py new file mode 100644 index 000000000..36ebf5a16 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_is_numeric.py @@ -0,0 +1,50 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + ("type_", "expected"), + [ + (ColumnType.float32(), True), + (ColumnType.float64(), True), + (ColumnType.int8(), True), + (ColumnType.int16(), True), + (ColumnType.int32(), True), + (ColumnType.int64(), True), + (ColumnType.uint8(), True), + (ColumnType.uint16(), True), + (ColumnType.uint32(), True), + (ColumnType.uint64(), True), + (ColumnType.date(), False), + (ColumnType.datetime(), False), + (ColumnType.duration(), False), + (ColumnType.time(), False), + (ColumnType.string(), False), + (ColumnType.binary(), False), + (ColumnType.boolean(), False), + (ColumnType.null(), False), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_return_whether_type_represents_numbers(type_: ColumnType, expected: bool) -> None: + assert type_.is_numeric == expected diff --git a/tests/safeds/data/tabular/typing/_column_type/test_is_signed_int.py b/tests/safeds/data/tabular/typing/_column_type/test_is_signed_int.py new file mode 100644 index 000000000..a1e8befa6 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_is_signed_int.py @@ -0,0 +1,50 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + ("type_", "expected"), + [ + (ColumnType.float32(), False), + (ColumnType.float64(), False), + (ColumnType.int8(), True), + (ColumnType.int16(), True), + (ColumnType.int32(), True), + (ColumnType.int64(), True), + (ColumnType.uint8(), False), + (ColumnType.uint16(), False), + (ColumnType.uint32(), False), + (ColumnType.uint64(), False), + (ColumnType.date(), False), + (ColumnType.datetime(), False), + (ColumnType.duration(), False), + (ColumnType.time(), False), + (ColumnType.string(), False), + (ColumnType.binary(), False), + (ColumnType.boolean(), False), + (ColumnType.null(), False), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_return_whether_type_represents_signed_ints(type_: ColumnType, expected: bool) -> None: + assert type_.is_signed_int == expected diff --git a/tests/safeds/data/tabular/typing/_column_type/test_is_temporal.py b/tests/safeds/data/tabular/typing/_column_type/test_is_temporal.py new file mode 100644 index 000000000..f3b0d602b --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_is_temporal.py @@ -0,0 +1,50 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + ("type_", "expected"), + [ + (ColumnType.float32(), False), + (ColumnType.float64(), False), + (ColumnType.int8(), False), + (ColumnType.int16(), False), + (ColumnType.int32(), False), + (ColumnType.int64(), False), + (ColumnType.uint8(), False), + (ColumnType.uint16(), False), + (ColumnType.uint32(), False), + (ColumnType.uint64(), False), + (ColumnType.date(), True), + (ColumnType.datetime(), True), + (ColumnType.duration(), True), + (ColumnType.time(), True), + (ColumnType.string(), False), + (ColumnType.binary(), False), + (ColumnType.boolean(), False), + (ColumnType.null(), False), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_return_whether_type_represents_temporal_data(type_: ColumnType, expected: bool) -> None: + assert type_.is_temporal == expected diff --git a/tests/safeds/data/tabular/typing/_column_type/test_is_unsigned_int.py b/tests/safeds/data/tabular/typing/_column_type/test_is_unsigned_int.py new file mode 100644 index 000000000..235990a2d --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_is_unsigned_int.py @@ -0,0 +1,50 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + ("type_", "expected"), + [ + (ColumnType.float32(), False), + (ColumnType.float64(), False), + (ColumnType.int8(), False), + (ColumnType.int16(), False), + (ColumnType.int32(), False), + (ColumnType.int64(), False), + (ColumnType.uint8(), True), + (ColumnType.uint16(), True), + (ColumnType.uint32(), True), + (ColumnType.uint64(), True), + (ColumnType.date(), False), + (ColumnType.datetime(), False), + (ColumnType.duration(), False), + (ColumnType.time(), False), + (ColumnType.string(), False), + (ColumnType.binary(), False), + (ColumnType.boolean(), False), + (ColumnType.null(), False), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_return_whether_type_represents_unsigned_ints(type_: ColumnType, expected: bool) -> None: + assert type_.is_unsigned_int == expected diff --git a/tests/safeds/data/tabular/typing/_column_type/test_repr.py b/tests/safeds/data/tabular/typing/_column_type/test_repr.py new file mode 100644 index 000000000..ead9e975c --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_repr.py @@ -0,0 +1,50 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + ("type_", "expected"), + [ + (ColumnType.float32(), "float32"), + (ColumnType.float64(), "float64"), + (ColumnType.int8(), "int8"), + (ColumnType.int16(), "int16"), + (ColumnType.int32(), "int32"), + (ColumnType.int64(), "int64"), + (ColumnType.uint8(), "uint8"), + (ColumnType.uint16(), "uint16"), + (ColumnType.uint32(), "uint32"), + (ColumnType.uint64(), "uint64"), + (ColumnType.date(), "date"), + (ColumnType.datetime(), "datetime"), + (ColumnType.duration(), "duration"), + (ColumnType.time(), "time"), + (ColumnType.string(), "string"), + (ColumnType.binary(), "binary"), + (ColumnType.boolean(), "boolean"), + (ColumnType.null(), "null"), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_return_a_string_representation(type_: ColumnType, expected: bool) -> None: + assert repr(type_) == expected diff --git a/tests/safeds/data/tabular/typing/_column_type/test_sizeof.py b/tests/safeds/data/tabular/typing/_column_type/test_sizeof.py new file mode 100644 index 000000000..1ec47ae74 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_sizeof.py @@ -0,0 +1,52 @@ +import sys + +import pytest + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + "type_", + [ + ColumnType.float32(), + ColumnType.float64(), + ColumnType.int8(), + ColumnType.int16(), + ColumnType.int32(), + ColumnType.int64(), + ColumnType.uint8(), + ColumnType.uint16(), + ColumnType.uint32(), + ColumnType.uint64(), + ColumnType.date(), + ColumnType.datetime(), + ColumnType.duration(), + ColumnType.time(), + ColumnType.string(), + ColumnType.binary(), + ColumnType.boolean(), + ColumnType.null(), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_size_be_greater_than_normal_object(type_: ColumnType) -> None: + assert sys.getsizeof(type_) > sys.getsizeof(object()) diff --git a/tests/safeds/data/tabular/typing/_column_type/test_str.py b/tests/safeds/data/tabular/typing/_column_type/test_str.py new file mode 100644 index 000000000..c1eefdaeb --- /dev/null +++ b/tests/safeds/data/tabular/typing/_column_type/test_str.py @@ -0,0 +1,50 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType + + +@pytest.mark.parametrize( + ("type_", "expected"), + [ + (ColumnType.float32(), "float32"), + (ColumnType.float64(), "float64"), + (ColumnType.int8(), "int8"), + (ColumnType.int16(), "int16"), + (ColumnType.int32(), "int32"), + (ColumnType.int64(), "int64"), + (ColumnType.uint8(), "uint8"), + (ColumnType.uint16(), "uint16"), + (ColumnType.uint32(), "uint32"), + (ColumnType.uint64(), "uint64"), + (ColumnType.date(), "date"), + (ColumnType.datetime(), "datetime"), + (ColumnType.duration(), "duration"), + (ColumnType.time(), "time"), + (ColumnType.string(), "string"), + (ColumnType.binary(), "binary"), + (ColumnType.boolean(), "boolean"), + (ColumnType.null(), "null"), + ], + ids=[ + "float32", + "float64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "date", + "datetime", + "duration", + "time", + "string", + "binary", + "boolean", + "null", + ], +) +def test_should_return_a_string_representation(type_: ColumnType, expected: bool) -> None: + assert str(type_) == expected diff --git a/tests/safeds/data/tabular/typing/_schema/__init__.py b/tests/safeds/data/tabular/typing/_schema/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/safeds/data/tabular/typing/_schema/__snapshots__/test_hash.ambr b/tests/safeds/data/tabular/typing/_schema/__snapshots__/test_hash.ambr new file mode 100644 index 000000000..0f36d0e34 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/__snapshots__/test_hash.ambr @@ -0,0 +1,10 @@ +# serializer version: 1 +# name: TestContract.test_should_return_same_hash_in_different_processes[empty] + 2230328334128810753 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[one column] + 2245614720678699598 +# --- +# name: TestContract.test_should_return_same_hash_in_different_processes[two columns] + 1881809609867026590 +# --- diff --git a/tests/safeds/data/tabular/typing/_schema/test_column_count.py b/tests/safeds/data/tabular/typing/_schema/test_column_count.py new file mode 100644 index 000000000..c4b3ebf07 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_column_count.py @@ -0,0 +1,20 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + ("schema", "expected"), + [ + (Schema({}), 0), + (Schema({"col1": ColumnType.null()}), 1), + (Schema({"col1": ColumnType.null(), "col2": ColumnType.null()}), 2), + ], + ids=[ + "empty", + "one column", + "two columns", + ], +) +def test_should_return_number_of_columns(schema: Schema, expected: int) -> None: + assert schema.column_count == expected diff --git a/tests/safeds/data/tabular/typing/_schema/test_column_names.py b/tests/safeds/data/tabular/typing/_schema/test_column_names.py new file mode 100644 index 000000000..d3d84f01e --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_column_names.py @@ -0,0 +1,20 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + ("schema", "expected"), + [ + (Schema({}), []), + (Schema({"col1": ColumnType.null()}), ["col1"]), + (Schema({"col1": ColumnType.null(), "col2": ColumnType.null()}), ["col1", "col2"]), + ], + ids=[ + "empty", + "one column", + "two columns", + ], +) +def test_should_return_column_names(schema: Schema, expected: list[str]) -> None: + assert schema.column_names == expected diff --git a/tests/safeds/data/tabular/typing/_schema/test_eq.py b/tests/safeds/data/tabular/typing/_schema/test_eq.py new file mode 100644 index 000000000..668d029b4 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_eq.py @@ -0,0 +1,107 @@ +from typing import Any + +import pytest + +from safeds.data.tabular.containers import Column +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + ("schema_1", "schema_2", "expected"), + [ + # equal (empty) + ( + Schema({}), + Schema({}), + True, + ), + # equal (one column) + ( + Schema({"col1": ColumnType.null()}), + Schema({"col1": ColumnType.null()}), + True, + ), + # equal (two columns) + ( + Schema({"col1": ColumnType.null(), "col2": ColumnType.int8()}), + Schema({"col1": ColumnType.null(), "col2": ColumnType.int8()}), + True, + ), + # not equal (too few columns) + ( + Schema({"col1": ColumnType.null()}), + Schema({}), + False, + ), + # not equal (too many columns) + ( + Schema({}), + Schema({"col1": ColumnType.null()}), + False, + ), + # not equal (different column order) + ( + Schema({"col1": ColumnType.null(), "col2": ColumnType.int8()}), + Schema({"col2": ColumnType.int8(), "col1": ColumnType.null()}), + False, + ), + # not equal (different column names) + ( + Schema({"col1": ColumnType.null()}), + Schema({"col2": ColumnType.null()}), + False, + ), + # not equal (different types) + ( + Schema({"col1": ColumnType.null()}), + Schema({"col1": ColumnType.int8()}), + False, + ), + ], + ids=[ + # Equal + "equal (empty)", + "equal (one column)", + "equal (two columns)", + # Not equal + "not equal (too few columns)", + "not equal (too many columns)", + "not equal (different column order)", + "not equal (different column names)", + "not equal (different types)", + ], +) +def test_should_return_whether_schemas_are_equal(schema_1: Schema, schema_2: Schema, expected: bool) -> None: + assert (schema_1.__eq__(schema_2)) == expected + + +@pytest.mark.parametrize( + "schema", + [ + Schema({}), + Schema({"col1": ColumnType.null()}), + Schema({"col1": ColumnType.null(), "col2": ColumnType.null()}), + ], + ids=[ + "empty", + "one column", + "two columns", + ], +) +def test_should_return_true_if_schemas_are_identical(schema: Schema) -> None: + assert (schema.__eq__(schema)) is True + + +@pytest.mark.parametrize( + ("schema", "other"), + [ + (Schema({}), None), + (Schema({}), Column("col1", [])), + ], + ids=[ + "Schema vs. None", + "Schema vs. Column", + ], +) +def test_should_return_not_implemented_if_other_is_not_schema(schema: Schema, other: Any) -> None: + assert (schema.__eq__(other)) is NotImplemented diff --git a/tests/safeds/data/tabular/typing/_schema/test_get_column_type.py b/tests/safeds/data/tabular/typing/_schema/test_get_column_type.py new file mode 100644 index 000000000..713291ebf --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_get_column_type.py @@ -0,0 +1,29 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType, Schema +from safeds.exceptions import ColumnNotFoundError + + +@pytest.mark.parametrize( + ("schema", "name", "expected"), + [ + ( + Schema({"col1": ColumnType.int64()}), + "col1", + ColumnType.int64(), + ), + ( + Schema({"col1": ColumnType.string()}), + "col1", + ColumnType.string(), + ), + ], + ids=["int column", "string column"], +) +def test_should_return_data_type_of_column(schema: Schema, name: str, expected: ColumnType) -> None: + assert schema.get_column_type(name) == expected + + +def test_should_raise_if_column_name_is_unknown() -> None: + with pytest.raises(ColumnNotFoundError): + Schema({}).get_column_type("col1") diff --git a/tests/safeds/data/tabular/typing/_schema/test_has_column.py b/tests/safeds/data/tabular/typing/_schema/test_has_column.py new file mode 100644 index 000000000..38a182ccd --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_has_column.py @@ -0,0 +1,16 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + ("schema", "column", "expected"), + [ + (Schema({}), "C", False), + (Schema({"A": ColumnType.null()}), "A", True), + (Schema({"A": ColumnType.null()}), "B", False), + ], + ids=["empty", "has column", "doesn't have column"], +) +def test_should_return_if_column_is_in_schema(schema: Schema, column: str, expected: bool) -> None: + assert schema.has_column(column) == expected diff --git a/tests/safeds/data/tabular/typing/_schema/test_hash.py b/tests/safeds/data/tabular/typing/_schema/test_hash.py new file mode 100644 index 000000000..3fd9ea31a --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_hash.py @@ -0,0 +1,78 @@ +from collections.abc import Callable + +import pytest +from syrupy import SnapshotAssertion + +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + "schema_factory", + [ + lambda: Schema({}), + lambda: Schema({"col1": ColumnType.null()}), + lambda: Schema({"col1": ColumnType.null(), "col2": ColumnType.null()}), + ], + ids=[ + "empty", + "one column", + "two columns", + ], +) +class TestContract: + def test_should_return_same_hash_for_equal_objects(self, schema_factory: Callable[[], Schema]) -> None: + schema_1 = schema_factory() + schema_2 = schema_factory() + assert hash(schema_1) == hash(schema_2) + + def test_should_return_same_hash_in_different_processes( + self, + schema_factory: Callable[[], Schema], + snapshot: SnapshotAssertion, + ) -> None: + schema_ = schema_factory() + assert hash(schema_) == snapshot + + +@pytest.mark.parametrize( + ("schema_1", "schema_2"), + [ + # not equal (too few columns) + ( + Schema({"col1": ColumnType.null()}), + Schema({}), + ), + # not equal (too many columns) + ( + Schema({}), + Schema({"col1": ColumnType.null()}), + ), + # not equal (different column order) + ( + Schema({"col1": ColumnType.null(), "col2": ColumnType.int8()}), + Schema({"col2": ColumnType.int8(), "col1": ColumnType.null()}), + ), + # not equal (different column names) + ( + Schema({"col1": ColumnType.null()}), + Schema({"col2": ColumnType.null()}), + ), + # not equal (different types) + ( + Schema({"col1": ColumnType.null()}), + Schema({"col1": ColumnType.int8()}), + ), + ], + ids=[ + "too few columns", + "too many columns", + "different column order", + "different column names", + "different types", + ], +) +def test_should_be_good_hash( + schema_1: Schema, + schema_2: Schema, +) -> None: + assert hash(schema_1) != hash(schema_2) diff --git a/tests/safeds/data/tabular/typing/_schema/test_repr.py b/tests/safeds/data/tabular/typing/_schema/test_repr.py new file mode 100644 index 000000000..212aa6b2e --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_repr.py @@ -0,0 +1,29 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + ("schema", "expected"), + [ + ( + Schema({}), + "Schema({})", + ), + ( + Schema({"col1": ColumnType.null()}), + "Schema({'col1': null})", + ), + ( + Schema({"col1": ColumnType.null(), "col2": ColumnType.int8()}), + "Schema({\n 'col1': null,\n 'col2': int8\n})", + ), + ], + ids=[ + "empty", + "one column", + "two columns", + ], +) +def test_should_return_a_string_representation(schema: Schema, expected: str) -> None: + assert repr(schema) == expected diff --git a/tests/safeds/data/tabular/typing/_schema/test_repr_markdown.py b/tests/safeds/data/tabular/typing/_schema/test_repr_markdown.py new file mode 100644 index 000000000..72a3d2777 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_repr_markdown.py @@ -0,0 +1,29 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + ("schema", "expected"), + [ + ( + Schema({}), + "Empty schema", + ), + ( + Schema({"col1": ColumnType.null()}), + "| Column Name | Column Type |\n| --- | --- |\n| col1 | Null |", + ), + ( + Schema({"col1": ColumnType.null(), "col2": ColumnType.int8()}), + "| Column Name | Column Type |\n| --- | --- |\n| col1 | Null |\n| col2 | Int8 |", + ), + ], + ids=[ + "empty", + "one column", + "two columns", + ], +) +def test_should_return_markdown(schema: Schema, expected: str) -> None: + assert schema._repr_markdown_() == expected diff --git a/tests/safeds/data/tabular/typing/_schema/test_sizeof.py b/tests/safeds/data/tabular/typing/_schema/test_sizeof.py new file mode 100644 index 000000000..5b8cf2021 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_sizeof.py @@ -0,0 +1,22 @@ +import sys + +import pytest + +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + "schema", + [ + Schema({}), + Schema({"col1": ColumnType.null()}), + Schema({"col1": ColumnType.null(), "col2": ColumnType.null()}), + ], + ids=[ + "empty", + "one column", + "two columns", + ], +) +def test_should_size_be_greater_than_normal_object(schema: Schema) -> None: + assert sys.getsizeof(schema) > sys.getsizeof(object()) diff --git a/tests/safeds/data/tabular/typing/_schema/test_str.py b/tests/safeds/data/tabular/typing/_schema/test_str.py new file mode 100644 index 000000000..ff7aeb10d --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_str.py @@ -0,0 +1,29 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + ("schema", "expected"), + [ + ( + Schema({}), + "{}", + ), + ( + Schema({"col1": ColumnType.null()}), + "{'col1': null}", + ), + ( + Schema({"col1": ColumnType.null(), "col2": ColumnType.int8()}), + "{\n 'col1': null,\n 'col2': int8\n}", + ), + ], + ids=[ + "empty", + "one column", + "two columns", + ], +) +def test_should_return_a_string_representation(schema: Schema, expected: str) -> None: + assert str(schema) == expected diff --git a/tests/safeds/data/tabular/typing/_schema/test_to_dict.py b/tests/safeds/data/tabular/typing/_schema/test_to_dict.py new file mode 100644 index 000000000..daff207c0 --- /dev/null +++ b/tests/safeds/data/tabular/typing/_schema/test_to_dict.py @@ -0,0 +1,29 @@ +import pytest + +from safeds.data.tabular.typing import ColumnType, Schema + + +@pytest.mark.parametrize( + ("schema", "expected"), + [ + ( + Schema({}), + {}, + ), + ( + Schema({"col1": ColumnType.null()}), + {"col1": ColumnType.null()}, + ), + ( + Schema({"col1": ColumnType.null(), "col2": ColumnType.int8()}), + {"col1": ColumnType.null(), "col2": ColumnType.int8()}, + ), + ], + ids=[ + "empty", + "one column", + "two columns", + ], +) +def test_should_return_dictionary(schema: Schema, expected: dict[str, ColumnType]) -> None: + assert schema.to_dict() == expected diff --git a/tests/safeds/data/tabular/typing/test_data_type.py b/tests/safeds/data/tabular/typing/test_data_type.py deleted file mode 100644 index 3f8b9663a..000000000 --- a/tests/safeds/data/tabular/typing/test_data_type.py +++ /dev/null @@ -1,124 +0,0 @@ -# TODO -# from collections.abc import Iterable -# from typing import Any -# -# import numpy as np -# import pandas as pd -# import pytest -# from safeds.data.tabular.typing import ( -# Anything, -# Boolean, -# ColumnType, -# Integer, -# Nothing, -# RealNumber, -# String, -# ) -# -# -# class TestDataType: -# @pytest.mark.parametrize( -# ("data", "expected"), -# [ -# ([1, 2, 3], Integer(is_nullable=False)), -# ([1.0, 2.0, 3.0], Integer(is_nullable=False)), -# ([1.0, 2.5, 3.0], RealNumber(is_nullable=False)), -# ([True, False, True], Boolean(is_nullable=False)), -# (["a", "b", "c"], String(is_nullable=False)), -# (["a", 1, 2.0], Anything(is_nullable=False)), -# ([None, None, None], Nothing()), -# ([None, 1, 2], Integer(is_nullable=True)), -# ([1.0, 2.0, None], Integer(is_nullable=True)), -# ([1.0, 2.5, None], RealNumber(is_nullable=True)), -# ([True, False, None], Boolean(is_nullable=True)), -# (["a", None, "b"], String(is_nullable=True)), -# ], -# ids=[ -# "Integer", -# "Real number .0", -# "Real number", -# "Boolean", -# "String", -# "Mixed", -# "None", -# "Nullable integer", -# "Nullable RealNumber .0", -# "Nullable RealNumber", -# "Nullable Boolean", -# "Nullable String", -# ], -# ) -# def test_should_return_the_data_type(self, data: Iterable, expected: ColumnType) -> None: -# assert ColumnType._data_type(pd.Series(data)) == expected -# -# @pytest.mark.parametrize( -# ("data", "error_message"), -# [(np.array([1, 2, 3], dtype=np.int16), "Unsupported numpy data type ''.")], -# ids=["int16 not supported"], -# ) -# def test_should_throw_not_implemented_error_when_type_is_not_supported(self, data: Any, error_message: str) -> None: -# with pytest.raises(NotImplementedError, match=error_message): -# ColumnType._data_type(data) -# -# -# class TestRepr: -# @pytest.mark.parametrize( -# ("column_type", "expected"), -# [ -# (Anything(is_nullable=False), "Anything"), -# (Anything(is_nullable=True), "Anything?"), -# (Boolean(is_nullable=False), "Boolean"), -# (Boolean(is_nullable=True), "Boolean?"), -# (RealNumber(is_nullable=False), "RealNumber"), -# (RealNumber(is_nullable=True), "RealNumber?"), -# (Integer(is_nullable=False), "Integer"), -# (Integer(is_nullable=True), "Integer?"), -# (String(is_nullable=False), "String"), -# (String(is_nullable=True), "String?"), -# ], -# ids=repr, -# ) -# def test_should_create_a_printable_representation(self, column_type: ColumnType, expected: str) -> None: -# assert repr(column_type) == expected -# -# -# class TestIsNullable: -# @pytest.mark.parametrize( -# ("column_type", "expected"), -# [ -# (Anything(is_nullable=False), False), -# (Anything(is_nullable=True), True), -# (Boolean(is_nullable=False), False), -# (Boolean(is_nullable=True), True), -# (RealNumber(is_nullable=False), False), -# (RealNumber(is_nullable=True), True), -# (Integer(is_nullable=False), False), -# (Integer(is_nullable=True), True), -# (String(is_nullable=False), False), -# (String(is_nullable=True), True), -# ], -# ids=repr, -# ) -# def test_should_return_whether_the_column_type_is_nullable(self, column_type: ColumnType, expected: bool) -> None: -# assert column_type.is_nullable() == expected -# -# -# class TestIsNumeric: -# @pytest.mark.parametrize( -# ("column_type", "expected"), -# [ -# (Anything(is_nullable=False), False), -# (Anything(is_nullable=True), False), -# (Boolean(is_nullable=False), False), -# (Boolean(is_nullable=True), False), -# (RealNumber(is_nullable=False), True), -# (RealNumber(is_nullable=True), True), -# (Integer(is_nullable=False), True), -# (Integer(is_nullable=True), True), -# (String(is_nullable=False), False), -# (String(is_nullable=True), False), -# ], -# ids=repr, -# ) -# def test_should_return_whether_the_column_type_is_numeric(self, column_type: ColumnType, expected: bool) -> None: -# assert column_type.is_numeric() == expected diff --git a/tests/safeds/data/tabular/typing/test_schema.py b/tests/safeds/data/tabular/typing/test_schema.py deleted file mode 100644 index 8596f0234..000000000 --- a/tests/safeds/data/tabular/typing/test_schema.py +++ /dev/null @@ -1,517 +0,0 @@ -# TODO -# from __future__ import annotations -# -# import sys -# from typing import TYPE_CHECKING -# -# import pandas as pd -# import pytest -# from safeds.data.tabular.typing import Schema -# from safeds.exceptions import ColumnNotFoundError -# -# if TYPE_CHECKING: -# from collections.abc import Iterable -# from typing import Any -# -# -# class TestFromPandasDataFrame: -# @pytest.mark.parametrize( -# ("columns", "expected"), -# [ -# ( -# pd.DataFrame({"A": [True, False, True]}), -# Schema({"A": Boolean()}), -# ), -# ( -# pd.DataFrame({"A": [1, 2, 3]}), -# Schema({"A": Integer()}), -# ), -# ( -# pd.DataFrame({"A": [1.0, 2.0, 3.0]}), -# Schema({"A": Integer()}), -# ), -# ( -# pd.DataFrame({"A": ["a", "b", "c"]}), -# Schema({"A": String()}), -# ), -# ( -# pd.DataFrame({"A": [1, 2.0, "a", True]}), -# Schema({"A": Anything()}), -# ), -# ( -# pd.DataFrame({"A": [1.0, 2.5, 3.0]}), -# Schema({"A": RealNumber()}), -# ), -# ( -# pd.DataFrame({"A": [1, 2, 3], "B": ["a", "b", "c"]}), -# Schema({"A": Integer(), "B": String()}), -# ), -# ( -# pd.DataFrame({"A": [True, False, None]}), -# Schema({"A": Boolean(is_nullable=True)}), -# ), -# ( -# pd.DataFrame({"A": [1, None, 3]}), -# Schema({"A": Integer(is_nullable=True)}), -# ), -# ( -# pd.DataFrame({"A": [1.0, None, 3.0]}), -# Schema({"A": Integer(is_nullable=True)}), -# ), -# ( -# pd.DataFrame({"A": [1.5, None, 3.0]}), -# Schema({"A": RealNumber(is_nullable=True)}), -# ), -# ( -# pd.DataFrame({"A": ["a", None, "c"]}), -# Schema({"A": String(is_nullable=True)}), -# ), -# ( -# pd.DataFrame({"A": [1, 2.0, None, True]}), -# Schema({"A": Anything(is_nullable=True)}), -# ), -# ], -# ids=[ -# "boolean", -# "integer", -# "real number .0", -# "string", -# "mixed", -# "real number", -# "multiple columns", -# "boolean?", -# "integer?", -# "real number? .0", -# "real number?", -# "string?", -# "Anything?", -# ], -# ) -# def test_should_create_schema_from_pandas_dataframe(self, columns: Iterable, expected: Schema) -> None: -# assert Schema._from_pandas_dataframe(columns) == expected -# -# -# class TestRepr: -# @pytest.mark.parametrize( -# ("schema", "expected"), -# [ -# (Schema({}), "Schema({})"), -# (Schema({"A": Integer()}), "Schema({'A': Integer})"), -# (Schema({"A": Integer(), "B": String()}), "Schema({\n 'A': Integer,\n 'B': String\n})"), -# ], -# ids=[ -# "empty", -# "single column", -# "multiple columns", -# ], -# ) -# def test_should_create_a_string_representation(self, schema: Schema, expected: str) -> None: -# assert repr(schema) == expected -# -# -# class TestStr: -# @pytest.mark.parametrize( -# ("schema", "expected"), -# [ -# (Schema({}), "{}"), -# (Schema({"A": Integer()}), "{'A': Integer}"), -# (Schema({"A": Integer(), "B": String()}), "{\n 'A': Integer,\n 'B': String\n}"), -# ], -# ids=[ -# "empty", -# "single column", -# "multiple columns", -# ], -# ) -# def test_should_create_a_string_representation(self, schema: Schema, expected: str) -> None: -# assert str(schema) == expected -# -# -# class TestEq: -# @pytest.mark.parametrize( -# ("schema1", "schema2", "expected"), -# [ -# (Schema({}), Schema({}), True), -# (Schema({"col1": Integer()}), Schema({"col1": Integer()}), True), -# (Schema({"col1": Integer()}), Schema({"col1": String()}), False), -# (Schema({"col1": Integer()}), Schema({"col2": Integer()}), False), -# ( -# Schema({"col1": Integer(), "col2": String()}), -# Schema({"col2": String(), "col1": Integer()}), -# True, -# ), -# ], -# ids=[ -# "empty", -# "same name and type", -# "same name but different type", -# "different name but same type", -# "flipped columns", -# ], -# ) -# def test_should_return_whether_two_schema_are_equal(self, schema1: Schema, schema2: Schema, expected: bool) -> None: -# assert (schema1.__eq__(schema2)) == expected -# -# @pytest.mark.parametrize( -# ("schema", "other"), -# [ -# (Schema({"col1": Integer()}), None), -# (Schema({"col1": Integer()}), {"col1": Integer()}), -# ], -# ) -# def test_should_return_not_implemented_if_other_is_not_schema(self, schema: Schema, other: Any) -> None: -# assert (schema.__eq__(other)) is NotImplemented -# -# -# class TestHash: -# @pytest.mark.parametrize( -# ("schema1", "schema2"), -# [ -# (Schema({}), Schema({})), -# (Schema({"col1": Integer()}), Schema({"col1": Integer()})), -# ], -# ids=[ -# "empty", -# "one column", -# ], -# ) -# def test_should_return_same_hash_for_equal_schemas(self, schema1: Schema, schema2: Schema) -> None: -# assert hash(schema1) == hash(schema2) -# -# @pytest.mark.parametrize( -# ("schema1", "schema2"), -# [ -# (Schema({"col1": Integer()}), Schema({"col1": String()})), -# (Schema({"col1": Integer()}), Schema({"col2": Integer()})), -# ], -# ids=[ -# "same name but different type", -# "different name but same type", -# ], -# ) -# def test_should_return_different_hash_for_unequal_schemas(self, schema1: Schema, schema2: Schema) -> None: -# assert hash(schema1) != hash(schema2) -# -# -# class TestHasColumn: -# @pytest.mark.parametrize( -# ("schema", "column_name", "expected"), -# [ -# (Schema({}), "A", False), -# (Schema({"A": Integer()}), "A", True), -# (Schema({"A": Integer()}), "B", False), -# ], -# ids=[ -# "empty", -# "column exists", -# "column does not exist", -# ], -# ) -# def test_should_return_whether_column_exists(self, schema: Schema, column_name: str, expected: bool) -> None: -# assert schema.has_column(column_name) == expected -# -# -# class TestGetTypeOfColumn: -# @pytest.mark.parametrize( -# ("schema", "column_name", "expected"), -# [ -# (Schema({"A": Integer()}), "A", Integer()), -# (Schema({"A": Integer(), "B": String()}), "B", String()), -# ], -# ids=[ -# "one column", -# "two columns", -# ], -# ) -# def test_should_return_type_of_existing_column( -# self, -# schema: Schema, -# column_name: str, -# expected: ColumnType, -# ) -> None: -# assert schema.get_column_type(column_name) == expected -# -# def test_should_raise_if_column_does_not_exist(self) -> None: -# schema = Schema({"A": Integer()}) -# with pytest.raises(ColumnNotFoundError): -# schema.get_column_type("B") -# -# -# class TestGetColumnNames: -# @pytest.mark.parametrize( -# ("schema", "expected"), -# [ -# (Schema({}), []), -# (Schema({"A": Integer()}), ["A"]), -# (Schema({"A": Integer(), "B": RealNumber()}), ["A", "B"]), -# ], -# ids=[ -# "empty", -# "single column", -# "multiple columns", -# ], -# ) -# def test_should_return_column_names(self, schema: Schema, expected: list[str]) -> None: -# assert schema.column_names == expected -# -# -# class TestToDict: -# @pytest.mark.parametrize( -# ("schema", "expected"), -# [ -# (Schema({}), {}), -# (Schema({"A": Integer()}), {"A": Integer()}), -# (Schema({"A": Integer(), "B": String()}), {"A": Integer(), "B": String()}), -# ], -# ids=[ -# "empty", -# "single column", -# "multiple columns", -# ], -# ) -# def test_should_return_dict_for_schema(self, schema: Schema, expected: str) -> None: -# assert schema.to_dict() == expected -# -# -# class TestMergeMultipleSchemas: -# @pytest.mark.parametrize( -# ("schemas", "error_msg_regex"), -# [([Schema({"Column1": Anything()}), Schema({"Column2": Anything()})], r"Could not find column\(s\) 'Column2'")], -# ids=["different_column_names"], -# ) -# def test_should_raise_if_column_names_are_different(self, schemas: list[Schema], error_msg_regex: str) -> None: -# with pytest.raises(ColumnNotFoundError, match=error_msg_regex): -# Schema._merge_multiple_schemas(schemas) -# -# @pytest.mark.parametrize( -# ("schemas", "expected"), -# [ -# ([Schema({"Column1": Integer()}), Schema({"Column1": Integer()})], Schema({"Column1": Integer()})), -# ([Schema({"Column1": RealNumber()}), Schema({"Column1": RealNumber()})], Schema({"Column1": RealNumber()})), -# ([Schema({"Column1": Boolean()}), Schema({"Column1": Boolean()})], Schema({"Column1": Boolean()})), -# ([Schema({"Column1": String()}), Schema({"Column1": String()})], Schema({"Column1": String()})), -# ([Schema({"Column1": Anything()}), Schema({"Column1": Anything()})], Schema({"Column1": Anything()})), -# ([Schema({"Column1": Integer()}), Schema({"Column1": RealNumber()})], Schema({"Column1": RealNumber()})), -# ([Schema({"Column1": Integer()}), Schema({"Column1": Boolean()})], Schema({"Column1": Anything()})), -# ([Schema({"Column1": Integer()}), Schema({"Column1": String()})], Schema({"Column1": Anything()})), -# ([Schema({"Column1": Integer()}), Schema({"Column1": Anything()})], Schema({"Column1": Anything()})), -# ([Schema({"Column1": RealNumber()}), Schema({"Column1": Boolean()})], Schema({"Column1": Anything()})), -# ([Schema({"Column1": RealNumber()}), Schema({"Column1": String()})], Schema({"Column1": Anything()})), -# ([Schema({"Column1": RealNumber()}), Schema({"Column1": Anything()})], Schema({"Column1": Anything()})), -# ([Schema({"Column1": Boolean()}), Schema({"Column1": String()})], Schema({"Column1": Anything()})), -# ([Schema({"Column1": Boolean()}), Schema({"Column1": Anything()})], Schema({"Column1": Anything()})), -# ([Schema({"Column1": String()}), Schema({"Column1": Anything()})], Schema({"Column1": Anything()})), -# ( -# [Schema({"Column1": Integer(is_nullable=True)}), Schema({"Column1": Integer()})], -# Schema({"Column1": Integer(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": RealNumber(is_nullable=True)}), Schema({"Column1": RealNumber()})], -# Schema({"Column1": RealNumber(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Boolean(is_nullable=True)}), Schema({"Column1": Boolean()})], -# Schema({"Column1": Boolean(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": String(is_nullable=True)}), Schema({"Column1": String()})], -# Schema({"Column1": String(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Anything(is_nullable=True)}), Schema({"Column1": Anything()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Integer(is_nullable=True)}), Schema({"Column1": RealNumber()})], -# Schema({"Column1": RealNumber(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Integer(is_nullable=True)}), Schema({"Column1": Boolean()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Integer(is_nullable=True)}), Schema({"Column1": String()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Integer(is_nullable=True)}), Schema({"Column1": Anything()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": RealNumber(is_nullable=True)}), Schema({"Column1": Boolean()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": RealNumber(is_nullable=True)}), Schema({"Column1": String()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": RealNumber(is_nullable=True)}), Schema({"Column1": Anything()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Boolean(is_nullable=True)}), Schema({"Column1": String()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Boolean(is_nullable=True)}), Schema({"Column1": Anything()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": String(is_nullable=True)}), Schema({"Column1": Anything()})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Integer()}), Schema({"Column1": Integer(is_nullable=True)})], -# Schema({"Column1": Integer(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": RealNumber()}), Schema({"Column1": RealNumber(is_nullable=True)})], -# Schema({"Column1": RealNumber(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Boolean()}), Schema({"Column1": Boolean(is_nullable=True)})], -# Schema({"Column1": Boolean(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": String()}), Schema({"Column1": String(is_nullable=True)})], -# Schema({"Column1": String(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Anything()}), Schema({"Column1": Anything(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Integer()}), Schema({"Column1": RealNumber(is_nullable=True)})], -# Schema({"Column1": RealNumber(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Integer()}), Schema({"Column1": Boolean(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Integer()}), Schema({"Column1": String(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Integer()}), Schema({"Column1": Anything(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": RealNumber()}), Schema({"Column1": Boolean(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": RealNumber()}), Schema({"Column1": String(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": RealNumber()}), Schema({"Column1": Anything(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Boolean()}), Schema({"Column1": String(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": Boolean()}), Schema({"Column1": Anything(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ( -# [Schema({"Column1": String()}), Schema({"Column1": Anything(is_nullable=True)})], -# Schema({"Column1": Anything(is_nullable=True)}), -# ), -# ], -# ids=[ -# "Integer Integer", -# "RealNumber RealNumber", -# "Boolean Boolean", -# "String String", -# "Anything Anything", -# "Integer RealNumber", -# "Integer Boolean", -# "Integer String", -# "Integer Anything", -# "RealNumber Boolean", -# "RealNumber String", -# "RealNumber Anything", -# "Boolean String", -# "Boolean Anything", -# "String Anything", -# "Integer(null) Integer", -# "RealNumber(null) RealNumber", -# "Boolean(null) Boolean", -# "String(null) String", -# "Anything(null) Anything", -# "Integer(null) RealNumber", -# "Integer(null) Boolean", -# "Integer(null) String", -# "Integer(null) Anything", -# "RealNumber(null) Boolean", -# "RealNumber(null) String", -# "RealNumber(null) Anything", -# "Boolean(null) String", -# "Boolean(null) Anything", -# "String(null) Anything", -# "Integer Integer(null)", -# "RealNumber RealNumber(null)", -# "Boolean Boolean(null)", -# "String String(null)", -# "Anything Anything(null)", -# "Integer RealNumber(null)", -# "Integer Boolean(null)", -# "Integer String(null)", -# "Integer Anything(null)", -# "RealNumber Boolean(null)", -# "RealNumber String(null)", -# "RealNumber Anything(null)", -# "Boolean String(null)", -# "Boolean Anything(null)", -# "String Anything(null)", -# ], -# ) -# def test_should_return_merged_schema(self, schemas: list[Schema], expected: Schema) -> None: -# assert Schema._merge_multiple_schemas(schemas) == expected -# schemas.reverse() -# assert ( -# Schema._merge_multiple_schemas(schemas) == expected -# ) # test the reversed list because the first parameter is handled differently -# -# -# class TestReprMarkdown: -# @pytest.mark.parametrize( -# ("schema", "expected"), -# [ -# (Schema({}), "Empty Schema"), -# (Schema({"A": Integer()}), "| Column Name | Column Type |\n| --- | --- |\n| A | Integer |"), -# ( -# Schema({"A": Integer(), "B": String()}), -# "| Column Name | Column Type |\n| --- | --- |\n| A | Integer |\n| B | String |", -# ), -# ], -# ids=[ -# "empty", -# "single column", -# "multiple columns", -# ], -# ) -# def test_should_create_a_string_representation(self, schema: Schema, expected: str) -> None: -# assert schema._repr_markdown_() == expected -# -# -# class TestSizeof: -# @pytest.mark.parametrize( -# "schema", -# [ -# Schema({}), -# Schema({"A": Integer()}), -# Schema({"A": Integer(), "B": String()}), -# ], -# ids=[ -# "empty", -# "single column", -# "multiple columns", -# ], -# ) -# def test_should_size_be_greater_than_normal_object(self, schema: Schema) -> None: -# assert sys.getsizeof(schema) > sys.getsizeof(object()) diff --git a/tests/safeds/exceptions/test_index_out_of_bounds_error.py b/tests/safeds/exceptions/test_index_out_of_bounds_error.py index f6cf66c2a..b077db279 100644 --- a/tests/safeds/exceptions/test_index_out_of_bounds_error.py +++ b/tests/safeds/exceptions/test_index_out_of_bounds_error.py @@ -1,4 +1,5 @@ import pytest + from safeds.exceptions import IndexOutOfBoundsError diff --git a/tests/safeds/exceptions/test_out_of_bounds_error.py b/tests/safeds/exceptions/test_out_of_bounds_error.py deleted file mode 100644 index 91a1b8a38..000000000 --- a/tests/safeds/exceptions/test_out_of_bounds_error.py +++ /dev/null @@ -1,154 +0,0 @@ -# TODO: test validation function _check_bounds -# import re -# -# import pytest -# from numpy import isinf, isnan -# from safeds.exceptions import _Bound, _ClosedBound, _OpenBound, OutOfBoundsError -# -# -# @pytest.mark.parametrize( -# "actual", -# [0, 1, -1, 2, -2], -# ids=["0", "1", "-1", "2", "-2"], -# ) -# @pytest.mark.parametrize("variable_name", ["test_variable"], ids=["test_variable"]) -# @pytest.mark.parametrize( -# ("lower_bound", "match_lower"), -# [ -# (_ClosedBound(-1), "[-1"), -# (_OpenBound(-1), "(-1"), -# (None, "(-\u221e"), -# (_OpenBound(float("-inf")), "(-\u221e"), -# (_OpenBound(float("inf")), "(\u221e"), -# ], -# ids=["lb_closed_-1", "lb_open_-1", "lb_none", "lb_neg_inf", "lb_inf"], -# ) -# @pytest.mark.parametrize( -# ("upper_bound", "match_upper"), -# [ -# (_ClosedBound(1), "1]"), -# (_OpenBound(1), "1)"), -# (None, "\u221e)"), -# (_OpenBound(float("-inf")), "-\u221e)"), -# (_OpenBound(float("inf")), "\u221e)"), -# ], -# ids=["ub_closed_-1", "ub_open_-1", "ub_none", "ub_neg_inf", "ub_inf"], -# ) -# def test_should_raise_out_of_bounds_error( -# actual: float, -# variable_name: str | None, -# lower_bound: _Bound | None, -# upper_bound: _Bound | None, -# match_lower: str, -# match_upper: str, -# ) -> None: -# # Check (-inf, inf) interval: -# if lower_bound is None and upper_bound is None: -# with pytest.raises( -# ValueError, -# match=r"Illegal interval: Attempting to raise OutOfBoundsError, but no bounds given\.", -# ): -# raise OutOfBoundsError(actual, lower_bound=lower_bound, upper_bound=upper_bound) -# return -# # Check if infinity was passed instead of None: -# if (lower_bound is not None and isinf(lower_bound.value)) or (upper_bound is not None and isinf(upper_bound.value)): -# with pytest.raises( -# ValueError, -# match="Illegal interval: Lower and upper bounds must be real numbers, or None if unbounded.", -# ): -# raise OutOfBoundsError(actual, lower_bound=lower_bound, upper_bound=upper_bound) -# return -# # All tests: Check interval where lower > upper: -# if lower_bound is not None and upper_bound is not None: -# with pytest.raises( -# ValueError, -# match=r"Illegal interval: Attempting to raise OutOfBoundsError, but given upper bound .+ is actually less " -# r"than given lower bound .+\.", -# ): -# raise OutOfBoundsError(actual, lower_bound=upper_bound, upper_bound=lower_bound) -# # Check case where actual value lies inside the interval: -# if (lower_bound is None or lower_bound._check_lower_bound(actual)) and ( -# upper_bound is None or upper_bound._check_upper_bound(actual) -# ): -# with pytest.raises( -# ValueError, -# match=rf"Illegal interval: Attempting to raise OutOfBoundsError, but value {actual} is not actually outside" -# rf" given interval [\[(].+,.+[\])]\.", -# ): -# raise OutOfBoundsError(actual, lower_bound=lower_bound, upper_bound=upper_bound) -# return -# # Check that error is raised correctly: -# with pytest.raises( -# OutOfBoundsError, -# match=rf"{actual} is not inside {re.escape(match_lower)}, {re.escape(match_upper)}.", -# ): -# raise OutOfBoundsError(actual, lower_bound=lower_bound, upper_bound=upper_bound) -# with pytest.raises( -# OutOfBoundsError, -# match=rf"{variable_name} \(={actual}\) is not inside {re.escape(match_lower)}, {re.escape(match_upper)}.", -# ): -# raise OutOfBoundsError(actual, name=variable_name, lower_bound=lower_bound, upper_bound=upper_bound) -# -# -# @pytest.mark.parametrize( -# ("value", "expected_value", "bound", "lower_bound"), -# [ -# (2, True, _ClosedBound(2), False), -# (2, True, _ClosedBound(2), True), -# (2, True, _ClosedBound(3), False), -# (2, True, _ClosedBound(1), True), -# (2, False, _OpenBound(2), False), -# (2, False, _OpenBound(2), True), -# (2, False, _OpenBound(1), False), -# (2, False, _OpenBound(3), True), -# (2, False, _OpenBound(float("inf")), True), -# (2, True, _OpenBound(float("inf")), False), -# (2, True, _OpenBound(float("-inf")), True), -# (2, False, _OpenBound(float("-inf")), False), -# ], -# ids=[ -# "ex_false-close_2-upper", -# "ex_false-close_2-lower", -# "ex_true-close_3-upper", -# "ex_true-close_1-lower", -# "ex_true-open_2-upper", -# "ex_true-open_2-lower", -# "ex_false-open_1-upper", -# "ex_false-open_3-lower", -# "ex_false-inf-lower", -# "ex_true-inf-upper", -# "ex_true--inf-lower", -# "ex_false--inf-upper", -# ], -# ) -# def test_should_return_true_if_value_in_bounds( -# value: float, -# expected_value: bool, -# bound: _Bound, -# lower_bound: bool, -# ) -> None: -# if lower_bound: -# assert expected_value == bound._check_lower_bound(value) -# else: -# assert expected_value == bound._check_upper_bound(value) -# -# -# @pytest.mark.parametrize("value", [float("nan"), float("-inf"), float("inf")], ids=["nan", "neg_inf", "inf"]) -# def test_should_raise_value_error(value: float) -> None: -# if isnan(value): -# with pytest.raises(ValueError, match="Bound must be a real number, not nan."): -# _ClosedBound(value) -# with pytest.raises(ValueError, match="Bound must be a real number, not nan."): -# _OpenBound(value) -# else: -# with pytest.raises(ValueError, match=r"ClosedBound must be a real number, not \+\/\-inf\."): -# _ClosedBound(value) -# -# -# @pytest.mark.parametrize("actual", [float("nan"), float("-inf"), float("inf")], ids=["nan", "neg_inf", "inf"]) -# def test_should_raise_value_error_because_invalid_actual(actual: float) -> None: -# with pytest.raises( -# ValueError, -# match="Attempting to raise OutOfBoundsError with actual value not being a real number.", -# ): -# raise OutOfBoundsError(actual, lower_bound=_ClosedBound(-1), upper_bound=_ClosedBound(1)) diff --git a/tests/safeds/exceptions/test_unknown_column_name_error.py b/tests/safeds/exceptions/test_unknown_column_name_error.py deleted file mode 100644 index 1e423a088..000000000 --- a/tests/safeds/exceptions/test_unknown_column_name_error.py +++ /dev/null @@ -1,43 +0,0 @@ -# TODO: move to validation tests -# import pytest -# from safeds.exceptions import ColumnNotFoundError -# -# -# @pytest.mark.parametrize( -# ("column_names", "similar_columns", "expected_error_message"), -# [ -# (["column1"], [], r"Could not find column\(s\) 'column1'\."), -# (["column1", "column2"], [], r"Could not find column\(s\) 'column1, column2'\."), -# (["column1"], ["column_a"], r"Could not find column\(s\) 'column1'\.\nDid you mean '\['column_a'\]'\?"), -# ( -# ["column1", "column2"], -# ["column_a"], -# r"Could not find column\(s\) 'column1, column2'\.\nDid you mean '\['column_a'\]'\?", -# ), -# ( -# ["column1"], -# ["column_a", "column_b"], -# r"Could not find column\(s\) 'column1'\.\nDid you mean '\['column_a', 'column_b'\]'\?", -# ), -# ( -# ["column1", "column2"], -# ["column_a", "column_b"], -# r"Could not find column\(s\) 'column1, column2'\.\nDid you mean '\['column_a', 'column_b'\]'\?", -# ), -# ], -# ids=[ -# "one_unknown_no_suggestions", -# "two_unknown_no_suggestions", -# "one_unknown_one_suggestion", -# "two_unknown_one_suggestion", -# "one_unknown_two_suggestions", -# "two_unknown_two_suggestions", -# ], -# ) -# def test_empty_similar_columns( -# column_names: list[str], -# similar_columns: list[str], -# expected_error_message: str, -# ) -> None: -# with pytest.raises(ColumnNotFoundError, match=expected_error_message): -# raise ColumnNotFoundError(column_names, similar_columns) diff --git a/tests/safeds/ml/classical/classification/test_ada_boost.py b/tests/safeds/ml/classical/classification/test_ada_boost.py index 28e44851b..01a5f99b1 100644 --- a/tests/safeds/ml/classical/classification/test_ada_boost.py +++ b/tests/safeds/ml/classical/classification/test_ada_boost.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -6,10 +7,10 @@ from safeds.ml.hyperparameters import Choice -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestLearner: diff --git a/tests/safeds/ml/classical/classification/test_baseline_classifier.py b/tests/safeds/ml/classical/classification/test_baseline_classifier.py index 8f507c41a..441cf9020 100644 --- a/tests/safeds/ml/classical/classification/test_baseline_classifier.py +++ b/tests/safeds/ml/classical/classification/test_baseline_classifier.py @@ -1,10 +1,11 @@ import pytest + from safeds.data.tabular.containers import Table from safeds.exceptions import ( ColumnTypeError, DatasetMissesDataError, FeatureDataMismatchError, - ModelNotFittedError, + NotFittedError, TargetDataMismatchError, ) from safeds.ml.classical.classification import BaselineClassifier @@ -54,7 +55,7 @@ def test_should_raise_if_is_fitted_is_set_correctly(self) -> None: def test_should_raise_if_model_not_fitted(self) -> None: model = BaselineClassifier() predict_data = Table({"feat": [0, 1], "target": [0, 1]}).to_tabular_dataset("target") - with pytest.raises(ModelNotFittedError): + with pytest.raises(NotFittedError): model.predict(predict_data) def test_should_raise_if_predict_data_has_differing_features(self) -> None: diff --git a/tests/safeds/ml/classical/classification/test_classifier.py b/tests/safeds/ml/classical/classification/test_classifier.py index a5134f118..e2ab372d2 100644 --- a/tests/safeds/ml/classical/classification/test_classifier.py +++ b/tests/safeds/ml/classical/classification/test_classifier.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Self import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import ( @@ -13,8 +14,8 @@ FittingWithoutChoiceError, LearningError, MissingValuesColumnError, - ModelNotFittedError, NonNumericColumnError, + NotFittedError, PlainTableError, ) from safeds.ml.classical.classification import ( @@ -84,11 +85,11 @@ def classifiers_with_choices() -> list[Classifier]: max_depth=Choice(1, 2), min_sample_count_in_leaves=Choice(1, 2), ), - SupportVectorClassifier(kernel=Choice(None, SupportVectorClassifier.Kernel.linear()), c=Choice(0.5, 1.0)), + SupportVectorClassifier(kernel=Choice(SupportVectorClassifier.Kernel.linear()), c=Choice(0.5, 1.0)), ] -@pytest.fixture() +@pytest.fixture def valid_data() -> TabularDataset: return Table( { @@ -97,12 +98,11 @@ def valid_data() -> TabularDataset: "feat2": [3, 6, 9, 12], "target": [0, 1, 0, 1], }, - ).to_tabular_dataset(target_name="target", extra_names=["id"]) + ).to_tabular_dataset("target", extra_names=["id"]) @pytest.mark.parametrize("classifier_with_choice", classifiers_with_choices(), ids=lambda x: x.__class__.__name__) class TestChoiceClassifiers: - def test_should_raise_if_model_is_fitted_with_choice( self, classifier_with_choice: Classifier, @@ -131,7 +131,6 @@ def test_workflow_with_choice_parameter( class TestFitByExhaustiveSearch: - @pytest.mark.parametrize("classifier", classifiers(), ids=lambda x: x.__class__.__name__) def test_should_raise_if_model_is_fitted_by_exhaustive_search_without_choice( self, @@ -211,7 +210,7 @@ def test_should_not_change_input_table(self, classifier: Classifier, request: Fi "feat2": [3, 6], "target": [0, 1], }, - ).to_tabular_dataset(target_name="target", extra_names=["id"]), + ).to_tabular_dataset("target", extra_names=["id"]), NonNumericColumnError, ( r"Tried to do a numerical operation on one or multiple non-numerical columns: \n\{'feat1'\}\nYou" @@ -228,7 +227,7 @@ def test_should_not_change_input_table(self, classifier: Classifier, request: Fi "feat2": [3, 6], "target": [0, 1], }, - ).to_tabular_dataset(target_name="target", extra_names=["id"]), + ).to_tabular_dataset("target", extra_names=["id"]), MissingValuesColumnError, ( r"Tried to do an operation on one or multiple columns containing missing values: \n\{'feat1'\}\nYou" @@ -245,7 +244,7 @@ def test_should_not_change_input_table(self, classifier: Classifier, request: Fi "feat2": [], "target": [], }, - ).to_tabular_dataset(target_name="target", extra_names=["id"]), + ).to_tabular_dataset("target", extra_names=["id"]), DatasetMissesDataError, r"Dataset contains no rows", ), @@ -305,7 +304,7 @@ def test_should_not_change_input_table(self, classifier: Classifier, request: Fi assert valid_data == valid_data_copy def test_should_raise_if_not_fitted(self, classifier: Classifier, valid_data: TabularDataset) -> None: - with pytest.raises(ModelNotFittedError): + with pytest.raises(NotFittedError): classifier.predict(valid_data.features) def test_should_raise_if_dataset_misses_features(self, classifier: Classifier, valid_data: TabularDataset) -> None: @@ -458,7 +457,7 @@ def predict(self, dataset: Table | TabularDataset) -> TabularDataset: feature = predicted.rename("feature") dataset = Table.from_columns([feature, predicted]) - return dataset.to_tabular_dataset(target_name="predicted") + return dataset.to_tabular_dataset("predicted") @property def is_fitted(self) -> bool: @@ -491,7 +490,7 @@ def test_valid_data(self, predicted: list[float], expected: list[float], result: "expected": expected, }, ).to_tabular_dataset( - target_name="expected", + "expected", ) assert DummyClassifier().summarize_metrics(table, 1) == result @@ -504,7 +503,7 @@ def test_with_same_type(self) -> None: "predicted": [1, 2, 3, 4], "expected": [1, 2, 3, 3], }, - ).to_tabular_dataset(target_name="expected") + ).to_tabular_dataset("expected") assert DummyClassifier().accuracy(table) == 0.75 @@ -514,7 +513,7 @@ def test_with_different_types(self) -> None: "predicted": ["1", "2", "3", "4"], "expected": [1, 2, 3, 3], }, - ).to_tabular_dataset(target_name="expected") + ).to_tabular_dataset("expected") assert DummyClassifier().accuracy(table) == 0.0 @@ -557,7 +556,7 @@ def test_should_compute_precision(self, predicted: list, expected: list, result: "predicted": predicted, "expected": expected, }, - ).to_tabular_dataset(target_name="expected") + ).to_tabular_dataset("expected") assert DummyClassifier().precision(table, 1) == result @@ -600,7 +599,7 @@ def test_should_compute_recall(self, predicted: list, expected: list, result: fl "predicted": predicted, "expected": expected, }, - ).to_tabular_dataset(target_name="expected") + ).to_tabular_dataset("expected") assert DummyClassifier().recall(table, 1) == result @@ -659,6 +658,6 @@ def test_should_compute_f1_score(self, predicted: list, expected: list, result: "predicted": predicted, "expected": expected, }, - ).to_tabular_dataset(target_name="expected") + ).to_tabular_dataset("expected") assert DummyClassifier().f1_score(table, 1) == result diff --git a/tests/safeds/ml/classical/classification/test_decision_tree.py b/tests/safeds/ml/classical/classification/test_decision_tree.py index 9c05681dc..5654ffc59 100644 --- a/tests/safeds/ml/classical/classification/test_decision_tree.py +++ b/tests/safeds/ml/classical/classification/test_decision_tree.py @@ -1,18 +1,18 @@ import pytest +from syrupy import SnapshotAssertion + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table -from safeds.exceptions import ModelNotFittedError, OutOfBoundsError +from safeds.exceptions import NotFittedError, OutOfBoundsError from safeds.ml.classical.classification import DecisionTreeClassifier from safeds.ml.hyperparameters import Choice -from syrupy import SnapshotAssertion - from tests.helpers import os_mac, skip_if_os -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestMaxDepth: @@ -54,7 +54,7 @@ def test_should_raise_if_less_than_or_equal_to_0(self, min_sample_count_in_leave class TestPlot: def test_should_raise_if_model_is_not_fitted(self) -> None: model = DecisionTreeClassifier() - with pytest.raises(ModelNotFittedError): + with pytest.raises(NotFittedError): model.plot() def test_should_check_that_plot_image_is_same_as_snapshot( diff --git a/tests/safeds/ml/classical/classification/test_gradient_boosting.py b/tests/safeds/ml/classical/classification/test_gradient_boosting.py index 14aae9aa2..c71f69504 100644 --- a/tests/safeds/ml/classical/classification/test_gradient_boosting.py +++ b/tests/safeds/ml/classical/classification/test_gradient_boosting.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -6,10 +7,10 @@ from safeds.ml.hyperparameters import Choice -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestTreeCount: diff --git a/tests/safeds/ml/classical/classification/test_k_nearest_neighbors.py b/tests/safeds/ml/classical/classification/test_k_nearest_neighbors.py index 66437d782..122e7adce 100644 --- a/tests/safeds/ml/classical/classification/test_k_nearest_neighbors.py +++ b/tests/safeds/ml/classical/classification/test_k_nearest_neighbors.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -6,10 +7,10 @@ from safeds.ml.hyperparameters import Choice -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestNeighborCount: diff --git a/tests/safeds/ml/classical/classification/test_logistic_classifier.py b/tests/safeds/ml/classical/classification/test_logistic_classifier.py index 92bd4b8da..969133213 100644 --- a/tests/safeds/ml/classical/classification/test_logistic_classifier.py +++ b/tests/safeds/ml/classical/classification/test_logistic_classifier.py @@ -1,14 +1,15 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.classification import LogisticClassifier -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestC: diff --git a/tests/safeds/ml/classical/classification/test_random_forest.py b/tests/safeds/ml/classical/classification/test_random_forest.py index 8f9efabad..bf50d534d 100644 --- a/tests/safeds/ml/classical/classification/test_random_forest.py +++ b/tests/safeds/ml/classical/classification/test_random_forest.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -6,10 +7,10 @@ from safeds.ml.hyperparameters import Choice -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestTreeCount: diff --git a/tests/safeds/ml/classical/classification/test_support_vector_machine.py b/tests/safeds/ml/classical/classification/test_support_vector_machine.py index 6fae902ed..cede56a89 100644 --- a/tests/safeds/ml/classical/classification/test_support_vector_machine.py +++ b/tests/safeds/ml/classical/classification/test_support_vector_machine.py @@ -1,6 +1,7 @@ import sys import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -29,10 +30,10 @@ def kernels() -> list[SupportVectorClassifier.Kernel]: ] -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestC: diff --git a/tests/safeds/ml/classical/regression/test_ada_boost.py b/tests/safeds/ml/classical/regression/test_ada_boost.py index 58eebdd97..f76bd3996 100644 --- a/tests/safeds/ml/classical/regression/test_ada_boost.py +++ b/tests/safeds/ml/classical/regression/test_ada_boost.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -6,10 +7,10 @@ from safeds.ml.hyperparameters import Choice -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestLearner: diff --git a/tests/safeds/ml/classical/regression/test_arima_model.py b/tests/safeds/ml/classical/regression/test_arima_model.py index 06a114bc7..7f87db3a5 100644 --- a/tests/safeds/ml/classical/regression/test_arima_model.py +++ b/tests/safeds/ml/classical/regression/test_arima_model.py @@ -1,16 +1,16 @@ from typing import Any import pytest + from safeds.data.labeled.containers import TimeSeriesDataset from safeds.data.tabular.containers import Table from safeds.exceptions import ( DatasetMissesDataError, MissingValuesColumnError, - ModelNotFittedError, NonNumericColumnError, + NotFittedError, ) from safeds.ml.classical.regression import AdaBoostRegressor, ArimaModelRegressor - from tests.helpers import resolve_resource_path @@ -22,8 +22,22 @@ def test_arima_model() -> None: ) train_ts, test_ts = time_series.split_rows(0.8) model = ArimaModelRegressor() - trained_model = model.fit(train_ts.to_time_series_dataset("value", window_size=1)) - trained_model.predict(test_ts.to_time_series_dataset("value", window_size=1)) + # trained_model = model.fit(train_ts.to_time_series_dataset("value", window_size=1)) + # trained_model.predict(test_ts.to_time_series_dataset("value", window_size=1)) + trained_model = model.fit( + TimeSeriesDataset( + train_ts, + "value", + window_size=1, + ), + ) + trained_model.predict( + TimeSeriesDataset( + test_ts, + "value", + window_size=1, + ), + ) # suggest it ran through assert True @@ -31,7 +45,7 @@ def test_arima_model() -> None: def create_test_data() -> TimeSeriesDataset: return TimeSeriesDataset( {"time": [1, 2, 3, 4, 5, 6, 7, 8, 9], "value": [1, 2, 3, 4, 5, 6, 7, 8, 9]}, - target_name="value", + "value", window_size=1, ) @@ -43,7 +57,7 @@ def create_test_data_with_feature() -> TimeSeriesDataset: "value": [1, 2, 3, 4, 5, 6, 7, 8, 9], "feature": [1, 2, 3, 4, 5, 6, 7, 8, 9], }, - target_name="value", + "value", window_size=1, ) @@ -82,38 +96,74 @@ def test_should_succeed_on_valid_data_plot() -> None: ("invalid_data", "expected_error", "expected_error_msg"), [ ( - Table( - { - "id": [1, 4], - "feat1": [1, 5], - "feat2": [3, 6], - "target": ["0", 1], - }, - ).to_time_series_dataset(target_name="target", window_size=1), + # Table( + # { + # "id": [1, 4], + # "feat1": [1, 5], + # "feat2": [3, 6], + # "target": ["0", 1], + # }, + # ).to_time_series_dataset("target", window_size=1), + TimeSeriesDataset( + Table( + { + "id": [1, 4], + "feat1": [1, 5], + "feat2": [3, 6], + "target": ["0", 1], + }, + ), + "target", + window_size=1, + ), NonNumericColumnError, r"Tried to do a numerical operation on one or multiple non-numerical columns: \ntarget", ), ( - Table( - { - "id": [1, 4], - "feat1": [1, 5], - "feat2": [3, 6], - "target": [None, 1], - }, - ).to_time_series_dataset(target_name="target", window_size=1), + # Table( + # { + # "id": [1, 4], + # "feat1": [1, 5], + # "feat2": [3, 6], + # "target": [None, 1], + # }, + # ).to_time_series_dataset("target", window_size=1), + TimeSeriesDataset( + Table( + { + "id": [1, 4], + "feat1": [1, 5], + "feat2": [3, 6], + "target": [None, 1], + }, + ), + "target", + window_size=1, + ), MissingValuesColumnError, r"Tried to do an operation on one or multiple columns containing missing values: \ntarget\nYou can use the Imputer to replace the missing values based on different strategies.\nIf you want toremove the missing values entirely you can use the method `TimeSeries.remove_rows_with_missing_values`.", ), ( - Table( - { - "id": [], - "feat1": [], - "feat2": [], - "target": [], - }, - ).to_time_series_dataset(target_name="target", window_size=1), + # Table( + # { + # "id": [], + # "feat1": [], + # "feat2": [], + # "target": [], + # }, + # ).to_time_series_dataset("target", window_size=1), + TimeSeriesDataset( + Table( + { + "id": [], + "feat1": [], + "feat2": [], + "target": [], + }, + ), + "target", + window_size=1, + ), DatasetMissesDataError, r"Dataset contains no rows", ), @@ -148,7 +198,7 @@ def test_correct_structure_of_time_series() -> None: def test_should_raise_if_not_fitted() -> None: model = ArimaModelRegressor() - with pytest.raises(ModelNotFittedError): + with pytest.raises(NotFittedError): model.predict(create_test_data()) @@ -165,7 +215,7 @@ def test_if_fitted_fitted() -> None: def test_should_raise_if_horizon_too_small_plot() -> None: model = ArimaModelRegressor() - with pytest.raises(ModelNotFittedError): + with pytest.raises(NotFittedError): model.plot_predictions(create_test_data()) diff --git a/tests/safeds/ml/classical/regression/test_baseline_regressor.py b/tests/safeds/ml/classical/regression/test_baseline_regressor.py index 2d8816ef2..a38bb69be 100644 --- a/tests/safeds/ml/classical/regression/test_baseline_regressor.py +++ b/tests/safeds/ml/classical/regression/test_baseline_regressor.py @@ -1,10 +1,11 @@ import pytest + from safeds.data.tabular.containers import Table from safeds.exceptions import ( ColumnTypeError, DatasetMissesDataError, FeatureDataMismatchError, - ModelNotFittedError, + NotFittedError, TargetDataMismatchError, ) from safeds.ml.classical.regression import BaselineRegressor @@ -54,7 +55,7 @@ def test_should_raise_if_is_fitted_is_set_correctly(self) -> None: def test_should_raise_if_model_not_fitted(self) -> None: model = BaselineRegressor() predict_data = Table({"feat": [0, 1], "target": [0, 1]}).to_tabular_dataset("target") - with pytest.raises(ModelNotFittedError): + with pytest.raises(NotFittedError): model.predict(predict_data) def test_should_raise_if_predict_data_has_differing_features(self) -> None: diff --git a/tests/safeds/ml/classical/regression/test_decision_tree.py b/tests/safeds/ml/classical/regression/test_decision_tree.py index 4999a85cb..0c198baee 100644 --- a/tests/safeds/ml/classical/regression/test_decision_tree.py +++ b/tests/safeds/ml/classical/regression/test_decision_tree.py @@ -1,16 +1,17 @@ import pytest +from syrupy import SnapshotAssertion + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table -from safeds.exceptions import ModelNotFittedError, OutOfBoundsError +from safeds.exceptions import NotFittedError, OutOfBoundsError from safeds.ml.classical.regression import DecisionTreeRegressor from safeds.ml.hyperparameters import Choice -from syrupy import SnapshotAssertion -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestMaxDepth: @@ -52,7 +53,7 @@ def test_should_raise_if_less_than_or_equal_to_0(self, min_sample_count_in_leave class TestPlot: def test_should_raise_if_model_is_not_fitted(self) -> None: model = DecisionTreeRegressor() - with pytest.raises(ModelNotFittedError): + with pytest.raises(NotFittedError): model.plot() def test_should_check_that_plot_image_is_same_as_snapshot( diff --git a/tests/safeds/ml/classical/regression/test_gradient_boosting.py b/tests/safeds/ml/classical/regression/test_gradient_boosting.py index 99c37277a..f9b105cfe 100644 --- a/tests/safeds/ml/classical/regression/test_gradient_boosting.py +++ b/tests/safeds/ml/classical/regression/test_gradient_boosting.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -6,10 +7,10 @@ from safeds.ml.hyperparameters import Choice -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestTreeCount: diff --git a/tests/safeds/ml/classical/regression/test_k_nearest_neighbors.py b/tests/safeds/ml/classical/regression/test_k_nearest_neighbors.py index 1e1342d56..fbb9643ab 100644 --- a/tests/safeds/ml/classical/regression/test_k_nearest_neighbors.py +++ b/tests/safeds/ml/classical/regression/test_k_nearest_neighbors.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -6,10 +7,10 @@ from safeds.ml.hyperparameters import Choice -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestNeighborCount: diff --git a/tests/safeds/ml/classical/regression/test_linear_regressor.py b/tests/safeds/ml/classical/regression/test_linear_regressor.py index 0334e58ea..518e24a33 100644 --- a/tests/safeds/ml/classical/regression/test_linear_regressor.py +++ b/tests/safeds/ml/classical/regression/test_linear_regressor.py @@ -1,6 +1,7 @@ import sys import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -28,10 +29,10 @@ def penalties() -> list[LinearRegressor.Penalty]: ] -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestPenalty: diff --git a/tests/safeds/ml/classical/regression/test_random_forest.py b/tests/safeds/ml/classical/regression/test_random_forest.py index 40c84108d..0fcbf1499 100644 --- a/tests/safeds/ml/classical/regression/test_random_forest.py +++ b/tests/safeds/ml/classical/regression/test_random_forest.py @@ -1,4 +1,5 @@ import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -6,10 +7,10 @@ from safeds.ml.hyperparameters import Choice -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestTreeCount: diff --git a/tests/safeds/ml/classical/regression/test_regressor.py b/tests/safeds/ml/classical/regression/test_regressor.py index 340fe8d7c..4a5316927 100644 --- a/tests/safeds/ml/classical/regression/test_regressor.py +++ b/tests/safeds/ml/classical/regression/test_regressor.py @@ -4,17 +4,18 @@ from typing import TYPE_CHECKING, Any, Self import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Column, Table from safeds.exceptions import ( - ColumnLengthMismatchError, DatasetMissesDataError, DatasetMissesFeaturesError, FittingWithChoiceError, FittingWithoutChoiceError, + LengthMismatchError, MissingValuesColumnError, - ModelNotFittedError, NonNumericColumnError, + NotFittedError, PlainTableError, ) from safeds.ml.classical.regression import ( @@ -92,11 +93,11 @@ def regressors_with_choices() -> list[Regressor]: penalty=LinearRegressor.Penalty.elastic_net(alpha=Choice(1.0, 2.0), lasso_ratio=Choice(0.1, 0.9)), ), RandomForestRegressor(tree_count=Choice(1, 2), max_depth=Choice(1, 2), min_sample_count_in_leaves=Choice(1, 2)), - SupportVectorRegressor(kernel=Choice(None, SupportVectorRegressor.Kernel.linear()), c=Choice(0.5, 1.0)), + SupportVectorRegressor(kernel=Choice(SupportVectorRegressor.Kernel.linear()), c=Choice(0.5, 1.0)), ] -@pytest.fixture() +@pytest.fixture def valid_data() -> TabularDataset: return Table( { @@ -105,12 +106,11 @@ def valid_data() -> TabularDataset: "feat2": [3, 6, 9, 12], "target": [0, 1, 0, 1], }, - ).to_tabular_dataset(target_name="target", extra_names=["id"]) + ).to_tabular_dataset("target", extra_names=["id"]) @pytest.mark.parametrize("regressor_with_choice", regressors_with_choices(), ids=lambda x: x.__class__.__name__) class TestChoiceRegressors: - def test_workflow_with_choice_parameter(self, regressor_with_choice: Regressor, valid_data: TabularDataset) -> None: model = regressor_with_choice.fit_by_exhaustive_search(valid_data, RegressorMetric.MEAN_SQUARED_ERROR) assert isinstance(model, type(regressor_with_choice)) @@ -192,7 +192,7 @@ def test_should_not_change_input_table(self, regressor: Regressor, request: Fixt "feat2": [3, 6], "target": [0, 1], }, - ).to_tabular_dataset(target_name="target", extra_names=["id"]), + ).to_tabular_dataset("target", extra_names=["id"]), NonNumericColumnError, r"Tried to do a numerical operation on one or multiple non-numerical columns: \n\{'feat1'\}", ), @@ -204,7 +204,7 @@ def test_should_not_change_input_table(self, regressor: Regressor, request: Fixt "feat2": [3, 6], "target": [0, 1], }, - ).to_tabular_dataset(target_name="target", extra_names=["id"]), + ).to_tabular_dataset("target", extra_names=["id"]), MissingValuesColumnError, r"Tried to do an operation on one or multiple columns containing missing values: \n\{'feat1'\}", ), @@ -216,7 +216,7 @@ def test_should_not_change_input_table(self, regressor: Regressor, request: Fixt "feat2": [], "target": [], }, - ).to_tabular_dataset(target_name="target", extra_names=["id"]), + ).to_tabular_dataset("target", extra_names=["id"]), DatasetMissesDataError, r"Dataset contains no rows", ), @@ -276,7 +276,7 @@ def test_should_not_change_input_table(self, regressor: Regressor, request: Fixt assert valid_data == valid_data_copy def test_should_raise_if_not_fitted(self, regressor: Regressor, valid_data: TabularDataset) -> None: - with pytest.raises(ModelNotFittedError): + with pytest.raises(NotFittedError): regressor.predict(valid_data.features) def test_should_raise_if_dataset_misses_features(self, regressor: Regressor, valid_data: TabularDataset) -> None: @@ -438,7 +438,7 @@ def predict(self, dataset: Table | TabularDataset) -> TabularDataset: feature = predicted.rename("feature") dataset = Table.from_columns([feature, predicted]) - return dataset.to_tabular_dataset(target_name="predicted") + return dataset.to_tabular_dataset("predicted") @property def is_fitted(self) -> bool: @@ -478,7 +478,7 @@ def test_valid_data(self, predicted: list[float], expected: list[float], result: "expected": expected, }, ).to_tabular_dataset( - target_name="expected", + "expected", ) assert DummyRegressor().summarize_metrics(table) == result @@ -502,7 +502,7 @@ def test_valid_data(self, predicted: list[float], expected: list[float], result: "expected": expected, }, ).to_tabular_dataset( - target_name="expected", + "expected", ) assert DummyRegressor().mean_absolute_error(table) == result @@ -516,7 +516,7 @@ class TestMeanSquaredError: ) def test_valid_data(self, predicted: list[float], expected: list[float], result: float) -> None: table = Table({"predicted": predicted, "expected": expected}).to_tabular_dataset( - target_name="expected", + "expected", ) assert DummyRegressor().mean_squared_error(table) == result @@ -528,7 +528,7 @@ class TestCheckMetricsPreconditions: [ (["A", "B"], [1, 2], TypeError), ([1, 2], ["A", "B"], TypeError), - ([1, 2, 3], [1, 2], ColumnLengthMismatchError), + ([1, 2, 3], [1, 2], LengthMismatchError), ], ) def test_should_raise_if_validation_fails( diff --git a/tests/safeds/ml/classical/regression/test_support_vector_machine.py b/tests/safeds/ml/classical/regression/test_support_vector_machine.py index 86d79fbd6..2e52cb753 100644 --- a/tests/safeds/ml/classical/regression/test_support_vector_machine.py +++ b/tests/safeds/ml/classical/regression/test_support_vector_machine.py @@ -1,6 +1,7 @@ import sys import pytest + from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import OutOfBoundsError @@ -29,10 +30,10 @@ def kernels() -> list[SupportVectorRegressor.Kernel]: ] -@pytest.fixture() +@pytest.fixture def training_set() -> TabularDataset: table = Table({"col1": [1, 2, 3, 4], "col2": [1, 2, 3, 4]}) - return table.to_tabular_dataset(target_name="col1") + return table.to_tabular_dataset("col1") class TestC: diff --git a/tests/safeds/ml/classical/test_util_sklearn.py b/tests/safeds/ml/classical/test_util_sklearn.py index 75e5c1fe6..27443084f 100644 --- a/tests/safeds/ml/classical/test_util_sklearn.py +++ b/tests/safeds/ml/classical/test_util_sklearn.py @@ -11,7 +11,7 @@ # # def test_predict_should_not_warn_about_feature_names() -> None: # """See https://github.com/Safe-DS/Library/issues/51.""" -# training_set = Table({"a": [1, 2, 3], "b": [2, 4, 6]}).to_tabular_dataset(target_name="b") +# training_set = Table({"a": [1, 2, 3], "b": [2, 4, 6]}).to_tabular_dataset("b") # # model = LinearRegressor() # fitted_model = model.fit(training_set) diff --git a/tests/safeds/ml/hyperparameters/test_choice.py b/tests/safeds/ml/hyperparameters/test_choice.py index ce4213044..758f8a081 100644 --- a/tests/safeds/ml/hyperparameters/test_choice.py +++ b/tests/safeds/ml/hyperparameters/test_choice.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING import pytest + from safeds.exceptions import EmptyChoiceError from safeds.ml.hyperparameters import Choice diff --git a/tests/safeds/ml/nn/converters/test_input_converter_time_series.py b/tests/safeds/ml/nn/converters/test_input_converter_time_series.py index 7e2d8f1b6..0b98ec9ad 100644 --- a/tests/safeds/ml/nn/converters/test_input_converter_time_series.py +++ b/tests/safeds/ml/nn/converters/test_input_converter_time_series.py @@ -2,6 +2,7 @@ import pytest +from safeds.data.labeled.containers import TimeSeriesDataset from safeds.data.tabular.containers import Table from safeds.ml.nn import ( NeuralNetworkRegressor, @@ -19,8 +20,13 @@ def test_should_raise_if_is_fitted_is_set_correctly_lstm() -> None: InputConversionTimeSeries(), [LSTMLayer(neuron_count=1)], ) - ts = Table.from_dict({"target": [1, 1, 1, 1], "time": [0, 0, 0, 0], "feat": [0, 0, 0, 0]}).to_time_series_dataset( - target_name="target", + # ts = Table.from_dict({"target": [1, 1, 1, 1], "time": [0, 0, 0, 0], "feat": [0, 0, 0, 0]}).to_time_series_dataset( + # "target", + # window_size=1, + # ) + ts = TimeSeriesDataset( + Table.from_dict({"target": [1, 1, 1, 1], "time": [0, 0, 0, 0], "feat": [0, 0, 0, 0]}), + "target", window_size=1, ) assert not model.is_fitted diff --git a/tests/safeds/ml/nn/layers/test_convolutional2d_layer.py b/tests/safeds/ml/nn/layers/test_convolutional2d_layer.py index 4b8ac362e..495b580e3 100644 --- a/tests/safeds/ml/nn/layers/test_convolutional2d_layer.py +++ b/tests/safeds/ml/nn/layers/test_convolutional2d_layer.py @@ -2,9 +2,10 @@ from typing import Literal import pytest +from torch import nn + from safeds.data.image.typing import ImageSize from safeds.ml.nn.layers import Convolutional2DLayer, ConvolutionalTranspose2DLayer -from torch import nn class TestConvolutional2DLayer: diff --git a/tests/safeds/ml/nn/layers/test_forward_layer.py b/tests/safeds/ml/nn/layers/test_forward_layer.py index 1879a7287..82cd84a8a 100644 --- a/tests/safeds/ml/nn/layers/test_forward_layer.py +++ b/tests/safeds/ml/nn/layers/test_forward_layer.py @@ -2,11 +2,12 @@ from typing import Any, Literal import pytest +from torch import nn + from safeds.data.image.typing import ImageSize from safeds.exceptions import OutOfBoundsError from safeds.ml.hyperparameters import Choice from safeds.ml.nn.layers import ForwardLayer -from torch import nn # TODO: Should be tested on a model, not a layer, since input size gets inferred # @pytest.mark.parametrize( diff --git a/tests/safeds/ml/nn/layers/test_gru_layer.py b/tests/safeds/ml/nn/layers/test_gru_layer.py index 409b5880e..d2dd602ff 100644 --- a/tests/safeds/ml/nn/layers/test_gru_layer.py +++ b/tests/safeds/ml/nn/layers/test_gru_layer.py @@ -2,11 +2,12 @@ from typing import Any import pytest +from torch import nn + from safeds.data.image.typing import ImageSize from safeds.exceptions import OutOfBoundsError from safeds.ml.hyperparameters import Choice from safeds.ml.nn.layers import GRULayer -from torch import nn @pytest.mark.parametrize( diff --git a/tests/safeds/ml/nn/layers/test_lstm_layer.py b/tests/safeds/ml/nn/layers/test_lstm_layer.py index fc04c6eac..0323a293a 100644 --- a/tests/safeds/ml/nn/layers/test_lstm_layer.py +++ b/tests/safeds/ml/nn/layers/test_lstm_layer.py @@ -2,11 +2,12 @@ from typing import Any import pytest +from torch import nn + from safeds.data.image.typing import ImageSize from safeds.exceptions import OutOfBoundsError from safeds.ml.hyperparameters import Choice from safeds.ml.nn.layers import LSTMLayer -from torch import nn # TODO: Should be tested on a model, not a layer, since input size gets inferred # @pytest.mark.parametrize( diff --git a/tests/safeds/ml/nn/test_forward_workflow.py b/tests/safeds/ml/nn/test_forward_workflow.py index a3957a83c..2c2252634 100644 --- a/tests/safeds/ml/nn/test_forward_workflow.py +++ b/tests/safeds/ml/nn/test_forward_workflow.py @@ -39,6 +39,6 @@ def test_forward_model(device: Device) -> None: ) fitted_model = model.fit(train_table.to_tabular_dataset("target"), epoch_count=1, learning_rate=0.01) - fitted_model.predict(test_table.remove_columns_except(["value"])) + fitted_model.predict(test_table.select_columns(["value"])) assert fitted_model._model is not None assert fitted_model._model.state_dict()["_pytorch_layers.0._layer.weight"].device == _get_device() diff --git a/tests/safeds/ml/nn/test_lstm_workflow.py b/tests/safeds/ml/nn/test_lstm_workflow.py index 50a75c1d8..31443afd2 100644 --- a/tests/safeds/ml/nn/test_lstm_workflow.py +++ b/tests/safeds/ml/nn/test_lstm_workflow.py @@ -2,6 +2,7 @@ from torch.types import Device from safeds._config import _get_device +from safeds.data.labeled.containers import TimeSeriesDataset from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import RangeScaler from safeds.ml.nn import ( @@ -38,7 +39,15 @@ def test_lstm_model(device: Device) -> None: [ForwardLayer(neuron_count=256), GRULayer(128), LSTMLayer(neuron_count=1)], ) trained_model = model.fit( - train_table.to_time_series_dataset( + # train_table.to_time_series_dataset( + # "value", + # window_size=7, + # forecast_horizon=12, + # continuous=True, + # extra_names=["date"], + # ), + TimeSeriesDataset( + train_table, "value", window_size=7, forecast_horizon=12, @@ -50,7 +59,15 @@ def test_lstm_model(device: Device) -> None: trained_model.predict(test_table) trained_model_2 = model_2.fit( - train_table.to_time_series_dataset( + # train_table.to_time_series_dataset( + # "value", + # window_size=7, + # forecast_horizon=12, + # continuous=False, + # extra_names=["date"], + # ), + TimeSeriesDataset( + train_table, "value", window_size=7, forecast_horizon=12, diff --git a/tests/safeds/ml/nn/test_model.py b/tests/safeds/ml/nn/test_model.py index c425da5fd..46e6165e6 100644 --- a/tests/safeds/ml/nn/test_model.py +++ b/tests/safeds/ml/nn/test_model.py @@ -5,14 +5,14 @@ from torch.types import Device from safeds.data.image.typing import ImageSize -from safeds.data.labeled.containers import TabularDataset +from safeds.data.labeled.containers import TabularDataset, TimeSeriesDataset from safeds.data.tabular.containers import Table from safeds.exceptions import ( FeatureDataMismatchError, FittingWithChoiceError, InvalidFitDataError, InvalidModelStructureError, - ModelNotFittedError, + NotFittedError, OutOfBoundsError, ) from safeds.ml.hyperparameters import Choice @@ -206,7 +206,13 @@ def test_should_catch_invalid_fit_data(self, device: Device, table: TabularDatas def test_should_raise_when_time_series_classification_with_continuous_data(self, device: Device) -> None: configure_test_with_device(device) - data = Table.from_dict({"a": [1, 2, 3], "b": [1, 2, 3], "c": [0, 1, 0]}).to_time_series_dataset( + # data = Table.from_dict({"a": [1, 2, 3], "b": [1, 2, 3], "c": [0, 1, 0]}).to_time_series_dataset( + # "c", + # 1, + # continuous=True, + # ) + data = TimeSeriesDataset( + Table.from_dict({"a": [1, 2, 3], "b": [1, 2, 3], "c": [0, 1, 0]}), "c", 1, continuous=True, @@ -623,7 +629,7 @@ def test_should_raise_if_predict_function_returns_wrong_datatype_for_multiclass_ def test_should_raise_if_model_has_not_been_fitted(self, device: Device) -> None: configure_test_with_device(device) - with pytest.raises(ModelNotFittedError, match="The model has not been fitted yet."): + with pytest.raises(NotFittedError, match="This model has not been fitted yet."): NeuralNetworkClassifier( InputConversionTable(), [ForwardLayer(neuron_count=1)], @@ -1235,7 +1241,7 @@ def test_should_raise_if_predict_function_returns_wrong_datatype(self, batch_siz def test_should_raise_if_model_has_not_been_fitted(self, device: Device) -> None: configure_test_with_device(device) - with pytest.raises(ModelNotFittedError, match="The model has not been fitted yet."): + with pytest.raises(NotFittedError, match="This model has not been fitted yet."): NeuralNetworkRegressor( InputConversionTable(), [ForwardLayer(neuron_count=1)], diff --git a/tests/safeds/ml/nn/typing/test_model_image_size.py b/tests/safeds/ml/nn/typing/test_model_image_size.py index 423b42aee..4827b2137 100644 --- a/tests/safeds/ml/nn/typing/test_model_image_size.py +++ b/tests/safeds/ml/nn/typing/test_model_image_size.py @@ -2,11 +2,11 @@ from typing import Any import pytest +from torch.types import Device + from safeds.data.image.containers import Image from safeds.exceptions import OutOfBoundsError from safeds.ml.nn.typing import ConstantImageSize, ModelImageSize, VariableImageSize -from torch.types import Device - from tests.helpers import ( configure_test_with_device, get_devices,