From 7eaddd9a5469e5c3a8829b0df45d0a05c8c5afab Mon Sep 17 00:00:00 2001 From: Mikhalkovich Stanislav Date: Thu, 7 May 2026 22:53:13 +0300 Subject: [PATCH] =?UTF-8?q?ML=20-=20=D0=BC=D0=BD=D0=BE=D0=B6=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=BE=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20ML=20-=20=D1=83=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B5=D1=82=D0=BE=D1=87=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=B9=20=D0=B8=20=D0=B1=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=20ML=20-=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20DecisionTreeRegressor.Fit,=20RandomForestRegress?= =?UTF-8?q?or.Fit=20ML=20-=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Configuration/GlobalAssemblyInfo.cs | 2 +- Configuration/Version.defs | 2 +- .../01_FirstClassification_DataFrame.pas | 38 + .../02_FirstRegression_DataFrame.pas | 46 + .../03_FirstClustering_DataFrame.pas | 84 + .../04_FirstPipeline_DataFrame.pas | 59 + .../MachineLearning/02_DataFrame/df19.pas | 2 +- .../MachineLearning/02_DataFrame/df20.pas | 2 +- .../MachineLearning/02_DataFrame/df21.pas | 2 +- .../03_Preprocessing/01_StandardScaler.pas | 31 + .../03_Preprocessing/02_MinMaxScaler.pas | 32 + .../03_Imputer_LabelEncoder.pas | 30 + .../03_Preprocessing/04_OneHotEncoder.pas | 24 + .../03_Preprocessing/05_Normalizer.pas | 22 + .../03_Preprocessing/06_PCA.pas | 24 + .../03_Preprocessing/07_VarianceThreshold.pas | 23 + .../03_Preprocessing/08_SelectKBest.pas | 55 + .../09_PreprocessingChain.pas | 30 + .../04_Models/02_ClassificationBasic.pas | 31 + .../04_Models/03_RegressionCorrelated.pas | 6 - .../04_Models/11_KNN_WithScaling.pas | 60 + .../04_Models/12_KNNRegression.pas | 51 + .../04_Models/13_DecisionTreeRegression.pas | 50 + .../04_Models/14_RandomForestRegression.pas | 65 + .../15_GradientBoostingRegression.pas | 62 + .../16_RandomForestClassification.pas | 49 + .../17_GradientBoostingClassification.pas | 44 + .../18_DecisionTreeClassification.pas | 32 + .../04_Models/19_KNNClassification.pas | 27 + .../20_LogisticRegression_Probabilities.pas | 36 + .../21_RandomForest_Probabilities.pas | 35 + .../22_GradientBoosting_Probabilities.pas | 42 + .../01_TrainTestSplit_CrossValidate.pas | 47 + .../02_CrossValidate_Regression.pas | 46 + .../05_Validation/03_GridSearch_Ridge.pas | 81 + .../05_Validation/04_GridSearch_KNN.pas | 50 + .../05_GridSearch_LogisticRegression.exe | Bin 0 -> 112640 bytes .../05_GridSearch_LogisticRegression.pas | 56 + .../06_ModelComparison_Classification.pas | 52 + .../07_ModelComparison_Regression.pas | 55 + .../01_DataPipeline_Classification.pas | 43 + .../02_DataPipeline_Regression.pas | 35 + ...02_DataPipeline_Regression_Categorical.exe | Bin 0 -> 153088 bytes .../06_Pipelines/03_UDataPipeline_KMeans.pas | 48 + .../04_UMatrixPipeline_KMeans.pas | 58 + .../05_MatrixPipeline_Classification.exe | Bin 0 -> 137216 bytes .../05_MatrixPipeline_Classification.pas | 45 + .../06_MatrixPipeline_Regression.exe | Bin 0 -> 129024 bytes .../06_MatrixPipeline_Regression.pas | 32 + .../07_MatrixPipeline_KNN_WithScaling.pas | 47 + .../01_KMeans_SelectK_ByInertia.pas | 41 + .../07_Clustering/02_KMeans_vs_DBSCAN.pas | 41 + .../07_Clustering/03_DBSCAN_SelectEps.pas | 44 + .../07_Clustering/04_RussianCities_KMeans.pas | 45 + .../07_Clustering/05_ClusteringMetrics.exe | Bin 0 -> 100864 bytes .../07_Clustering/05_ClusteringMetrics.pas | 37 + .../08_Datasets/Iris/07_Pipeline.pas | 14 +- .../08_Datasets/MoscowHousing/01_Info.pas | 4 +- .../08_Datasets/RussianCities/01_Info.pas | 6 +- .../08_Datasets/TitanicRu/01_Info.pas | 14 + .../TitanicRu/02_AgeFareScatter.pas | 16 + .../TitanicRu/03_Preprocessing.pas | 24 + .../TitanicRu/04_AgeHistogramBySurvival.pas | 23 + .../TitanicRu/05_Classification.pas | 32 + .../TitanicRu/06_ModelComparison.pas | 48 + .../08_Datasets/TitanicRu/07_PredictProba.pas | 39 + .../TitanicRu/08_CrossValidation.pas | 58 + .../TitanicRu/09_FeatureImportance.pas | 31 + .../TitanicRu/10_SurvivalBySexAndClass.pas | 26 + .../08_Datasets/_Synthetic/05_MakeSpiral.pas | 2 +- .../09_Visualization/01_Scatter_Iris.pas | 28 + .../09_Visualization/02_PairPlot_Iris.pas | 26 + .../09_Visualization/03_Histogram_Iris.pas | 21 + .../04_Heatmap_Correlation.pas | 25 + .../05_Surface_ClassificationBoundary.pas | 29 + .../06_Surface_LogisticVsTree.pas | 34 + .../07_Surface_TreeVsForest.pas | 37 + .../01_Iris_EndToEnd_Pipeline.exe | Bin 0 -> 146944 bytes .../01_Iris_EndToEnd_Pipeline.pas | 28 + .../02_MoscowHousing_EndToEnd_Pipeline.exe | Bin 0 -> 153088 bytes .../02_MoscowHousing_Pipeline.pas | 34 + .../MachineLearning/13_Seminars/seminar1.pas | 6 +- .../MachineLearning/13_Seminars/seminar2.pas | 4 +- .../MachineLearning/13_Seminars/seminar3.pas | 4 +- .../MachineLearning/13_Seminars/seminar4.pas | 3 +- .../13_Seminars/seminar5_synthetic.pas | 122 ++ .../WhatsNew/3_11_1/DataFrameABC1.pas | 8 +- Libraries/WeifenLuo.WinFormsUI.Docking.dll | Bin 417792 -> 417792 bytes .../WinFormsUI/Docking/VS2005DockPaneStrip.cs | 2 +- Release/pabcversion.txt | 2 +- .../Files/Datasets/moscow_housing.csv | 1501 +++++++++++++++++ .../Files/Datasets/moscow_housing.meta | 49 + .../Files/Datasets/russian_cities.csv | 672 ++++++++ .../Files/Datasets/russian_cities.meta | 56 + .../Files/Datasets/titanic_ru.csv | 892 ++++++++++ .../Files/Datasets/titanic_ru.meta | 64 + ReleaseGenerators/PascalABCNET_version.nsh | 2 +- .../Core/001_labels_to_ints_strict.pas | 15 + .../Core/002_vector_tointarray_strict.pas | 15 + .../003_dataframe_setschema_validation.pas | 15 + .../Core/004_groupby_bool_key_schema.pas | 17 + .../Core/005_head_tail_empty_schema.pas | 28 + ...ndardizeall_preserves_nonnumeric_types.pas | 16 + ...ormalizeall_preserves_nonnumeric_types.pas | 16 + ...8_transformlabels_unknown_class_raises.pas | 21 + .../Core/009_mape_rejects_nan_inf.pas | 17 + .../010_onehot_preserves_other_metadata.pas | 22 + .../Core/011_imputer_preserves_schema.pas | 20 + ...12_imputer_constant_preserves_int_type.pas | 20 + .../013_groupby_count_bool_key_schema.pas | 17 + ...taframe_cursor_invalid_accessor_raises.pas | 15 + ...ansformlabels_unknown_int_class_raises.pas | 21 + .../Core/016_mape_skips_zero_targets.pas | 13 + .../017_r2_zero_variance_returns_zero.pas | 10 + .../Core/018_head_regular_slice.pas | 16 + .../Core/019_tail_regular_slice.pas | 16 + .../Core/020_filter_preserves_schema.pas | 15 + ...21_takerows_preserves_schema_and_order.pas | 16 + ...22_select_preserves_order_and_metadata.pas | 19 + ...eeps_schema_and_column_name_consistent.pas | 16 + .../024_join_no_collision_names_contract.pas | 21 + .../025_join_collision_names_contract.pas | 21 + .../026_drop_preserves_order_and_metadata.pas | 18 + .../Core/027_join_multikey_contract.pas | 23 + ...028_right_join_different_keys_contract.pas | 18 + .../Core/029_full_join_contract.pas | 19 + .../030_labelencoder_first_appearance.pas | 19 + .../031_dataadapters_encode_str_contract.pas | 25 + .../032_dataadapters_encode_int_contract.pas | 25 + .../033_full_join_different_keys_contract.pas | 19 + ..._dataframe_setschema_failure_is_atomic.pas | 21 + ...taframe_add_duplicate_column_is_atomic.pas | 15 + ...egorical_returns_independent_dataframe.pas | 18 + ...cs_accuracy_rejects_non_integer_labels.pas | 11 + .../038_metrics_accuracy_rejects_nan_inf.pas | 11 + ...egorical_flags_on_right_nonkey_columns.pas | 19 + ...40_groupby_keys_are_marked_categorical.pas | 14 + ...sform_type_drift_raises_contract_error.pas | 19 + ...sform_type_drift_raises_contract_error.pas | 19 + ...fferent_keys_with_right_name_collision.pas | 20 + .../Examples/001_iris_07_pipeline_smoke.pas | 27 + .../002_iris_04_traintestsplit_smoke.pas | 20 + .../003_preprocessing_minmax_smoke.pas | 17 + .../004_preprocessing_onehot_smoke.pas | 18 + .../005_preprocessing_imputer_smoke.pas | 16 + .../Examples/006_upipeline_kmeans_smoke.pas | 17 + .../Regression/001_join_schema_names.pas | 29 + .../Regression/002_pipeline_predictlabels.pas | 44 + .../003_logistic_predictlabels_contract.pas | 29 + ...04_randomforest_predictlabels_contract.pas | 29 + ...radientboosting_predictlabels_contract.pas | 29 + .../006_knn_predictlabels_contract.pas | 29 + .../Regression/007_minmax_inverse_range.pas | 20 + .../008_dataframe_schema_snapshot.pas | 24 + .../009_pipeline_rowcount_invariant.pas | 45 + ...10_decisiontree_predictlabels_contract.pas | 29 + ...1_decisiontree_getclasslabels_contract.pas | 19 + ...ipeline_getencodedlabels_unknown_class.pas | 26 + .../013_upipeline_rowcount_invariant.pas | 43 + .../014_logistic_getclasslabels_contract.pas | 19 + ...5_randomforest_getclasslabels_contract.pas | 19 + ...adientboosting_getclasslabels_contract.pas | 19 + .../017_knn_getclasslabels_contract.pas | 19 + ...eline_regression_rejects_string_target.pas | 19 + ...sification_requires_categorical_target.pas | 18 + .../020_logistic_predictproba_shape.pas | 19 + .../021_randomforest_predictproba_shape.pas | 19 + ...22_gradientboosting_predictproba_shape.pas | 19 + .../023_pipeline_predictproba_shape.pas | 26 + ...4_pipeline_target_in_features_rejected.pas | 14 + ..._upipeline_duplicate_features_rejected.pas | 12 + ...predictlabels_returns_original_strings.pas | 27 + .../027_join_float_key_rejected.pas | 15 + ...028_pipeline_target_bound_step_blocked.pas | 53 + ...9_pipeline_target_columns_step_blocked.pas | 53 + ...peline_int_categorical_target_contract.pas | 41 + ...31_logistic_sparse_raw_labels_contract.pas | 38 + ...andomforest_sparse_raw_labels_contract.pas | 33 + ...entboosting_sparse_raw_labels_contract.pas | 33 + ...ecisiontree_sparse_raw_labels_contract.pas | 31 + .../035_knn_sparse_raw_labels_contract.pas | 33 + ..._pipeline_predict_dense_encoded_labels.pas | 32 + ..._contract_matches_documented_semantics.pas | 35 + ...lassifier_predict_contract_is_explicit.pas | 28 + TestSuite/_MachineLearning/TestHelpers.pas | 63 + TestSuite/_MachineLearning/clean_ml_tests.cmd | 6 + TestSuite/_MachineLearning/run_ml_tests.cmd | 4 + TestSuite/_MachineLearning/run_ml_tests.ps1 | 121 ++ bin/Lib/DataAdapters.pas | 6 +- bin/Lib/DataFrameABC.pas | 794 +++++++-- bin/Lib/DataFrameABCCore.pas | 52 +- bin/Lib/LinearAlgebraML.pas | 72 + bin/Lib/MLABC.pas | 4 +- bin/Lib/MLCoreABC.pas | 21 +- bin/Lib/MLDatasets.pas | 113 +- bin/Lib/MLModelsABC.pas | 915 +++++----- bin/Lib/MLPipelineABC.pas | 80 +- bin/Lib/MetricsABC.pas | 24 +- bin/Lib/PlotML.pas | 447 ++++- bin/Lib/PreprocessorABC.pas | 410 +++-- 200 files changed, 10214 insertions(+), 860 deletions(-) create mode 100644 InstallerSamples/MachineLearning/01_GettingStarted/01_FirstClassification_DataFrame.pas create mode 100644 InstallerSamples/MachineLearning/01_GettingStarted/02_FirstRegression_DataFrame.pas create mode 100644 InstallerSamples/MachineLearning/01_GettingStarted/03_FirstClustering_DataFrame.pas create mode 100644 InstallerSamples/MachineLearning/01_GettingStarted/04_FirstPipeline_DataFrame.pas create mode 100644 InstallerSamples/MachineLearning/03_Preprocessing/01_StandardScaler.pas create mode 100644 InstallerSamples/MachineLearning/03_Preprocessing/02_MinMaxScaler.pas create mode 100644 InstallerSamples/MachineLearning/03_Preprocessing/03_Imputer_LabelEncoder.pas create mode 100644 InstallerSamples/MachineLearning/03_Preprocessing/04_OneHotEncoder.pas create mode 100644 InstallerSamples/MachineLearning/03_Preprocessing/05_Normalizer.pas create mode 100644 InstallerSamples/MachineLearning/03_Preprocessing/06_PCA.pas create mode 100644 InstallerSamples/MachineLearning/03_Preprocessing/07_VarianceThreshold.pas create mode 100644 InstallerSamples/MachineLearning/03_Preprocessing/08_SelectKBest.pas create mode 100644 InstallerSamples/MachineLearning/03_Preprocessing/09_PreprocessingChain.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/02_ClassificationBasic.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/11_KNN_WithScaling.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/12_KNNRegression.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/13_DecisionTreeRegression.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/14_RandomForestRegression.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/15_GradientBoostingRegression.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/16_RandomForestClassification.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/17_GradientBoostingClassification.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/18_DecisionTreeClassification.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/19_KNNClassification.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/20_LogisticRegression_Probabilities.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/21_RandomForest_Probabilities.pas create mode 100644 InstallerSamples/MachineLearning/04_Models/22_GradientBoosting_Probabilities.pas create mode 100644 InstallerSamples/MachineLearning/05_Validation/01_TrainTestSplit_CrossValidate.pas create mode 100644 InstallerSamples/MachineLearning/05_Validation/02_CrossValidate_Regression.pas create mode 100644 InstallerSamples/MachineLearning/05_Validation/03_GridSearch_Ridge.pas create mode 100644 InstallerSamples/MachineLearning/05_Validation/04_GridSearch_KNN.pas create mode 100644 InstallerSamples/MachineLearning/05_Validation/05_GridSearch_LogisticRegression.exe create mode 100644 InstallerSamples/MachineLearning/05_Validation/05_GridSearch_LogisticRegression.pas create mode 100644 InstallerSamples/MachineLearning/05_Validation/06_ModelComparison_Classification.pas create mode 100644 InstallerSamples/MachineLearning/05_Validation/07_ModelComparison_Regression.pas create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/01_DataPipeline_Classification.pas create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/02_DataPipeline_Regression.pas create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/02_DataPipeline_Regression_Categorical.exe create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/03_UDataPipeline_KMeans.pas create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/04_UMatrixPipeline_KMeans.pas create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/05_MatrixPipeline_Classification.exe create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/05_MatrixPipeline_Classification.pas create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/06_MatrixPipeline_Regression.exe create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/06_MatrixPipeline_Regression.pas create mode 100644 InstallerSamples/MachineLearning/06_Pipelines/07_MatrixPipeline_KNN_WithScaling.pas create mode 100644 InstallerSamples/MachineLearning/07_Clustering/01_KMeans_SelectK_ByInertia.pas create mode 100644 InstallerSamples/MachineLearning/07_Clustering/02_KMeans_vs_DBSCAN.pas create mode 100644 InstallerSamples/MachineLearning/07_Clustering/03_DBSCAN_SelectEps.pas create mode 100644 InstallerSamples/MachineLearning/07_Clustering/04_RussianCities_KMeans.pas create mode 100644 InstallerSamples/MachineLearning/07_Clustering/05_ClusteringMetrics.exe create mode 100644 InstallerSamples/MachineLearning/07_Clustering/05_ClusteringMetrics.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/01_Info.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/02_AgeFareScatter.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/03_Preprocessing.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/04_AgeHistogramBySurvival.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/05_Classification.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/06_ModelComparison.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/07_PredictProba.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/08_CrossValidation.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/09_FeatureImportance.pas create mode 100644 InstallerSamples/MachineLearning/08_Datasets/TitanicRu/10_SurvivalBySexAndClass.pas create mode 100644 InstallerSamples/MachineLearning/09_Visualization/01_Scatter_Iris.pas create mode 100644 InstallerSamples/MachineLearning/09_Visualization/02_PairPlot_Iris.pas create mode 100644 InstallerSamples/MachineLearning/09_Visualization/03_Histogram_Iris.pas create mode 100644 InstallerSamples/MachineLearning/09_Visualization/04_Heatmap_Correlation.pas create mode 100644 InstallerSamples/MachineLearning/09_Visualization/05_Surface_ClassificationBoundary.pas create mode 100644 InstallerSamples/MachineLearning/09_Visualization/06_Surface_LogisticVsTree.pas create mode 100644 InstallerSamples/MachineLearning/09_Visualization/07_Surface_TreeVsForest.pas create mode 100644 InstallerSamples/MachineLearning/10_RealTasks/01_Iris_EndToEnd_Pipeline.exe create mode 100644 InstallerSamples/MachineLearning/10_RealTasks/01_Iris_EndToEnd_Pipeline.pas create mode 100644 InstallerSamples/MachineLearning/10_RealTasks/02_MoscowHousing_EndToEnd_Pipeline.exe create mode 100644 InstallerSamples/MachineLearning/10_RealTasks/02_MoscowHousing_Pipeline.pas create mode 100644 InstallerSamples/MachineLearning/13_Seminars/seminar5_synthetic.pas create mode 100644 ReleaseGenerators/Files/Datasets/moscow_housing.csv create mode 100644 ReleaseGenerators/Files/Datasets/moscow_housing.meta create mode 100644 ReleaseGenerators/Files/Datasets/russian_cities.csv create mode 100644 ReleaseGenerators/Files/Datasets/russian_cities.meta create mode 100644 ReleaseGenerators/Files/Datasets/titanic_ru.csv create mode 100644 ReleaseGenerators/Files/Datasets/titanic_ru.meta create mode 100644 TestSuite/_MachineLearning/Core/001_labels_to_ints_strict.pas create mode 100644 TestSuite/_MachineLearning/Core/002_vector_tointarray_strict.pas create mode 100644 TestSuite/_MachineLearning/Core/003_dataframe_setschema_validation.pas create mode 100644 TestSuite/_MachineLearning/Core/004_groupby_bool_key_schema.pas create mode 100644 TestSuite/_MachineLearning/Core/005_head_tail_empty_schema.pas create mode 100644 TestSuite/_MachineLearning/Core/006_standardizeall_preserves_nonnumeric_types.pas create mode 100644 TestSuite/_MachineLearning/Core/007_normalizeall_preserves_nonnumeric_types.pas create mode 100644 TestSuite/_MachineLearning/Core/008_transformlabels_unknown_class_raises.pas create mode 100644 TestSuite/_MachineLearning/Core/009_mape_rejects_nan_inf.pas create mode 100644 TestSuite/_MachineLearning/Core/010_onehot_preserves_other_metadata.pas create mode 100644 TestSuite/_MachineLearning/Core/011_imputer_preserves_schema.pas create mode 100644 TestSuite/_MachineLearning/Core/012_imputer_constant_preserves_int_type.pas create mode 100644 TestSuite/_MachineLearning/Core/013_groupby_count_bool_key_schema.pas create mode 100644 TestSuite/_MachineLearning/Core/014_dataframe_cursor_invalid_accessor_raises.pas create mode 100644 TestSuite/_MachineLearning/Core/015_transformlabels_unknown_int_class_raises.pas create mode 100644 TestSuite/_MachineLearning/Core/016_mape_skips_zero_targets.pas create mode 100644 TestSuite/_MachineLearning/Core/017_r2_zero_variance_returns_zero.pas create mode 100644 TestSuite/_MachineLearning/Core/018_head_regular_slice.pas create mode 100644 TestSuite/_MachineLearning/Core/019_tail_regular_slice.pas create mode 100644 TestSuite/_MachineLearning/Core/020_filter_preserves_schema.pas create mode 100644 TestSuite/_MachineLearning/Core/021_takerows_preserves_schema_and_order.pas create mode 100644 TestSuite/_MachineLearning/Core/022_select_preserves_order_and_metadata.pas create mode 100644 TestSuite/_MachineLearning/Core/023_rename_keeps_schema_and_column_name_consistent.pas create mode 100644 TestSuite/_MachineLearning/Core/024_join_no_collision_names_contract.pas create mode 100644 TestSuite/_MachineLearning/Core/025_join_collision_names_contract.pas create mode 100644 TestSuite/_MachineLearning/Core/026_drop_preserves_order_and_metadata.pas create mode 100644 TestSuite/_MachineLearning/Core/027_join_multikey_contract.pas create mode 100644 TestSuite/_MachineLearning/Core/028_right_join_different_keys_contract.pas create mode 100644 TestSuite/_MachineLearning/Core/029_full_join_contract.pas create mode 100644 TestSuite/_MachineLearning/Core/030_labelencoder_first_appearance.pas create mode 100644 TestSuite/_MachineLearning/Core/031_dataadapters_encode_str_contract.pas create mode 100644 TestSuite/_MachineLearning/Core/032_dataadapters_encode_int_contract.pas create mode 100644 TestSuite/_MachineLearning/Core/033_full_join_different_keys_contract.pas create mode 100644 TestSuite/_MachineLearning/Core/034_dataframe_setschema_failure_is_atomic.pas create mode 100644 TestSuite/_MachineLearning/Core/035_dataframe_add_duplicate_column_is_atomic.pas create mode 100644 TestSuite/_MachineLearning/Core/036_setcategorical_returns_independent_dataframe.pas create mode 100644 TestSuite/_MachineLearning/Core/037_metrics_accuracy_rejects_non_integer_labels.pas create mode 100644 TestSuite/_MachineLearning/Core/038_metrics_accuracy_rejects_nan_inf.pas create mode 100644 TestSuite/_MachineLearning/Core/039_join_preserves_categorical_flags_on_right_nonkey_columns.pas create mode 100644 TestSuite/_MachineLearning/Core/040_groupby_keys_are_marked_categorical.pas create mode 100644 TestSuite/_MachineLearning/Core/041_labelencoder_transform_type_drift_raises_contract_error.pas create mode 100644 TestSuite/_MachineLearning/Core/042_onehot_transform_type_drift_raises_contract_error.pas create mode 100644 TestSuite/_MachineLearning/Core/043_join_different_keys_with_right_name_collision.pas create mode 100644 TestSuite/_MachineLearning/Examples/001_iris_07_pipeline_smoke.pas create mode 100644 TestSuite/_MachineLearning/Examples/002_iris_04_traintestsplit_smoke.pas create mode 100644 TestSuite/_MachineLearning/Examples/003_preprocessing_minmax_smoke.pas create mode 100644 TestSuite/_MachineLearning/Examples/004_preprocessing_onehot_smoke.pas create mode 100644 TestSuite/_MachineLearning/Examples/005_preprocessing_imputer_smoke.pas create mode 100644 TestSuite/_MachineLearning/Examples/006_upipeline_kmeans_smoke.pas create mode 100644 TestSuite/_MachineLearning/Regression/001_join_schema_names.pas create mode 100644 TestSuite/_MachineLearning/Regression/002_pipeline_predictlabels.pas create mode 100644 TestSuite/_MachineLearning/Regression/003_logistic_predictlabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/004_randomforest_predictlabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/005_gradientboosting_predictlabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/006_knn_predictlabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/007_minmax_inverse_range.pas create mode 100644 TestSuite/_MachineLearning/Regression/008_dataframe_schema_snapshot.pas create mode 100644 TestSuite/_MachineLearning/Regression/009_pipeline_rowcount_invariant.pas create mode 100644 TestSuite/_MachineLearning/Regression/010_decisiontree_predictlabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/011_decisiontree_getclasslabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/012_pipeline_getencodedlabels_unknown_class.pas create mode 100644 TestSuite/_MachineLearning/Regression/013_upipeline_rowcount_invariant.pas create mode 100644 TestSuite/_MachineLearning/Regression/014_logistic_getclasslabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/015_randomforest_getclasslabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/016_gradientboosting_getclasslabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/017_knn_getclasslabels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/018_pipeline_regression_rejects_string_target.pas create mode 100644 TestSuite/_MachineLearning/Regression/019_pipeline_classification_requires_categorical_target.pas create mode 100644 TestSuite/_MachineLearning/Regression/020_logistic_predictproba_shape.pas create mode 100644 TestSuite/_MachineLearning/Regression/021_randomforest_predictproba_shape.pas create mode 100644 TestSuite/_MachineLearning/Regression/022_gradientboosting_predictproba_shape.pas create mode 100644 TestSuite/_MachineLearning/Regression/023_pipeline_predictproba_shape.pas create mode 100644 TestSuite/_MachineLearning/Regression/024_pipeline_target_in_features_rejected.pas create mode 100644 TestSuite/_MachineLearning/Regression/025_upipeline_duplicate_features_rejected.pas create mode 100644 TestSuite/_MachineLearning/Regression/026_pipeline_predictlabels_returns_original_strings.pas create mode 100644 TestSuite/_MachineLearning/Regression/027_join_float_key_rejected.pas create mode 100644 TestSuite/_MachineLearning/Regression/028_pipeline_target_bound_step_blocked.pas create mode 100644 TestSuite/_MachineLearning/Regression/029_pipeline_target_columns_step_blocked.pas create mode 100644 TestSuite/_MachineLearning/Regression/030_pipeline_int_categorical_target_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/031_logistic_sparse_raw_labels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/032_randomforest_sparse_raw_labels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/033_gradientboosting_sparse_raw_labels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/034_decisiontree_sparse_raw_labels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/035_knn_sparse_raw_labels_contract.pas create mode 100644 TestSuite/_MachineLearning/Regression/036_pipeline_predict_dense_encoded_labels.pas create mode 100644 TestSuite/_MachineLearning/Regression/037_pipeline_predict_contract_matches_documented_semantics.pas create mode 100644 TestSuite/_MachineLearning/Regression/038_standalone_classifier_predict_contract_is_explicit.pas create mode 100644 TestSuite/_MachineLearning/TestHelpers.pas create mode 100644 TestSuite/_MachineLearning/clean_ml_tests.cmd create mode 100644 TestSuite/_MachineLearning/run_ml_tests.cmd create mode 100644 TestSuite/_MachineLearning/run_ml_tests.ps1 diff --git a/Configuration/GlobalAssemblyInfo.cs b/Configuration/GlobalAssemblyInfo.cs index 64fe7363e..afc5a8f17 100644 --- a/Configuration/GlobalAssemblyInfo.cs +++ b/Configuration/GlobalAssemblyInfo.cs @@ -15,7 +15,7 @@ internal static class RevisionClass public const string Major = "3"; public const string Minor = "11"; public const string Build = "1"; - public const string Revision = "3812"; + public const string Revision = "3814"; public const string MainVersion = Major + "." + Minor; public const string FullVersion = Major + "." + Minor + "." + Build + "." + Revision; diff --git a/Configuration/Version.defs b/Configuration/Version.defs index f399f3dad..2ed31f4f6 100644 --- a/Configuration/Version.defs +++ b/Configuration/Version.defs @@ -1,4 +1,4 @@ %COREVERSION%=1 -%REVISION%=3812 +%REVISION%=3814 %MINOR%=11 %MAJOR%=3 diff --git a/InstallerSamples/MachineLearning/01_GettingStarted/01_FirstClassification_DataFrame.pas b/InstallerSamples/MachineLearning/01_GettingStarted/01_FirstClassification_DataFrame.pas new file mode 100644 index 000000000..c9d3780c1 --- /dev/null +++ b/InstallerSamples/MachineLearning/01_GettingStarted/01_FirstClassification_DataFrame.pas @@ -0,0 +1,38 @@ +// Первый пример классификации. +// +// В этом примере: +// 1) загружаем готовый датасет Iris; +// 2) выделяем признаки и целевую переменную; +// 3) делим данные на обучающую и тестовую выборки; +// 4) обучаем логистическую регрессию; +// 5) оцениваем точность модели. +uses MLABC; + +begin + // Загружаем учебный датасет Iris + var ds := Datasets.Iris; + + // Берём таблицу данных из датасета + var df := ds.Data; + + // Преобразуем выбранные признаки в числовую матрицу + var X := df.ToMatrix(ds.Features); + + // Кодируем названия классов в числа 0, 1, 2 + var y := df.EncodeLabels(ds.Target); + + // Делим данные на обучающую и тестовую выборки + var (Xtrain, Xtest, ytrain, ytest) := Validation.TrainTestSplit(X, y, 0.2, 1); + + // Создаём модель логистической регрессии + var model := new LogisticRegression; + + // Обучаем модель на обучающей выборке + model.Fit(Xtrain, ytrain); + + // Получаем предсказания для тестовой выборки + var pred := model.Predict(Xtest); + + // Считаем долю правильных ответов + Println('Точность:', Metrics.Accuracy(ytest, pred):0:3); +end. diff --git a/InstallerSamples/MachineLearning/01_GettingStarted/02_FirstRegression_DataFrame.pas b/InstallerSamples/MachineLearning/01_GettingStarted/02_FirstRegression_DataFrame.pas new file mode 100644 index 000000000..4827d3a60 --- /dev/null +++ b/InstallerSamples/MachineLearning/01_GettingStarted/02_FirstRegression_DataFrame.pas @@ -0,0 +1,46 @@ +// Первый пример регрессии. +// +// В этом примере: +// 1) загружаем готовый датасет с ценами на квартиры; +// 2) выбираем признаки и целевую переменную; +// 3) делим данные на обучающую и тестовую выборки; +// 4) обучаем линейную регрессию; +// 5) оцениваем качество модели по метрике R². +uses MLABC; + +begin + // Загружаем учебный датасет с ценами на квартиры в Москве + var ds := Datasets.MoscowHousing; + + // Берём таблицу данных из датасета + var df := ds.Data; + + // Выбираем числовые признаки, по которым будем предсказывать цену + var features := ['rooms', 'area', 'kitchen_area', 'floor', 'floors_total', 'metro_minutes']; + + // Целевая переменная - цена квартиры + var target := 'price'; + + // Преобразуем выбранные признаки в числовую матрицу + var X := df.ToMatrix(features); + + // Преобразуем целевую переменную в числовой вектор + var y := df.ToVector(target); + + // Делим данные на обучающую и тестовую выборки + var (Xtrain, Xtest, ytrain, ytest) := Validation.TrainTestSplit(X, y, 0.2, 42); + + // Создаём модель линейной регрессии + var model := new LinearRegression; + + // Обучаем модель на обучающей выборке + model.Fit(Xtrain, ytrain); + + // Получаем предсказания для тестовой выборки + var pred := model.Predict(Xtest); + + // Считаем, насколько хорошо модель объясняет данные. + // Для простой линейной модели R² около 0.6 и выше здесь уже неплохой результат, + // но более сложные модели могут работать точнее. + Println('R²:', Metrics.R2(ytest, pred):0:3); +end. diff --git a/InstallerSamples/MachineLearning/01_GettingStarted/03_FirstClustering_DataFrame.pas b/InstallerSamples/MachineLearning/01_GettingStarted/03_FirstClustering_DataFrame.pas new file mode 100644 index 000000000..a0f43ada6 --- /dev/null +++ b/InstallerSamples/MachineLearning/01_GettingStarted/03_FirstClustering_DataFrame.pas @@ -0,0 +1,84 @@ +// Первый пример кластеризации. +// +// В этом примере: +// 1) загружаем готовый датасет городов России; +// 2) выбираем признаки для кластеризации; +// 3) обучаем алгоритм KMeans; +// 4) получаем номер кластера для каждого города; +// 5) выводим несколько представителей каждого кластера. +// +// Важно: +// здесь кластеры отражают прежде всего географическую близость городов, +// потому что для кластеризации используются только широта и долгота. +// Население нужно только для выбора самых заметных представителей кластера. +// +// При числе кластеров k = 4 результат можно интерпретировать так: +// • один кластер обычно соответствует Европейской части России; +// • один - Уралу, Поволжью и соседним территориям; +// • один - Сибири; +// • один - Дальнему Востоку и северо-востоку. +uses MLABC; + +begin + // Загружаем учебный датасет с городами России + var ds := Datasets.RussianCities; + + // Берём таблицу данных из датасета + var df := ds.Data; + + // Для первого примера используем только координаты города + var features := ['lat', 'lon']; + + // Преобразуем выбранные признаки в числовую матрицу + var X := df.ToMatrix(features); + + // Создаём модель KMeans и просим разбить города на 4 группы. + // Для первого примера этого достаточно, чтобы увидеть общую структуру данных. + var model := new KMeans(4, seed := 42); + + // Обучаем модель на всех объектах + model.Fit(X); + + // Получаем номер кластера для каждого города + var labels := model.PredictLabels(X); + + // Получаем названия городов + var cities := df.GetStrColumn('city'); + + // Получаем численность населения городов + var populations := df.GetFloatColumn('population'); + + // Выводим, сколько кластеров нашёл алгоритм + Println($'Число кластеров: {model.ClustersCount}'); + + Println; + Println('Примеры городов в каждом кластере (в скобках - тыс. жителей):'); + + // Для каждого кластера выводим три самых крупных города + for var cluster := 0 to model.ClustersCount - 1 do + begin + Println; + Println($'Кластер {cluster + 1}:'); + + var clusterIndices := new List; + + for var i := 0 to labels.Length - 1 do + if labels[i] = cluster then + clusterIndices.Add(i); + + clusterIndices := clusterIndices + .OrderByDescending(i -> populations[i]) + .ToList; + + var shown := 0; + + foreach var i in clusterIndices do + begin + Println($' {cities[i]} ({populations[i]:F0})'); + shown += 1; + + if shown = 4 then + break; + end; + end; +end. diff --git a/InstallerSamples/MachineLearning/01_GettingStarted/04_FirstPipeline_DataFrame.pas b/InstallerSamples/MachineLearning/01_GettingStarted/04_FirstPipeline_DataFrame.pas new file mode 100644 index 000000000..9240365f7 --- /dev/null +++ b/InstallerSamples/MachineLearning/01_GettingStarted/04_FirstPipeline_DataFrame.pas @@ -0,0 +1,59 @@ +// Первый пример работы с pipeline. +// +// В этом примере: +// 1) загружаем готовый датасет Iris; +// 2) делим данные на обучающую и тестовую выборки; +// 3) собираем pipeline из масштабирования и модели; +// 4) обучаем pipeline; +// 5) оцениваем точность и смотрим вероятности классов. +// +// Pipeline удобен тем, что он сам выполняет все шаги по порядку: +// подготовку признаков, обучение модели и предсказание. +uses MLABC; + +begin + // Загружаем учебный датасет Iris + var ds := Datasets.Iris; + + // Берём таблицу данных из датасета + var df := ds.Data; + + // Делим данные на обучающую и тестовую выборки + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 3); + + // Создаём pipeline: + // сначала масштабируем признаки, + // затем обучаем логистическую регрессию + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression + ); + + // Обучаем весь pipeline на обучающей выборке + pipe.Fit(trainDf); + + // Получаем предсказанные классы для тестовой выборки + var pred := pipe.Predict(testDf); + + // Получаем правильные метки классов в кодировке pipeline + var y := pipe.GetEncodedLabels(testDf); + + // Считаем долю правильных ответов + Println($'Точность: {Metrics.Accuracy(y, pred):F3}'); + + // Получаем вероятности классов для объектов тестовой выборки + var proba := pipe.PredictProba(testDf); + + // Получаем названия классов в правильном порядке + var classes := pipe.GetClassLabels; + + Println($'Классы: {classes.JoinToString('', '')}'); + Println('Вероятности для первого объекта:'); + + for var j := 0 to classes.Length - 1 do + Println($'{classes[j]}: {proba[0, j]:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/02_DataFrame/df19.pas b/InstallerSamples/MachineLearning/02_DataFrame/df19.pas index 02de4714b..0bbcfdce2 100644 --- a/InstallerSamples/MachineLearning/02_DataFrame/df19.pas +++ b/InstallerSamples/MachineLearning/02_DataFrame/df19.pas @@ -12,5 +12,5 @@ begin Kat,21,NA '''); - df.PrintPreview(3); + df.Print; end. \ No newline at end of file diff --git a/InstallerSamples/MachineLearning/02_DataFrame/df20.pas b/InstallerSamples/MachineLearning/02_DataFrame/df20.pas index 90a188879..9e56f1453 100644 --- a/InstallerSamples/MachineLearning/02_DataFrame/df20.pas +++ b/InstallerSamples/MachineLearning/02_DataFrame/df20.pas @@ -17,5 +17,5 @@ begin .SortBy('age', descending := True) .GroupBy('age') .Mean('score') - .PrintPreview(3); + .Print; end. \ No newline at end of file diff --git a/InstallerSamples/MachineLearning/02_DataFrame/df21.pas b/InstallerSamples/MachineLearning/02_DataFrame/df21.pas index bbcc4547a..31c0669d8 100644 --- a/InstallerSamples/MachineLearning/02_DataFrame/df21.pas +++ b/InstallerSamples/MachineLearning/02_DataFrame/df21.pas @@ -14,5 +14,5 @@ begin df.Filter(r -> r.IsValid('score')) .SortBy(['age','score'],[True,True]) - .Println; + .Print; end. \ No newline at end of file diff --git a/InstallerSamples/MachineLearning/03_Preprocessing/01_StandardScaler.pas b/InstallerSamples/MachineLearning/03_Preprocessing/01_StandardScaler.pas new file mode 100644 index 000000000..9c07ec908 --- /dev/null +++ b/InstallerSamples/MachineLearning/03_Preprocessing/01_StandardScaler.pas @@ -0,0 +1,31 @@ +// StandardScaler приводит признаки к сопоставимому масштабу: +// после преобразования у каждого столбца среднее близко к 0, +// а стандартное отклонение - к 1. +uses MLABC; + +begin + var X := new Matrix(5, 2); + + // Первый признак имеет маленький масштаб и неравномерный шаг + X[0,0] := 1; X[1,0] := 2; X[2,0] := 3; X[3,0] := 10; X[4,0] := 20; + + // Второй признак имеет гораздо больший масштаб + X[0,1] := 100; X[1,1] := 120; X[2,1] := 150; X[3,1] := 300; X[4,1] := 500; + + Println('До масштабирования:'); + Println($'Средние: {X.ColumnMeans[0]:F3}, {X.ColumnMeans[1]:F3}'); + Println($'Стандартные отклонения: {X.ColumnStd(0):F3}, {X.ColumnStd(1):F3}'); + Println; + + var scaler := new StandardScaler; + scaler.Fit(X); + + var Xscaled := scaler.Transform(X); + + Println('После StandardScaler:'); + Println($'Средние: {Xscaled.ColumnMeans[0]:F3}, {Xscaled.ColumnMeans[1]:F3}'); + Println($'Стандартные отклонения: {Xscaled.ColumnStd(0):F3}, {Xscaled.ColumnStd(1):F3}'); + Println; + Println('Преобразованные значения:'); + Xscaled.Print; +end. diff --git a/InstallerSamples/MachineLearning/03_Preprocessing/02_MinMaxScaler.pas b/InstallerSamples/MachineLearning/03_Preprocessing/02_MinMaxScaler.pas new file mode 100644 index 000000000..7713a3971 --- /dev/null +++ b/InstallerSamples/MachineLearning/03_Preprocessing/02_MinMaxScaler.pas @@ -0,0 +1,32 @@ +// MinMaxScaler переводит значения каждого признака в диапазон [0, 1]. +// +// Это полезно, когда важно сохранить относительный порядок значений, +// но привести признаки к единому диапазону. +uses MLABC; + +begin + var X := new Matrix(5, 2); + + // Первый признак имеет маленький масштаб и неравномерный шаг + X[0,0] := 1; X[1,0] := 2; X[2,0] := 3; X[3,0] := 10; X[4,0] := 20; + + // Второй признак имеет гораздо больший масштаб + X[0,1] := 100; X[1,1] := 120; X[2,1] := 150; X[3,1] := 300; X[4,1] := 500; + + Println('До масштабирования:'); + Println($'Минимумы: {X.ColumnMin(0):F3}, {X.ColumnMin(1):F3}'); + Println($'Максимумы: {X.ColumnMax(0):F3}, {X.ColumnMax(1):F3}'); + Println; + + var scaler := new MinMaxScaler; + scaler.Fit(X); + + var Xscaled := scaler.Transform(X); + + Println('После MinMaxScaler:'); + Println($'Минимумы: {Xscaled.ColumnMin(0):F3}, {Xscaled.ColumnMin(1):F3}'); + Println($'Максимумы: {Xscaled.ColumnMax(0):F3}, {Xscaled.ColumnMax(1):F3}'); + Println; + Println('Преобразованные значения:'); + Xscaled.Print; +end. diff --git a/InstallerSamples/MachineLearning/03_Preprocessing/03_Imputer_LabelEncoder.pas b/InstallerSamples/MachineLearning/03_Preprocessing/03_Imputer_LabelEncoder.pas new file mode 100644 index 000000000..2f5fd8104 --- /dev/null +++ b/InstallerSamples/MachineLearning/03_Preprocessing/03_Imputer_LabelEncoder.pas @@ -0,0 +1,30 @@ +// Пример табличной предобработки: +// сначала заполняем пропуски в числовом столбце, +// затем кодируем категориальный столбец числами. +uses MLABC; + +begin + var df := DataFrame.FromCsvText(''' +city,population,region +Ростов-на-Дону,1142,Юг +Таганрог,NA,Юг +Воронеж,1058,Центр +Курск,452,Центр +Белгород,392,Центр +'''); + + Println('Исходные данные:'); + df.Print; + Println; + + // Заполняем пропуск в числовом столбце средним значением + var imputer := new Imputer(['population']); + df := imputer.FitTransform(df); + + // Кодируем названия регионов числами 0, 1, 2, ... + var encoder := new LabelEncoder('region'); + df := encoder.FitTransform(df); + + Println('После Imputer и LabelEncoder:'); + df.Print; +end. diff --git a/InstallerSamples/MachineLearning/03_Preprocessing/04_OneHotEncoder.pas b/InstallerSamples/MachineLearning/03_Preprocessing/04_OneHotEncoder.pas new file mode 100644 index 000000000..c4499e9f8 --- /dev/null +++ b/InstallerSamples/MachineLearning/03_Preprocessing/04_OneHotEncoder.pas @@ -0,0 +1,24 @@ +// OneHotEncoder заменяет категориальный столбец +// несколькими бинарными столбцами - по одному на каждую категорию. +uses MLABC; + +begin + var df := DataFrame.FromCsvText(''' +city,region +Ростов-на-Дону,Юг +Таганрог,Юг +Воронеж,Центр +Курск,Центр +Хабаровск,Дальний Восток +'''); + + Println('Исходные данные:'); + df.Print; + Println; + + var encoder := new OneHotEncoder('region'); + df := encoder.FitTransform(df); + + Println('После OneHotEncoder:'); + df.Print; +end. diff --git a/InstallerSamples/MachineLearning/03_Preprocessing/05_Normalizer.pas b/InstallerSamples/MachineLearning/03_Preprocessing/05_Normalizer.pas new file mode 100644 index 000000000..4a523a556 --- /dev/null +++ b/InstallerSamples/MachineLearning/03_Preprocessing/05_Normalizer.pas @@ -0,0 +1,22 @@ +// В этом примере показано, +// как Normalizer нормализует строки матрицы. + +uses MLABC; + +begin + var X := new Matrix(3, 2); + X[0, 0] := 3; X[0, 1] := 4; + X[1, 0] := 1; X[1, 1] := 2; + X[2, 0] := 5; X[2, 1] := 12; + + Println('До Normalizer:'); + X.Println; + Println; + + var norm := new Normalizer(NormType.L2); + norm.Fit(X); + var Xn := norm.Transform(X); + + Println('После Normalizer:'); + Xn.Println; +end. diff --git a/InstallerSamples/MachineLearning/03_Preprocessing/06_PCA.pas b/InstallerSamples/MachineLearning/03_Preprocessing/06_PCA.pas new file mode 100644 index 000000000..6494ead24 --- /dev/null +++ b/InstallerSamples/MachineLearning/03_Preprocessing/06_PCA.pas @@ -0,0 +1,24 @@ +// В этом примере PCA уменьшает число признаков +// с двух до одного. + +uses MLABC; + +begin + var X := new Matrix(5, 2); + X[0, 0] := 1; X[0, 1] := 2; + X[1, 0] := 2; X[1, 1] := 4; + X[2, 0] := 3; X[2, 1] := 6; + X[3, 0] := 4; X[3, 1] := 8; + X[4, 0] := 5; X[4, 1] := 10; + + Println('Размер до PCA: ', X.RowCount, 'x', X.ColCount); + + var pca := new PCATransformer(1); + pca.Fit(X); + var X1 := pca.Transform(X); + + Println('Размер после PCA: ', X1.RowCount, 'x', X1.ColCount); + Println; + Println('Преобразованные данные:'); + X1.Println; +end. diff --git a/InstallerSamples/MachineLearning/03_Preprocessing/07_VarianceThreshold.pas b/InstallerSamples/MachineLearning/03_Preprocessing/07_VarianceThreshold.pas new file mode 100644 index 000000000..fb71e815a --- /dev/null +++ b/InstallerSamples/MachineLearning/03_Preprocessing/07_VarianceThreshold.pas @@ -0,0 +1,23 @@ +// В этом примере VarianceThreshold удаляет признак, +// у которого нет разброса значений. + +uses MLABC; + +begin + var X := new Matrix(4, 3); + X[0, 0] := 1; X[0, 1] := 10; X[0, 2] := 5; + X[1, 0] := 2; X[1, 1] := 10; X[1, 2] := 6; + X[2, 0] := 3; X[2, 1] := 10; X[2, 2] := 7; + X[3, 0] := 4; X[3, 1] := 10; X[3, 2] := 8; + + Println('Размер до VarianceThreshold: ', X.RowCount, 'x', X.ColCount); + + var vt := new VarianceThreshold(0.001); + vt.Fit(X); + var X2 := vt.Transform(X); + + Println('Размер после VarianceThreshold: ', X2.RowCount, 'x', X2.ColCount); + Println; + Println('Преобразованные данные:'); + X2.Println; +end. diff --git a/InstallerSamples/MachineLearning/03_Preprocessing/08_SelectKBest.pas b/InstallerSamples/MachineLearning/03_Preprocessing/08_SelectKBest.pas new file mode 100644 index 000000000..6f9077521 --- /dev/null +++ b/InstallerSamples/MachineLearning/03_Preprocessing/08_SelectKBest.pas @@ -0,0 +1,55 @@ +// В этом примере SelectKBest оставляет +// только два самых полезных признака +// и показывает, как это влияет на качество модели. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeClassification( + n := 200, + nFeatures := 6, + nInformative := 2, + nRedundant := 0, + noise := 0.1, + classSep := 1.2, + seed := 42 + ); + + var featureNames := ['f1', 'f2', 'f3', 'f4', 'f5', 'f6']; + + Println('Размер до SelectKBest: ', X.RowCount, 'x', X.ColCount); + + var skb := new SelectKBest(2, FeatureScore.Correlation); + skb.Fit(X, y); + var X2 := skb.Transform(X); + + Println('Размер после SelectKBest: ', X2.RowCount, 'x', X2.ColCount); + Println; + Println('Отобранные признаки:'); + + var selected := skb.SelectedFeatures; + for var i := 0 to selected.Length - 1 do + Println(' ', featureNames[selected[i]]); + + Println; + + var fullScore := Validation.StratifiedCrossValidate( + new LogisticRegression(learningRate := 0.05, epochs := 1000), + X, y, + 5, + ClassificationMetrics.Accuracy, + seed := 42 + ); + + var reducedScore := Validation.StratifiedCrossValidate( + new LogisticRegression(learningRate := 0.05, epochs := 1000), + X2, y, + 5, + ClassificationMetrics.Accuracy, + seed := 42 + ); + + Println('Сравнение качества LogisticRegression:'); + Println($' Все признаки: Accuracy = {fullScore:F3}'); + Println($' После SelectKBest: Accuracy = {reducedScore:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/03_Preprocessing/09_PreprocessingChain.pas b/InstallerSamples/MachineLearning/03_Preprocessing/09_PreprocessingChain.pas new file mode 100644 index 000000000..4cec2a39b --- /dev/null +++ b/InstallerSamples/MachineLearning/03_Preprocessing/09_PreprocessingChain.pas @@ -0,0 +1,30 @@ +// В этом примере показана ручная цепочка предобработки таблицы. +// +// Сначала заполняем пропуск в числовом столбце, +// затем превращаем категориальный столбец в набор бинарных признаков. + +uses MLABC; + +begin + var df := DataFrame.FromCsvText(''' +city,population,region +Ростов-на-Дону,1142,Юг +Таганрог,NA,Юг +Воронеж,1058,Центр +Курск,452,Центр +Хабаровск,616,Дальний Восток +'''); + + Println('Исходные данные:'); + df.Print; + Println; + + var imputer := new Imputer(['population']); + df := imputer.FitTransform(df); + + var encoder := new OneHotEncoder('region'); + df := encoder.FitTransform(df); + + Println('После цепочки Imputer -> OneHotEncoder:'); + df.Print; +end. diff --git a/InstallerSamples/MachineLearning/04_Models/02_ClassificationBasic.pas b/InstallerSamples/MachineLearning/04_Models/02_ClassificationBasic.pas new file mode 100644 index 000000000..0e9eb448a --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/02_ClassificationBasic.pas @@ -0,0 +1,31 @@ +// Базовый пример логистической регрессии на датасете Iris. +// +// Iris - удобный учебный датасет для первого знакомства с классификацией: +// классы в нём разделяются достаточно хорошо, поэтому простая линейная модель +// уже даёт высокую точность. +uses MLABC; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + + var X := df.ToMatrix(ds.Features); + var y := df.EncodeLabels(ds.Target); + + var (Xtrain, Xtest, ytrain, ytest) := + Validation.TrainTestSplit(X, y, testRatio := 0.2, seed := 1); + + var model := new LogisticRegression; + model.Fit(Xtrain, ytrain); + + var pred := model.Predict(Xtest); + var proba := model.PredictProba(Xtest); + + Println($'Точность на тестовой выборке: {Metrics.Accuracy(ytest, pred):F3}'); + Println; + Println('Вероятности для первого объекта:'); + + var classes := model.GetClasses; + for var j := 0 to classes.Length - 1 do + Println($'Класс {classes[j]:F0}: {proba[0, j]:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/03_RegressionCorrelated.pas b/InstallerSamples/MachineLearning/04_Models/03_RegressionCorrelated.pas index fff125515..acf57d3a3 100644 --- a/InstallerSamples/MachineLearning/04_Models/03_RegressionCorrelated.pas +++ b/InstallerSamples/MachineLearning/04_Models/03_RegressionCorrelated.pas @@ -81,10 +81,4 @@ begin Println($'Linear MSE: {Metrics.MSE(yTest, yTestLR),0:F4}'); Println($'Ridge MSE: {Metrics.MSE(yTest, yTestRidge),0:F4}'); Println($'ElasticNet MSE: {Metrics.MSE(yTest, yTestEN),0:F4}'); - - {Println; - Println('Коэффициенты (первые 10):'); - lr.Coefficients.ToArray.Take(10).Println; - ridge.Coefficients.ToArray.Take(10).Println; - en.Coefficients.ToArray.Take(10).Println;} end. \ No newline at end of file diff --git a/InstallerSamples/MachineLearning/04_Models/11_KNN_WithScaling.pas b/InstallerSamples/MachineLearning/04_Models/11_KNN_WithScaling.pas new file mode 100644 index 000000000..567147c13 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/11_KNN_WithScaling.pas @@ -0,0 +1,60 @@ +// KNN чувствителен к масштабу признаков. +// +// В этом примере мы специально делаем часть признаков +// очень большими по масштабу. Без StandardScaler расстояние +// между объектами начинает определяться в основном этими +// признаками, и качество KNN ухудшается. +// +// После масштабирования все признаки снова становятся +// сопоставимыми, и модель работает лучше. +uses MLABC; + +begin + var (X, y) := Datasets.MakeClassification( + n := 500, + nFeatures := 4, + nInformative := 2, + nRedundant := 0, + noise := 0.15, + classSep := 2.2, + flipProb := 0.02, + classBalance := 0.5, + shuffle := True, + seed := 42 + ); + + // Искусственно увеличиваем масштаб двух последних признаков. + // Они начинают слишком сильно влиять на расстояние в KNN. + for var i := 0 to X.RowCount - 1 do + begin + X[i, 2] *= 1000; + X[i, 3] *= 1000; + end; + + var (Xtrain, Xtest, ytrain, ytest) := + Validation.TrainTestSplit(X, y, testRatio := 0.25, seed := 42); + + // --- KNN без масштабирования + var knnRaw := new KNNClassifier(7); + knnRaw.Fit(Xtrain, ytrain); + + var predRaw := knnRaw.Predict(Xtest); + var accRaw := Metrics.Accuracy(ytest, predRaw); + + // --- Масштабируем признаки + var scaler := new StandardScaler; + scaler.Fit(Xtrain); + + var XtrainScaled := scaler.Transform(Xtrain); + var XtestScaled := scaler.Transform(Xtest); + + // --- KNN после масштабирования + var knnScaled := new KNNClassifier(7); + knnScaled.Fit(XtrainScaled, ytrain); + + var predScaled := knnScaled.Predict(XtestScaled); + var accScaled := Metrics.Accuracy(ytest, predScaled); + + Println($'Точность KNN без масштабирования: {accRaw:F3}'); + Println($'Точность KNN после StandardScaler: {accScaled:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/12_KNNRegression.pas b/InstallerSamples/MachineLearning/04_Models/12_KNNRegression.pas new file mode 100644 index 000000000..6287b6d21 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/12_KNNRegression.pas @@ -0,0 +1,51 @@ +// В этом примере сравниваются две регрессионные модели: +// линейная регрессия и KNNRegressor. +// +// Данные специально сделаны нелинейными. Для такой задачи +// LinearRegression обычно слишком проста, а KNNRegressor +// может лучше учитывать локальную форму зависимости. +// +// Если R² отрицателен, это означает, что модель работает +// даже хуже, чем очень простой прогноз по среднему значению. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeRegression( + n := 300, + nFeatures := 2, + nInformative := 2, + noise := 0.15, + coefScale := 1.0, + bias := 0.0, + nonlinearStrength := 4.0, + shuffle := True, + seed := 42 + ); + + var (XTrain, XTest, yTrain, yTest) := Validation.TrainTestSplit( + X, y, testRatio := 0.3, seed := 42 + ); + + var linear := new LinearRegression; + linear.Fit(XTrain, yTrain); + + var knn := new KNNRegressor(7, KNNWeighting.Distance); + knn.Fit(XTrain, yTrain); + + var yPredLinear := linear.Predict(XTest); + var yPredKNN := knn.Predict(XTest); + + var r2Linear := RegressionMetrics.R2(yTest, yPredLinear); + var r2KNN := RegressionMetrics.R2(yTest, yPredKNN); + + Println('Сравнение моделей на нелинейной зависимости'); + Println; + Println($'Линейная регрессия: R² = {r2Linear:F3}'); + Println($'KNN-регрессия: R² = {r2KNN:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- Линейная регрессия здесь слишком проста для задачи.'); + Println('- Отрицательный R² означает, что её прогноз хуже среднего значения.'); + Println('- KNN-регрессия лучше описывает локальную нелинейную зависимость.'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/13_DecisionTreeRegression.pas b/InstallerSamples/MachineLearning/04_Models/13_DecisionTreeRegression.pas new file mode 100644 index 000000000..8ecd7e500 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/13_DecisionTreeRegression.pas @@ -0,0 +1,50 @@ +// В этом примере сравниваются две модели на нелинейной задаче: +// линейная регрессия и дерево решений для регрессии. +// +// Линейная модель умеет строить только одну общую прямую зависимость. +// Дерево решений разбивает пространство признаков на области +// и в каждой области даёт свой локальный прогноз. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeRegression( + n := 300, + nFeatures := 2, + nInformative := 2, + noise := 0.15, + coefScale := 1.0, + bias := 0.0, + nonlinearStrength := 4.0, + shuffle := True, + seed := 42 + ); + + var (XTrain, XTest, yTrain, yTest) := Validation.TrainTestSplit( + X, y, testRatio := 0.3, seed := 42 + ); + + var linear := new LinearRegression; + linear.Fit(XTrain, yTrain); + + // При minSamplesLeaf = 5 узел должен содержать хотя бы 10 объектов, + // чтобы его можно было разделить на два листа не меньше чем по 5 объектов. + var tree := new DecisionTreeRegressor(maxDepth := 6, minSamplesSplit := 10, minSamplesLeaf := 5); + tree.Fit(XTrain, yTrain); + + var yPredLinear := linear.Predict(XTest); + var yPredTree := tree.Predict(XTest); + + var r2Linear := RegressionMetrics.R2(yTest, yPredLinear); + var r2Tree := RegressionMetrics.R2(yTest, yPredTree); + + Println('Сравнение моделей на нелинейной зависимости'); + Println; + Println($'Линейная регрессия: R² = {r2Linear:F3}'); + Println($'Дерево решений: R² = {r2Tree:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- Линейная регрессия пытается описать всю зависимость одной формулой.'); + Println('- Дерево решений умеет подстраиваться под разные участки данных.'); + Println('- Поэтому на нелинейной задаче дерево обычно работает лучше.'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/14_RandomForestRegression.pas b/InstallerSamples/MachineLearning/04_Models/14_RandomForestRegression.pas new file mode 100644 index 000000000..0b400ae67 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/14_RandomForestRegression.pas @@ -0,0 +1,65 @@ +// В этом примере сравниваются три модели на нелинейной задаче: +// линейная регрессия, дерево решений и случайный лес. +// +// Случайный лес усредняет предсказания многих деревьев. +// Благодаря этому он обычно работает устойчивее и точнее, +// чем одно дерево решений, особенно если в данных есть шум +// и лишние признаки. +// +// В этой задаче для леса используется режим AllFeatures. +// Для регрессии это часто даёт более сильный результат, +// чем случайный выбор только части признаков в каждом узле. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeRegression( + n := 300, + nFeatures := 8, + nInformative := 2, + noise := 0.35, + coefScale := 1.0, + bias := 0.0, + nonlinearStrength := 4.0, + shuffle := True, + seed := 42 + ); + + var (XTrain, XTest, yTrain, yTest) := Validation.TrainTestSplit( + X, y, testRatio := 0.3, seed := 42 + ); + + var linear := new LinearRegression; + linear.Fit(XTrain, yTrain); + + var tree := new DecisionTreeRegressor(maxDepth := 8, minSamplesSplit := 10, minSamplesLeaf := 5); + tree.Fit(XTrain, yTrain); + + var forest := new RandomForestRegressor( + nTrees := 150, + maxDepth := 8, + minSamplesSplit := 10, + minSamplesLeaf := 5, + maxFeaturesMode := TMaxFeaturesMode.AllFeatures + ); + forest.Fit(XTrain, yTrain); + + var yPredLinear := linear.Predict(XTest); + var yPredTree := tree.Predict(XTest); + var yPredForest := forest.Predict(XTest); + + var r2Linear := RegressionMetrics.R2(yTest, yPredLinear); + var r2Tree := RegressionMetrics.R2(yTest, yPredTree); + var r2Forest := RegressionMetrics.R2(yTest, yPredForest); + + Println('Сравнение моделей на нелинейной зависимости'); + Println; + Println($'Линейная регрессия: R² = {r2Linear:F3}'); + Println($'Дерево решений: R² = {r2Tree:F3}'); + Println($'Случайный лес: R² = {r2Forest:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- Линейная модель плохо подходит для сложной нелинейной зависимости.'); + Println('- Одно дерево решений уже может уловить форму данных.'); + Println('- Случайный лес усредняет много деревьев и обычно даёт более устойчивый прогноз.'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/15_GradientBoostingRegression.pas b/InstallerSamples/MachineLearning/04_Models/15_GradientBoostingRegression.pas new file mode 100644 index 000000000..646a92eb5 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/15_GradientBoostingRegression.pas @@ -0,0 +1,62 @@ +// В этом примере сравниваются три модели на нелинейной задаче: +// линейная регрессия, дерево решений и градиентный бустинг. +// +// Градиентный бустинг строит много небольших деревьев последовательно. +// Каждое следующее дерево старается исправить ошибки предыдущих, +// поэтому на сложных зависимостях такая модель часто работает очень хорошо. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeRegression( + n := 300, + nFeatures := 8, + nInformative := 2, + noise := 0.35, + coefScale := 1.0, + bias := 0.0, + nonlinearStrength := 4.0, + shuffle := True, + seed := 42 + ); + + var (XTrain, XTest, yTrain, yTest) := Validation.TrainTestSplit( + X, y, testRatio := 0.3, seed := 42 + ); + + var linear := new LinearRegression; + linear.Fit(XTrain, yTrain); + + var tree := new DecisionTreeRegressor(maxDepth := 6, minSamplesSplit := 10, minSamplesLeaf := 5); + tree.Fit(XTrain, yTrain); + + var boosting := new GradientBoostingRegressor( + nEstimators := 120, + learningRate := 0.1, + maxDepth := 3, + minSamplesSplit := 10, + minSamplesLeaf := 5, + subsample := 1.0, + seed := 42 + ); + boosting.Fit(XTrain, yTrain); + + var yPredLinear := linear.Predict(XTest); + var yPredTree := tree.Predict(XTest); + var yPredBoosting := boosting.Predict(XTest); + + var r2Linear := RegressionMetrics.R2(yTest, yPredLinear); + var r2Tree := RegressionMetrics.R2(yTest, yPredTree); + var r2Boosting := RegressionMetrics.R2(yTest, yPredBoosting); + + Println('Сравнение моделей на нелинейной зависимости'); + Println; + Println($'Линейная регрессия: R² = {r2Linear:F3}'); + Println($'Дерево решений: R² = {r2Tree:F3}'); + Println($'Градиентный бустинг: R² = {r2Boosting:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- Линейная модель плохо подходит для сложной нелинейной зависимости.'); + Println('- Одно дерево решений уже может уловить форму данных.'); + Println('- Градиентный бустинг последовательно исправляет ошибки и часто даёт самый точный прогноз.'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/16_RandomForestClassification.pas b/InstallerSamples/MachineLearning/04_Models/16_RandomForestClassification.pas new file mode 100644 index 000000000..c01fa3c99 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/16_RandomForestClassification.pas @@ -0,0 +1,49 @@ +// В этом примере сравниваются две модели на нелинейной задаче классификации: +// LogisticRegression и RandomForestClassifier. +// +// Логистическая регрессия строит линейную границу между классами. +// Поэтому на вложенных окружностях она принципиально ограничена. +// +// Случайный лес умеет учитывать более сложную структуру данных +// и обычно лучше справляется с нелинейной геометрией. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeCircles( + n := 450, + noise := 0.18, + factor := 0.5, + classBalance := 0.5, + flipProb := 0.04, + scale := 3.0, + seed := 42 + ); + + var (XTrain, XTest, yTrain, yTest) := Validation.TrainTestSplit(X, y, 0.25, seed := 42); + + var logreg := new LogisticRegression(learningRate := 0.05, epochs := 1000); + logreg.Fit(XTrain, yTrain); + var logregPred := logreg.Predict(XTest); + var logregAcc := ClassificationMetrics.Accuracy(yTest, logregPred); + + var forest := new RandomForestClassifier( + nTrees := 150, + maxDepth := 8, + minSamplesSplit := 6, + minSamplesLeaf := 3, + seed := 42 + ); + forest.Fit(XTrain, yTrain); + var forestPred := forest.Predict(XTest); + var forestAcc := ClassificationMetrics.Accuracy(yTest, forestPred); + + Println('Сравнение моделей на нелинейной задаче классификации'); + Println; + Println($'Логистическая регрессия: Accuracy = {logregAcc:F3}'); + Println($'Случайный лес: Accuracy = {forestAcc:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- Логистическая регрессия строит только линейную границу.'); + Println('- Случайный лес лучше улавливает нелинейную форму классов.'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/17_GradientBoostingClassification.pas b/InstallerSamples/MachineLearning/04_Models/17_GradientBoostingClassification.pas new file mode 100644 index 000000000..bb88a9488 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/17_GradientBoostingClassification.pas @@ -0,0 +1,44 @@ +// В этом примере сравниваются две модели на нелинейной задаче классификации: +// LogisticRegression и GradientBoostingClassifier. +// +// Логистическая регрессия строит линейную границу между классами. +// Градиентный бустинг последовательно улучшает ансамбль деревьев +// и хорошо справляется со сложной формой границы. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeMoons( + n := 450, + noise := 0.20, + seed := 42 + ); + + var (XTrain, XTest, yTrain, yTest) := Validation.TrainTestSplit(X, y, 0.25, seed := 42); + + var logreg := new LogisticRegression(learningRate := 0.05, epochs := 1000); + logreg.Fit(XTrain, yTrain); + var logregPred := logreg.Predict(XTest); + var logregAcc := ClassificationMetrics.Accuracy(yTest, logregPred); + + var gb := new GradientBoostingClassifier( + nEstimators := 120, + learningRate := 0.1, + maxDepth := 3, + minSamplesSplit := 6, + minSamplesLeaf := 3, + seed := 42 + ); + gb.Fit(XTrain, yTrain); + var gbPred := gb.Predict(XTest); + var gbAcc := ClassificationMetrics.Accuracy(yTest, gbPred); + + Println('Сравнение моделей на нелинейной задаче классификации'); + Println; + Println($'Логистическая регрессия: Accuracy = {logregAcc:F3}'); + Println($'Градиентный бустинг: Accuracy = {gbAcc:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- Логистическая регрессия строит только линейную границу.'); + Println('- Градиентный бустинг лучше улавливает сложную форму классов.'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/18_DecisionTreeClassification.pas b/InstallerSamples/MachineLearning/04_Models/18_DecisionTreeClassification.pas new file mode 100644 index 000000000..148c719b3 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/18_DecisionTreeClassification.pas @@ -0,0 +1,32 @@ +// В этом примере сравниваются две модели на нелинейной задаче классификации: +// LogisticRegression и DecisionTreeClassifier. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeMoons( + n := 450, + noise := 0.20, + seed := 42 + ); + + var (XTrain, XTest, yTrain, yTest) := Validation.TrainTestSplit(X, y, 0.25, seed := 42); + + var logreg := new LogisticRegression(learningRate := 0.05, epochs := 1000); + logreg.Fit(XTrain, yTrain); + var logregAcc := ClassificationMetrics.Accuracy(yTest, logreg.Predict(XTest)); + + var tree := new DecisionTreeClassifier( + maxDepth := 5, + minSamplesSplit := 6, + minSamplesLeaf := 3, + seed := 42 + ); + tree.Fit(XTrain, yTrain); + var treeAcc := ClassificationMetrics.Accuracy(yTest, tree.Predict(XTest)); + + Println('Сравнение моделей на нелинейной задаче классификации'); + Println; + Println($'Логистическая регрессия: Accuracy = {logregAcc:F3}'); + Println($'Дерево решений: Accuracy = {treeAcc:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/19_KNNClassification.pas b/InstallerSamples/MachineLearning/04_Models/19_KNNClassification.pas new file mode 100644 index 000000000..12bead154 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/19_KNNClassification.pas @@ -0,0 +1,27 @@ +// В этом примере сравниваются две модели на нелинейной задаче классификации: +// LogisticRegression и KNNClassifier. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeMoons( + n := 450, + noise := 0.20, + seed := 42 + ); + + var (XTrain, XTest, yTrain, yTest) := Validation.TrainTestSplit(X, y, 0.25, seed := 42); + + var logreg := new LogisticRegression(learningRate := 0.05, epochs := 1000); + logreg.Fit(XTrain, yTrain); + var logregAcc := ClassificationMetrics.Accuracy(yTest, logreg.Predict(XTest)); + + var knn := new KNNClassifier(7, KNNWeighting.Distance); + knn.Fit(XTrain, yTrain); + var knnAcc := ClassificationMetrics.Accuracy(yTest, knn.Predict(XTest)); + + Println('Сравнение моделей на нелинейной задаче классификации'); + Println; + Println($'Логистическая регрессия: Accuracy = {logregAcc:F3}'); + Println($'KNNClassifier: Accuracy = {knnAcc:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/20_LogisticRegression_Probabilities.pas b/InstallerSamples/MachineLearning/04_Models/20_LogisticRegression_Probabilities.pas new file mode 100644 index 000000000..42c12c779 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/20_LogisticRegression_Probabilities.pas @@ -0,0 +1,36 @@ +// В этом примере LogisticRegression возвращает не только класс, +// но и вероятности принадлежности к каждому классу. + +uses MLABC; + +begin + var ds := Datasets.Iris; + var (trainDs, testDs) := ds.StratifiedTrainTestSplit(testRatio := 0.2, seed := 42); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression(learningRate := 0.01, epochs := 2000) + ); + + pipe.Fit(trainDs.Data); + + var pred := pipe.Predict(testDs.Data); + var yTest := pipe.GetEncodedLabels(testDs.Data); + var acc := ClassificationMetrics.Accuracy(yTest, pred); + + var proba := pipe.PredictProba(testDs.Data); + var classes := pipe.GetClassLabels; + + Println('Логистическая регрессия с вероятностями классов'); + Println; + Println($'Точность: {acc:F3}'); + Println; + Println('Вероятности классов для первого объекта тестовой выборки:'); + + for var j := 0 to classes.Length - 1 do + Println($' {classes[j]}: {proba[0, j]:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/21_RandomForest_Probabilities.pas b/InstallerSamples/MachineLearning/04_Models/21_RandomForest_Probabilities.pas new file mode 100644 index 000000000..f21863fd8 --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/21_RandomForest_Probabilities.pas @@ -0,0 +1,35 @@ +// В этом примере RandomForestClassifier возвращает +// вероятности принадлежности к каждому классу. + +uses MLABC; + +begin + var ds := Datasets.Iris; + var (trainDs, testDs) := ds.StratifiedTrainTestSplit(testRatio := 0.2, seed := 42); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new RandomForestClassifier(nTrees := 100, maxDepth := 6, seed := 42) + ); + + pipe.Fit(trainDs.Data); + + var pred := pipe.Predict(testDs.Data); + var yTest := pipe.GetEncodedLabels(testDs.Data); + var acc := ClassificationMetrics.Accuracy(yTest, pred); + + var proba := pipe.PredictProba(testDs.Data); + var classes := pipe.GetClassLabels; + + Println('Случайный лес с вероятностями классов'); + Println; + Println($'Точность: {acc:F3}'); + Println; + Println('Вероятности классов для первого объекта тестовой выборки:'); + + for var j := 0 to classes.Length - 1 do + Println($' {classes[j]}: {proba[0, j]:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/04_Models/22_GradientBoosting_Probabilities.pas b/InstallerSamples/MachineLearning/04_Models/22_GradientBoosting_Probabilities.pas new file mode 100644 index 000000000..e2f2b6c2f --- /dev/null +++ b/InstallerSamples/MachineLearning/04_Models/22_GradientBoosting_Probabilities.pas @@ -0,0 +1,42 @@ +// В этом примере GradientBoostingClassifier возвращает +// вероятности принадлежности к каждому классу. + +uses MLABC; + +begin + var ds := Datasets.Iris; + var (trainDs, testDs) := ds.StratifiedTrainTestSplit(testRatio := 0.2, seed := 42); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new GradientBoostingClassifier( + nEstimators := 80, + learningRate := 0.1, + maxDepth := 3, + minSamplesSplit := 6, + minSamplesLeaf := 3, + seed := 42 + ) + ); + + pipe.Fit(trainDs.Data); + + var pred := pipe.Predict(testDs.Data); + var yTest := pipe.GetEncodedLabels(testDs.Data); + var acc := ClassificationMetrics.Accuracy(yTest, pred); + + var proba := pipe.PredictProba(testDs.Data); + var classes := pipe.GetClassLabels; + + Println('Градиентный бустинг с вероятностями классов'); + Println; + Println($'Точность: {acc:F3}'); + Println; + Println('Вероятности классов для первого объекта тестовой выборки:'); + + for var j := 0 to classes.Length - 1 do + Println($' {classes[j]}: {proba[0, j]:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/05_Validation/01_TrainTestSplit_CrossValidate.pas b/InstallerSamples/MachineLearning/05_Validation/01_TrainTestSplit_CrossValidate.pas new file mode 100644 index 000000000..da25dc558 --- /dev/null +++ b/InstallerSamples/MachineLearning/05_Validation/01_TrainTestSplit_CrossValidate.pas @@ -0,0 +1,47 @@ +// В этом примере одна и та же модель оценивается двумя способами: +// 1. по одной обучающей и тестовой выборке; +// 2. по стратифицированной k-fold кросс-валидации. +// +// Один train/test split даёт быструю, но более случайную оценку. +// Кросс-валидация обычно надёжнее, потому что усредняет результат +// по нескольким разбиениям данных. + +uses MLABC; + +begin + var ds := Datasets.Iris; + var (trainDs, testDs) := ds.StratifiedTrainTestSplit(testRatio := 0.2, seed := 3); + + var XTrain := trainDs.Data.ToMatrix(trainDs.Features); + var yTrain := trainDs.Data.EncodeLabels(trainDs.Target); + + var XTest := testDs.Data.ToMatrix(testDs.Features); + var yTest := testDs.Data.EncodeLabels(testDs.Target); + + var X := ds.Data.ToMatrix(ds.Features); + var y := ds.Data.EncodeLabels(ds.Target); + + var model := new LogisticRegression; + model.Fit(XTrain, yTrain); + + var yPred := model.Predict(XTest); + var testAccuracy := Metrics.Accuracy(yTest, yPred); + + var cvAccuracy := Validation.StratifiedCrossValidate( + new LogisticRegression, + X, y, + 5, + Metrics.Accuracy, + seed := 1 + ); + + Println('Оценка одной и той же модели двумя способами'); + Println; + Println($'Точность на стратифицированной тестовой выборке: {testAccuracy:F3}'); + Println($'Средняя точность по кросс-валидации: {cvAccuracy:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- Одна тестовая выборка даёт быстрый, но более случайный результат.'); + Println('- Кросс-валидация усредняет качество по нескольким разбиениям.'); + Println('- Поэтому её часто используют для более надёжной оценки модели.'); +end. diff --git a/InstallerSamples/MachineLearning/05_Validation/02_CrossValidate_Regression.pas b/InstallerSamples/MachineLearning/05_Validation/02_CrossValidate_Regression.pas new file mode 100644 index 000000000..7fffefca5 --- /dev/null +++ b/InstallerSamples/MachineLearning/05_Validation/02_CrossValidate_Regression.pas @@ -0,0 +1,46 @@ +// В этом примере линейная регрессия оценивается двумя способами: +// 1. по одной обучающей и тестовой выборке; +// 2. по k-fold кросс-валидации. +// +// Для регрессии в качестве метрики используется R²: +// чем ближе значение к 1, тем лучше модель объясняет данные. + +uses MLABC; + +begin + var ds := Datasets.MoscowHousing; + var df := ds.Data; + + var features := ['rooms', 'area', 'kitchen_area', 'floor', 'floors_total', 'metro_minutes']; + var target := 'price'; + + var X := df.ToMatrix(features); + var y := df.ToVector(target); + + var (XTrain, XTest, yTrain, yTest) := + Validation.TrainTestSplit(X, y, testRatio := 0.2, seed := 1); + + var model := new LinearRegression; + model.Fit(XTrain, yTrain); + + var yPred := model.Predict(XTest); + var testR2 := RegressionMetrics.R2(yTest, yPred); + + var cvR2 := Validation.CrossValidate( + new LinearRegression, + X, y, + 5, + RegressionMetrics.R2, + 1 + ); + + Println('Оценка линейной регрессии двумя способами'); + Println; + Println($'R² на тестовой выборке: {testR2:F3}'); + Println($'Средний R² по кросс-валидации: {cvR2:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- Одна тестовая выборка даёт быструю оценку качества.'); + Println('- Кросс-валидация усредняет результат по нескольким разбиениям.'); + Println('- Если значения близки, модель ведёт себя достаточно стабильно.'); +end. diff --git a/InstallerSamples/MachineLearning/05_Validation/03_GridSearch_Ridge.pas b/InstallerSamples/MachineLearning/05_Validation/03_GridSearch_Ridge.pas new file mode 100644 index 000000000..d3d5590fc --- /dev/null +++ b/InstallerSamples/MachineLearning/05_Validation/03_GridSearch_Ridge.pas @@ -0,0 +1,81 @@ +// В этом примере подбирается параметр регуляризации для RidgeRegression. +// +// Используются линейные данные с сильно коррелированными признаками. +// В такой ситуации Ridge часто работает устойчивее обычной линейной регрессии. +// +// Для подбора используется метрика MSE: +// чем меньше значение, тем лучше модель. + +uses MLABC; +uses System; + +function Normal(rnd: Random): real; +begin + var u1 := rnd.NextDouble; + var u2 := rnd.NextDouble; + if u1 < 1e-12 then + u1 := 1e-12; + Result := Sqrt(-2 * Ln(u1)) * Cos(2 * Pi * u2); +end; + +begin + var (X, y) := Datasets.MakeRegression( + n := 100, + nFeatures := 20, + nInformative := 3, + noise := 2.0, + coefScale := 1.0, + bias := 0.0, + nonlinearStrength := 0.0, + shuffle := True, + seed := 42 + ); + + // Несколько признаков делаем почти копиями первого, + // чтобы усилить корреляцию и сделать Ridge полезным. + var rnd := new Random(1); + for var i := 0 to X.RowCount - 1 do + begin + X[i, 1] := X[i, 0] + 0.01 * Normal(rnd); + X[i, 2] := X[i, 0] - 0.01 * Normal(rnd); + end; + + var lambdaValues := [0.0, 1.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0]; + + Println('Подбор параметра для RidgeRegression'); + Println; + Println('Проверяемые значения lambda:'); + + foreach var lambda in lambdaValues do + begin + var score := Validation.CrossValidate( + new RidgeRegression(lambda), + X, y, + 5, + RegressionMetrics.MSE, + 1 + ); + Println($' lambda = {lambda,6:F2} -> средняя MSE = {score:F3}'); + end; + + Println; + + var (bestLambda, bestScore, bestModel) := GridSearch.Search( + lambda -> new RidgeRegression(lambda), + lambdaValues, + X, y, + 5, + RegressionMetrics.MSE, + maximize := False, + stratified := False, + seed := 1 + ); + + Println($'Лучшее значение lambda: {bestLambda:F2}'); + Println($'Лучшая средняя MSE: {bestScore:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- GridSearch перебирает несколько значений параметра lambda.'); + Println('- Для каждого значения качество оценивается по кросс-валидации.'); + Println('- Лучшим считается параметр с наименьшей средней ошибкой MSE.'); +end. diff --git a/InstallerSamples/MachineLearning/05_Validation/04_GridSearch_KNN.pas b/InstallerSamples/MachineLearning/05_Validation/04_GridSearch_KNN.pas new file mode 100644 index 000000000..1ac9589f6 --- /dev/null +++ b/InstallerSamples/MachineLearning/05_Validation/04_GridSearch_KNN.pas @@ -0,0 +1,50 @@ +// В этом примере подбирается число соседей k +// для KNNClassifier с помощью GridSearch. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeMoons( + n := 400, + noise := 0.18, + seed := 42 + ); + + var scaler := new StandardScaler; + scaler.Fit(X); + X := scaler.Transform(X); + + var kValues := [1, 3, 5, 7, 9, 11, 15]; + + Println('Подбор параметра k для KNNClassifier'); + Println; + Println('Проверяемые значения k:'); + + foreach var k in kValues do + begin + var score := Validation.StratifiedCrossValidate( + new KNNClassifier(k, KNNWeighting.Distance), + X, y, + 5, + ClassificationMetrics.Accuracy, + seed := 42 + ); + + Println($' k = {k,2} -> средняя Accuracy = {score:F3}'); + end; + + var (bestK, bestScore, bestModel) := GridSearch.Search( + k -> new KNNClassifier(k, KNNWeighting.Distance), + kValues, + X, y, + 5, + ClassificationMetrics.Accuracy, + maximize := True, + stratified := True, + seed := 42 + ); + + Println; + Println($'Лучшее значение k: {bestK}'); + Println($'Лучшая средняя Accuracy: {bestScore:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/05_Validation/05_GridSearch_LogisticRegression.exe b/InstallerSamples/MachineLearning/05_Validation/05_GridSearch_LogisticRegression.exe new file mode 100644 index 0000000000000000000000000000000000000000..6f6ef6337c7474abb0cebd95acf510781eefda11 GIT binary patch literal 112640 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.+V;)yK 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 средняя Accuracy = {score:F3}'); + end; + + Println; + + var (bestLambda, bestScore, bestModel) := GridSearch.Search( + lambda -> new LogisticRegression(lambda := lambda, learningRate := 0.05, epochs := 1000), + lambdaValues, + X, y, + 5, + ClassificationMetrics.Accuracy, + maximize := True, + stratified := True, + seed := 42 + ); + + Println($'Лучшее значение lambda: {bestLambda:F2}'); + Println($'Лучшая средняя Accuracy: {bestScore:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/05_Validation/06_ModelComparison_Classification.pas b/InstallerSamples/MachineLearning/05_Validation/06_ModelComparison_Classification.pas new file mode 100644 index 000000000..09f4ce371 --- /dev/null +++ b/InstallerSamples/MachineLearning/05_Validation/06_ModelComparison_Classification.pas @@ -0,0 +1,52 @@ +// В этом примере сравниваются несколько моделей +// на одной задаче классификации. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeMoons( + n := 400, + noise := 0.18, + seed := 42 + ); + + Println('Сравнение моделей классификации по кросс-валидации'); + Println; + + var logregScore := Validation.StratifiedCrossValidate( + new LogisticRegression(learningRate := 0.05, epochs := 1000), + X, y, + 5, + ClassificationMetrics.Accuracy, + seed := 42 + ); + + var treeScore := Validation.StratifiedCrossValidate( + new DecisionTreeClassifier(maxDepth := 5, minSamplesSplit := 6, minSamplesLeaf := 3, seed := 42), + X, y, + 5, + ClassificationMetrics.Accuracy, + seed := 42 + ); + + var forestScore := Validation.StratifiedCrossValidate( + new RandomForestClassifier(nTrees := 100, maxDepth := 6, minSamplesSplit := 6, minSamplesLeaf := 3, seed := 42), + X, y, + 5, + ClassificationMetrics.Accuracy, + seed := 42 + ); + + var gbScore := Validation.StratifiedCrossValidate( + new GradientBoostingClassifier(nEstimators := 80, learningRate := 0.1, maxDepth := 3, minSamplesSplit := 6, minSamplesLeaf := 3, seed := 42), + X, y, + 5, + ClassificationMetrics.Accuracy, + seed := 42 + ); + + Println($'LogisticRegression: Accuracy = {logregScore:F3}'); + Println($'DecisionTreeClassifier: Accuracy = {treeScore:F3}'); + Println($'RandomForestClassifier: Accuracy = {forestScore:F3}'); + Println($'GradientBoostingClassifier: Accuracy = {gbScore:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/05_Validation/07_ModelComparison_Regression.pas b/InstallerSamples/MachineLearning/05_Validation/07_ModelComparison_Regression.pas new file mode 100644 index 000000000..15dc822cf --- /dev/null +++ b/InstallerSamples/MachineLearning/05_Validation/07_ModelComparison_Regression.pas @@ -0,0 +1,55 @@ +// В этом примере сравниваются несколько моделей +// на одной задаче регрессии. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeRegression( + n := 400, + nFeatures := 8, + nInformative := 3, + noise := 0.35, + nonlinearStrength := 1.5, + seed := 42 + ); + + Println('Сравнение моделей регрессии по кросс-валидации'); + Println; + + var linScore := Validation.CrossValidate( + new LinearRegression, + X, y, + 5, + RegressionMetrics.R2, + seed := 42 + ); + + var treeScore := Validation.CrossValidate( + new DecisionTreeRegressor(maxDepth := 6, minSamplesSplit := 10, minSamplesLeaf := 5, seed := 42), + X, y, + 5, + RegressionMetrics.R2, + seed := 42 + ); + + var forestScore := Validation.CrossValidate( + new RandomForestRegressor(nTrees := 120, maxDepth := 8, minSamplesSplit := 10, minSamplesLeaf := 5, seed := 42), + X, y, + 5, + RegressionMetrics.R2, + seed := 42 + ); + + var gbScore := Validation.CrossValidate( + new GradientBoostingRegressor(nEstimators := 100, learningRate := 0.1, maxDepth := 3, minSamplesSplit := 10, minSamplesLeaf := 5, seed := 42), + X, y, + 5, + RegressionMetrics.R2, + seed := 42 + ); + + Println($'LinearRegression: R² = {linScore:F3}'); + Println($'DecisionTreeRegressor: R² = {treeScore:F3}'); + Println($'RandomForestRegressor: R² = {forestScore:F3}'); + Println($'GradientBoostingRegressor: R² = {gbScore:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/06_Pipelines/01_DataPipeline_Classification.pas b/InstallerSamples/MachineLearning/06_Pipelines/01_DataPipeline_Classification.pas new file mode 100644 index 000000000..e8bd15e89 --- /dev/null +++ b/InstallerSamples/MachineLearning/06_Pipelines/01_DataPipeline_Classification.pas @@ -0,0 +1,43 @@ +// Строго типизированный pipeline для классификации на DataFrame. +// +// Pipeline сам: +// • проверяет схему таблицы; +// • применяет preprocessing к признакам; +// • кодирует целевую переменную внутри себя; +// • передаёт матрицу признаков в модель. +// +// Здесь показан канонический сценарий: +// Iris -> Train/Test Split -> StandardScaler -> LogisticRegression. +uses MLABC; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 3); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression + ); + + pipe.Fit(trainDf); + + var pred := pipe.Predict(testDf); + var y := pipe.GetEncodedLabels(testDf); + + Println('Точность:', Metrics.Accuracy(y, pred):0:3); + + var proba := pipe.PredictProba(testDf); + var classes := pipe.GetClassLabels; + + Println('Классы:', classes.JoinToString(', ')); + Println('Вероятности для первого объекта:'); + + for var j := 0 to classes.Length - 1 do + Println(classes[j], ': ', proba[0, j]:0:3); +end. diff --git a/InstallerSamples/MachineLearning/06_Pipelines/02_DataPipeline_Regression.pas b/InstallerSamples/MachineLearning/06_Pipelines/02_DataPipeline_Regression.pas new file mode 100644 index 000000000..ed36a3966 --- /dev/null +++ b/InstallerSamples/MachineLearning/06_Pipelines/02_DataPipeline_Regression.pas @@ -0,0 +1,35 @@ +// Pipeline для регрессии на DataFrame с категориальным признаком. +// +// В этом примере: +// • числовые признаки подаются в модель напрямую; +// • категориальный признак renovation кодируется через OneHotEncoder; +// • затем все признаки масштабируются; +// • после этого обучается линейная регрессия. +uses MLABC; + +begin + var ds := Datasets.MoscowHousing; + var df := ds.Data; + + var features := ['rooms', 'area', 'kitchen_area', 'floor', 'floors_total', 'metro_minutes', 'renovation']; + var target := 'price'; + + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 42); + + var pipe := + DataPipeline.Build( + TaskKind.tkRegression, + target, + features, + new OneHotEncoder('renovation'), + new StandardScaler, + new LinearRegression + ); + + pipe.Fit(trainDf); + + var pred := pipe.Predict(testDf); + var y := testDf.ToVector(target); + + Println($'R²: {Metrics.R2(y, pred):F3}'); +end. diff --git a/InstallerSamples/MachineLearning/06_Pipelines/02_DataPipeline_Regression_Categorical.exe b/InstallerSamples/MachineLearning/06_Pipelines/02_DataPipeline_Regression_Categorical.exe new file mode 100644 index 0000000000000000000000000000000000000000..cc132009415a0850d6c05d08e479c60729084b2e GIT binary patch literal 153088 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 literal 0 HcmV?d00001 diff --git a/InstallerSamples/MachineLearning/06_Pipelines/03_UDataPipeline_KMeans.pas b/InstallerSamples/MachineLearning/06_Pipelines/03_UDataPipeline_KMeans.pas new file mode 100644 index 000000000..00c6f7d86 --- /dev/null +++ b/InstallerSamples/MachineLearning/06_Pipelines/03_UDataPipeline_KMeans.pas @@ -0,0 +1,48 @@ +// В этом примере показан unsupervised pipeline на DataFrame. +// +// Мы сначала готовим признаки в таблице, +// а затем передаём их в UDataPipeline: +// StandardScaler -> KMeans. +// +// Такой конвейер удобен, когда нужно явно сохранить +// шаги подготовки данных и кластеризации в одном месте. + +uses MLABC; + +begin + var ds := Datasets.RussianCities; + var df := ds.Data; + + // Добавляем два полезных признака для кластеризации городов. + df := df.WithColumnFloat('density', row -> row.Float('population') / row.Float('area')); + df := df.WithColumnFloat('log_population', row -> Ln(row.Float('population'))); + + var features := ['log_population', 'density']; + + var pipe := + UDataPipeline.Build( + features, + new StandardScaler, + new KMeans(3, seed := 42) + ); + + var labels := pipe.FitPredict(df); + df.AddIntColumn('cluster', labels, nil); + + Println('Кластеризация городов с помощью UDataPipeline'); + Println; + Println('Используемые признаки: log_population, density'); + Println('Число найденных кластеров: 3'); + + for var cluster := 0 to 2 do + begin + Println; + Println($'Кластер {cluster + 1}:'); + + df.Filter(row -> row.Int('cluster') = cluster) + .SortBy('population', descending := True) + .Select(['city', 'population', 'density']) + .Head(5) + .Print; + end; +end. diff --git a/InstallerSamples/MachineLearning/06_Pipelines/04_UMatrixPipeline_KMeans.pas b/InstallerSamples/MachineLearning/06_Pipelines/04_UMatrixPipeline_KMeans.pas new file mode 100644 index 000000000..021df6ad8 --- /dev/null +++ b/InstallerSamples/MachineLearning/06_Pipelines/04_UMatrixPipeline_KMeans.pas @@ -0,0 +1,58 @@ +// В этом примере показан unsupervised pipeline для матрицы признаков. +// +// Данные сначала извлекаются из DataFrame в матрицу, +// а затем pipeline выполняет масштабирование и кластеризацию. +// +// Это удобно, когда табличная подготовка уже сделана заранее +// и хочется работать сразу с числовой матрицей. + +uses MLABC; + +begin + var ds := Datasets.RussianCities; + var df := ds.Data; + + // Добавляем два полезных числовых признака для кластеризации городов. + df := df.WithColumnFloat('density', row -> row.Float('population') / row.Float('area')); + df := df.WithColumnFloat('log_population', row -> Ln(row.Float('population'))); + + // Для кластеризации берем два числовых признака: + // логарифм населения и плотность населения. + var features := ['log_population', 'density']; + var X := df.ToMatrix(features); + + // Строим matrix pipeline: + // сначала StandardScaler, затем KMeans. + var pipe := UMatrixPipeline.Build( + new StandardScaler, + new KMeans(3, seed := 42) + ); + + // Обучаем pipeline и получаем метки кластеров. + pipe.Fit(X); + var labels := LabelsToInts(pipe.Predict(X)); + + // Добавляем метки кластеров в таблицу, + // чтобы потом удобно вывести представителей каждого кластера. + df.AddIntColumn('cluster', labels, nil); + + Println('Кластеризация городов с помощью UMatrixPipeline'); + Println; + Println('Используются признаки log_population и density.'); + Println('Для каждого кластера выводятся самые крупные города.'); + Println; + + for var cluster := 0 to 2 do + begin + Println($'Кластер {cluster + 1}:'); + + var clusterDf := df + .Filter(r -> r.Int('cluster') = cluster) + .SortBy('population', descending := True) + .Select(['city', 'population', 'density']) + .Head(5); + + clusterDf.Print; + Println; + end; +end. diff --git a/InstallerSamples/MachineLearning/06_Pipelines/05_MatrixPipeline_Classification.exe b/InstallerSamples/MachineLearning/06_Pipelines/05_MatrixPipeline_Classification.exe new file mode 100644 index 0000000000000000000000000000000000000000..bbfcd2fb1b3f78cfd4428af9bf1467f4b57c28de GIT binary patch literal 137216 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%0DfP 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 LogisticRegression. + var pipe := MatrixPipeline.Build( + new StandardScaler, + new LogisticRegression(learningRate := 0.05, epochs := 1000) + ); + + // Обучаем pipeline и проверяем качество на тестовой выборке. + var XTrain := trainDs.Data.ToMatrix(trainDs.Features); + var classes: array of string; + var yTrain := trainDs.Data.EncodeLabels(trainDs.Target, classes); + + var XTest := testDs.Data.ToMatrix(testDs.Features); + var yTest := testDs.Data.TransformLabels(testDs.Target, classes); + + pipe.Fit(XTrain, yTrain); + var pred := pipe.Predict(XTest); + var acc := ClassificationMetrics.Accuracy(yTest, pred); + + Println('Классификация Iris с помощью MatrixPipeline'); + Println; + Println($'Точность на тестовой выборке: {acc:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/06_Pipelines/06_MatrixPipeline_Regression.exe b/InstallerSamples/MachineLearning/06_Pipelines/06_MatrixPipeline_Regression.exe new file mode 100644 index 0000000000000000000000000000000000000000..73b6cd50ce47d52412fe42a8fb93f52868379a01 GIT binary patch literal 129024 zcmeFa3A|lZbw7UIx%0Vi$bRR&dtb))LP9v?K@#Ts!jJ?AV3W( z&OK)jYp=ETT5GR8pS$q^C*wFy7Jtt@=QvN|m;YAEbLz)#ME4GVyw~|i>Fe{K>^kS` z^VeO!wK1~29$r_!^2U*?ue|A|aL341TSn?TZyMQp)5z)PuN}EDyk^Uio*r+Z1%1J4 z$2q6Vb$&YIU$#c2ecy4r%3Wp0`KK<&aW!;aKVC-gKEi;N`i@uP&41Cavl4+!f`1uj z<=u`mL;fef_EVEMPZ`%a-@bSJL3!OBBc=%)ahzf*NfF;?rM{Q!*mCO*d@uY5(1^G+ ztx+-rU$LaVWm|Z)qc{K;q3u`5llr&PaZXKrKEGG;75|8v>*Sno&FOOf^`9JvCEzd5 zxaafFU*FX2CRfP0Tbyh z_Ls%>#w@ehV;#9kt{OfajrH=XUjqnHyK@ZkxWTb_X2Rp} zbWlcD$}TaUdAb}QdC>mBRxx#WJRbM;InHc_+2xcFkG2KzCm@QU#LsmW;5UcxIv(4- z`k?D*RwnA7eHL(^rrl#?26rON z%}lTuH#?z_NqY(hNz;r&<4RC>8^c_1Ld&ao8cKml=TFjRg=fUg?duZC4rEc~W0c}5 zgIN3l7eSkAs2Qgs!oV^hpF&f{y&0~F8{CgiM*7RL4phm?nShKSca2pEw$Bl$5%3h` zG+YIn30{+e%laj*nVl?h;6P?t`ZE>S4mghaObD&@vKxZy@l)MU&KIOQ#)gqDlh3YKMDpt&MMQRt z6y&Sxz1k0+d+s^NT27oYc(Kq$Lc5;`dzErtpjJ0@7m5mQE;AMsSYiF62v%T)>iTl+ zM*yZWlyS&$(o#{1HOGeP`d};Y6B?$|rPlbAT#wHIJ5+{IhT750gaoGL*BuACHeICa zZm<^=KyR2DdZQ_tUCwY7M@}YA75qO(GJCSuO6EG?Kf#J`(BTS&N&<+NJfUG-6lMF7A2U)2% zCLnhV(iIe9mLHTEXZ#AQ)-!8tD~%N=K!J5F#F0UsKsBtU8IvJwaxp+;*u8_xdQ zn}u~253xP210yav6!Zg7fELdSU}QYc&4;K$FQQ8ba>q%fh^P>%G^lP_y*$82Cvr@&HE;QL@s3YOq8 z>Tey)+aAn7Jv1Os-fco}V+gO^#X<<{?Jatq3{_mmU`Uo&gRw|^YGWI0EnXDE_-{M&3ThLrUu(J3`d?^lIzNz-| zooX*X+@bdJ!|k;fiRV>ML?eZV{6>V6mjzrN1{9B14AZ_1Dm~dljK{7xQi!=J2Z})q;3`=!6l?I zFXMY?Z;V!Ico<^5eEkCyya4ZGXvLjO^`aVZaq{(#HHTL&s!_7)UqrBKGvvW_XcNn$A>C-Jn@5d^3pOM&WHQK=S5k@~n7 ziGCBWL>yUhMmC677#;z{HuRJ#+AonRhs!25eyx#pDnFyPDSTd;Dm01$%Lx_Fgz6x>6N?V;Al1zo`wL7oWb|;YUd$sCfE%2&HzSG2TcOU z3)Ij%f`N9bnVk@UF7)mr&rY+&Z!wWo(uw z=$?-c8G;ccGv)H$mEVDMxYRxh`&SKwMvJ7n=YRxhy&SKwMvQ#%$ zTeHlKv)H#(78&(88~)8=XZAABfsf%p2#tu8r+g33LdBE|A;i$@sX-#0QV1K49*f8* z-Fq=*8TeOl{vDI8WWqH_;OFutXL7bQ+PA!6VyWm-cs7zO{}62WeyYg#+`Njg(*==p zote&tI?ajGI^Pq2{7l=@Uk`8<5?PVYQTvUR4wI^k>w7NEVx@ zLus@V&{3+Y)mYAOVB%_7kMlSJF-!v6)CXaPH>X2|`WbGZ(@?t`X+(D?H>|LjkzsX< zAv$FFu@ex~WbuX&P!zYq&!%i?jp*fz#gtvQ=Jh@8=GEHQQ=H9jj_j-Jpj#=vo0}0& zg;;w#;XHMTO?Jagq(a*NtOKJrwa*Z~kf{NR{7`3uzRQy7@=dU|tgqqb6pECo^M)|r z;4$4CX_2Ho(s9X|P7yUT3-RjLx#4DDP;P*UUU$XM7No5i16t@Au;~&DZOtrgwq(<& z;A%K+GN>YfI&@q0vo*2^V|2_;c6f{I`zpNmNZs0EzTB~x;}B}H;`in2bK!Ri?eLx)=EyCg4wH-P{CpH%qROamd1fZ^mSASg$Fv6R3`?P{lEX z)Lc=xO)z8sjEYV?+)Q{Q0&Slz>&!>^!Qi3vrD;#_*0`**5U}Q;45?YKemK}#y&r{k zl?#{(BWe2z0oHMG5;U4i#6TK&uv`O?zZuuyL_}CzTkGNfweNP`y+mqB`=_|qa;3n$q4m`va9q>f> zviSiG^H7iVV#(RAMrsb;!aYdrWXA49RCahId?!EM@Ll-nF4mVY#k&b^9pa1iWAUwl zXfizcl#W999(-~b6jJ77Z9XOm-^+)TQR`-$=FxznB(|}5N4f7qa&$}}k}y%CSYKtL z;Xo0q^k^LHO}1ooa(`?uQWQ#i{T#+!Bn#Yh6m)7#oiHIY24~Yj-{t*)?dMHCMI2<^ z$wncqXj4HEsc2^FXElQbzz+Z*x&^E};-quWrRw^g+O;rh+2O+#cfkTRA*P>pIt?pt z0jRN9qOF^l-_6&1$D@tWkE`}dt**&Hw{5W6wYHwhI zzVH+L>NUOLC-IV&3`+C&H`1XNzp;rmk_o9spOyb zwc!Xt)kjn6V$(I{%?@w1DLC?K%Y}RkhHC)A?JkCY1n7250ZIfnhXGOTW$WiPD{(RW zGNP;5;O#`NwjhF=SI&+BLN%|P1z{;GyBzI3CiKFuAbWj1fxa7Wb|^k_Nl+D5z-H_- zy{0dn@iLo{!8T(rGWJTw+C>q9HovSS66?o?=6<+Yv;7imA-~YHcokR?SOYV|7%c>R zaZC~41v4Xr;qo5k*Ph{P)+0I0g@nVO_E0!FEsiOf8LuH&(;s{j!FTdqYF%Jj3}*nt z8MWVosTlOj)v>cs4^3{zfo&N$6vID3VUd_u$9}13 zmw{|~Pjx+!F7H9QawxdE{-7D8^SYVUB9VfvU+J#|-vT6m$P9s$vXFAKu<#l_=!u zA@m`c)2`NNMJhFP_Z;N?Q{+WD{v$=uJwT}411?FvnwpEKjmj~y1YXuYjIqzgl_*=+Vz@h@2VxvfMOJqQzx#mS8#s`E2Nb&TZDa`mD@y3k%9>f?eGQMx z=t3;iGo^p)&#w4$%xq~U#$E%U)v>$qM92#O$)9VZ{CQ@Mb!VFCL|VtpLCw!GbB3=$ zH?Bk&W?rP9%H~}$n=Af2nGQ5_Be^j1Sm(k_c@Sdl7IKbnG4>ORy{!k~wj_v68T&Lj z=K|ybUpc4!9S~rflY8RwYey42AtS|ikwR>tShY;}J$$?Bw3U$}Rcq1@p~pzr73r+( zW4i;KXR5*Nam z9v2AK6nueC1YDS=9hZeME&^jpTnJ}+T#lI%7dRilg=yMxK^tlLA~2@Jg>a_FH>Ws?`?zq61 zITbF1Gd(WH6Bn42&bYwz0~e;r1hYEha+5nQFlJAM3*k(U%L&9~Qk{mG1TIX|EH4JS zZ|le%7Z9f^$lOiyegTM@SWGBk?1S zOS(m>7QX3@OF?r1!^TW%i&*CLc*8B9f`>m5yqTt%hr}|mivlBNVkaIF&h)q}CoYr9 z`QHdGOf!|73ydk{oN%Vc<-{rZ@(aO*Y0`WtIBUR{!&Lw25y?O8mR0axEzD=V@?VF# zMFS&+l9AKZEz6&thLfhG;h%&Crb*M_a(z>x9|9t-+K&1`IGu56ZI83g0ANddY}cUw z8*#`?oD3w;F1M7;P_78#!WHD}Bv;Jex$Ja%3oV;MHxCFOnC3UuO)2PLbdzOHk2gHB zDReW_Mf=7y&Gpx;n*t-Qz)n0Qoau2{IVCPd!G&qsak2UU@fH|U;zBsn2}pLyx1HhhLtv90)wG{v=+-}>DFA1vZ)=a1rJqb9*eV>{y$#kH2- z`t3#otDIw7MNA0KOX?!Tz<`|#&pT*^9dsPywQx%ihs8TtSr}N~((7#3-e|cHEX{DV z5b4pSy?-r=oj9G+M>$CCBOPa;3@urICq1Whv@oV=o=MWT%ooOCXMylUJ4)!>r( zQ7$qcwS}=hIU1F2pIL9J&o0-$ajw$Ne`YWwUB3cE1ls^i@ya<%kp^Q((HyDlM;5lD zS{Mi+mP320rD>2m#J;jm^6{2J7ZDY$ zy1u*iJ&g27UF4l}kR0P7m_tlTs9=JLU#b0-Xa=!EAmbLorHBYaU=~8c^G@?l%fAr7 z9HSs97+~T2LVg8_+0HDdlYqER2oyZGWphMIgsW#pBwSuTI3gA<%1&1HgJLAZ#BA%t zgSh~V&7jn};=}99&BSvzXhD#m&&Cp^-hwkojvJndA0>a=CZu8-j=l*k0MtVpY@HuV6_`XNt0(s2);Nofs?t zZ6&FPjQ(s~Uv8Oo{BDZIoa|dTh&pD$*h8yToLS$ge)rq@ZMCnh-)3EyLtWf}9mFNo!{_av0Oj z$vU7Gd45}LS84zVpb<`m*lOHtBCCNV%tyxBgjH@py09%c4L`ztDQ%3{!t9nUSk8_4`;UPK?aaELoJq)oEU4ch_A0y#WrVAhavc+9ePaa6)_3FL zBrfZ0K%ClRR&?5y!Etx$9{`7EiY>!#2bBr6Uiqpr#(oW@En7Zi*;KBJ&YJ-ZLs#&{ zf-6uEj4tKNG8JORuTBD^Oh%i48B-DB0;-PEpTVkhGI`_r z9!Y1piCx23g!i=Yt<=fLJs`pccDT#s~+Nz>RtePhdsu@cN6#wQcJ015JL zVQ;t-3C5_>_0Rgz;w$VELe#!es+nWjLU;=jx3sMv6|~qM(U%b$I({y6-Oh366cKtk z{<3}tz63t6sKt{x+JR6j{S$&7kg4rD6N~@F$y0jkW@_AA}LiG)p$m+)kHvMYg^2Z^QjeOV)@ z1>>Tl^&$WY;lxI33KM)I8k+hj64>0AiRfxdCxOlBSlL=1Xv?gBF(HAS+F0s>;wxz- zebnZ%Zj`6z17-ko>B$;ZR~AlAFi_gm*-<(>7I-UQV(fwOm~Xtm;?v%~Go6-|ckTfU zsBP5W_yOv}9VY~s3Qkw+46Urok-eiD83KfEeN0vyyW=d8J6GHed08i^6yZx^06#&I zx)_>BO>gf22><9WbmHqoOq-^o983?hSA1v;)xuWYK`sU_6<$^vz%xH_AqdL`3($oq zyVBbC0eCQs?*NU$Xbr5wdi1q;h()05?^eYcz~inyQY-}M5sgk4UVwri6HliCa zu{VLHo-PlhYMeA?_H4TpdleX>>vSQY)_-Gk+J?2tcI&tD{gf2m_X^*u)AIe$gzxK6 z=IAZnx2ViCznSg%^BW#)EGKUee$Ski-x!~W?8_y;Ykm{Qw49$d z75)(yiDmRoQvv`y7YPmFWO9BiqJ+`zI5TG+6qhP%-R);rTq+Gu^zAq)Eg=!|9_aMREb%ho2@4IcyeCU>5(^@plalZyUDGPH%EhsGPpW#xN5yd6ZN`?n8a z(r?i+&-`td`LgNDJnOez<}0T!6J5i!eEsIUeQ>HW)4Ce#gO&HZgAmUDo1~ApdMK(x z48x|ofb1V&%L$$-C+cl+YSehDE0eJrNu8qmCAvL)M0H@GS{WGmS;lb&@Ln0dX2ITv z`R^+$?90zr%nm+HdUOnyUZ1wrl=w`DNdQq+AN$)MLL52|O*SK;W97_b|<(<5fbFO)*~-q_4hn~B_50}NA!EgzTwV1Y0$?1@z$K?K<3 zE$8U4?$#*k&IDT+=0fuJ^+voF#T;O`Ma-wkHgNcZ@Lc!uU*#-)P^Ze0s~oc#OBG0yU8z4j3q_h{k%l?-%8j=IP9x#J`;8UkC9&aRynQeFV&{x2n6d1=yRu<5P?YltzJgzlr`_hF5gr3DQ`!L? zas8fxE>G`ODe?QT8l8iRspaS|L*5M5pL6Lshsg)bx!Sd zAOtiVMH&VQ;(PmpmWE2juabr#Q#C_6W~Ve%_xLl-%!G!S2SLLuGpk}|S{i1hX&40f zfpPleX6D0>nTmW04KqOli?%dWF$`F(MKlbWO89o6;Tcp2XgHcQ%r0OJ_B?Izhb#Vw z88P#;#UC~!w#6R-oRONe_{07jOYGc=KcB=NV&lNbtFvSIKEfPPF=ZJG*=LT>4t)vx(lDxkjdEG+e1JQ^Epu+9 zo*Z%s-w$BnT@uqa^j`hHkn~b`H)1iSd=Gze;mh$O7BYM%@hC=PV1aWS)BrGbZ`A^# zml!=51+P+n8PMHlyp-Kb%>(nKg5`AZDpBCGE1E{{782we5{kJuA|o55_L*{uw^}dd z0%cy%cyvz8C0e3-b}rHAPGzZ}#)HE+xPpz>`R=Uj%gCXwX^_$WIyvbO1g`+`IRERp zJ2rE>UIRUEY|ty3-T+;WQ9WO)ur0GOSPUpVB`SXKLcC(vzRAkCy&5>oeCSIdJGO%q z`+X)0nVUi5qcS&)V0hYnMboD?DUydi$U~oOeW}1O=8&u37}Kx04q>i>!H$xwG3$|$ zF;!&mo_wc~c13O3FV(~5!VNR^p4gJ`|YAj@QoEprfM z4oaCWn&S^ZVEm$s9i{SyeMhDgjDmBK45j8FrSWYW+oZ1{^PB9<&|+!3-Ih)o+5J3F$RQvu@GQ(E@!DZrvtMjNk!cMfF0)b)WH zrK`6nMEgCxa)m%D!qbS}-f~NhvPSd`_%ooJ1AhO2?+^F`1O8yuuMDqLm9IuGLnc?* zn_=7BKeK;Ua3SYzv43h2%g)JOy80=o4IQHO(URCfS`3}**u@AX#h-0vD`0G`(wDh7 zonqdge}p>8N1A!+B`>M{9VK&#ddWwcBcPjTG)%WzIS*j>{|G6B8>TXLC;T;YWX7P` zJQxIzF~_tbXlDAynqyV)j;r{`o8!%~>PH-Bj<-^HJg_)k{fOiIWflJfGZPh8Gbfmu z*3;{OmpKwxoUo_50d4&Wd-Oc6F*7C`@L}a?qYNz zoMKMWdO6vgV(aA;!!W5AA6%_?)MmWEaW9-CR!TY3bpxBQg# zhe>P!t5CqI+MlujbN?!CZAH^~Kc*sQ!}~X?90O4)gBAZYDdlu?nmJudIi98To6{Pv zMt$u=3wWB$Z|^e$=CleMw$m_-#MHLH(@@}PwXdSUioaTboncm+GZfh2Y@hOG^Znt=kUy?JwH_!^WD)#8hkxYYpe89vK=N-~T;d;VGGEZcozehM_jOzeQjTgj}h zK2l>i^W&At2n4UOwb;*Yht^526)VBUoC)_OEmLe_!0Hxk_MaYS?`z54cQDywJcL1( zf2+a2)wMszu2pD-?sDCKCd`hYskk1z0eXRXK?ei0#;j36KfB_eW6m*aRM5{h=U72M z2RNLgf_}EYwBnyD2IxF=mI?d0&_vd6l@>lf~w6F zeYdi#;$JA5ev!G*T%-*6eHQ{T!(6z>Uu)JzCqD1L7om#cg%%{Vf{ln-YA)QHoCW2t z>(v#+W}V)J;BPP+#8L3|#AvgwAOfVPRvaCJ9WZO5cWY($cHXSr>tEd4J$4fd=*L%x zK|imzXK1712)|0h&ARX*#4LjHuHB0dS$;CYSPh86xyYcpK71{H9NFP(BdRtCIh4F! zo~@#|y&f7ZB)94F#ZCGwO(}-3EPtBS?cx@7lf+oyYZEss*Ttq;xg>F4oOqpoiMb>> ziFr92!3sh!t)OQ!M_ag~h{xwm67j*wB0f1;#1|%u`08X4-)xB>U!?&nnYEQiRF>BD zUmD;*F&PMxI4>QyKA4w|D<^RjR;wQuLO(97o&6tZ1(xW*U-4g}fxjj8tPm2LEB$H|_N=YZHXa+61BY`*C$O3&Ld` z1fhlwW=BD|tb-ubu*AJj^B0X97tRXcrL3<@M6y7zrSoUb2Lh$i`$o693P zuVv!+0kdh3zu9b#qkaa6T2q&AGn=<<@~^1)S3-5bjH~*uJutW$?UT9I-(s!`_dvD$ z>uQG~hq*3%9ly5pZwa78p1BgPKd12Rw|}L=yiV)u>u6AQp!PajUzhjlVHy6_=8E24 zsRsWVbG3Fq%r(o|l2y#*JQV<_TJodjwFi+^NNQ!S5{N}^=U)E|Z~aTdLc5xihlW41pZFKhU62wu+S%Yx`-Va2~*Bx$R; z-fUG#dM8U+i0`8KLi^C{UjN{jVd#P+wDuwfcyZK-M8Q|-+E=@PQRO&lzC@wphbXoK z<`4$>H<%l=5qwd_-)6R%8?+I8k=bS&!EGRKn>KpNx?#G~4%@L*XO)%`Mjrd!Rr zsVA*wy&8|Y)|*gjNL#tXG|Ub~X$J~ITiMv-?=(B(w(_i~csdGYZf3p<6F{go((Rz8zO^JF%wbcP!0i=`_5=qu9M3YFGTOYC6p)PkJ95u+XEFtOOaD7RH>q{)I zx0%}%*Ov&cb#FNKV#dvQ#k^#n8OQlNC?7AWiiqi`{UuBnR0SmbK`RNj2kT-IMwEox zA5BQOQ%Sh_`H?UYkuYIN*kyJp2@^`f=Rks)kc~5fxNf1j7M;`vmS@_1E(!kgh<~{w z{&xfy$N0}z{O@?|H^qN#ebr*=bt9GUIV*Pu}J!-jMUh!XHUSaN0%l&fm3TwGvfz+>1%l&fyFf=w8 zxpVyhFPqT{_=i{gdxi2>ntRPFmGUbkvUJ}IO75*)O}d9= zKIHJ!G=mw|G2&I{fIPs*O_`1h6W)t98)H`dhjZc_=T5jhtc_3OHywTryp1=ZA$ktO z6j8Xp%$IxnFw}xoD;TTM<%e!?0rada^5+(U0eGC5Y;U$V*PHJx^cH(dy*WjV+Hk3k0}`%L3=!e=-98h*n+#Dmk|pO;@Z{4#!J34HiRHsmY#4ZmbRzQ~U+ z=!Y?aHRHA2*nbGlD^1>5MJ&oGb!zIC~_dOK-rkRtm2#x!NBYT=IT1G zQNRX``pt)6mJIVVPzH7R-N3iKuVFx`k0ajBtz$61;_k!#lVk-<0}EwvVrTF$enf}5 z)osib)oaYxo&XRGoXVOFoXlq%08Hv>1Ax_Fl0)(Hka+){cxbaws1GcN&S#Y-09~E2njd`cX%?IAuL(6>>bBTB+c1RwIF{UE>@SWSo1Ej9VWP z;t$^}<`>uaKr?kMDvD5R%2skm?w z7UGhgfGssAz5-CJY@rgExF;oB#o%cXf0M2M0tvZtMmQy6`EN;99G%Lny{W9F0F{{c zb7D(4aPO)iL35Q;Q)1UNig~C%xzO3ZYYWVPkc&wst}0@2vS%5Y_|2M-PE-V2C|rE zqhcr}Li+)PShnFXSkgWtMY|v(nOLA&M%m6{@&o$C6u~!9w%Smv7LpAIz++UbBQT?x z^^2;@m}gdH?ys>6XZlr{P5mo}!f7-rBA54Ap-J)42j6_<&|Kk!SYgadFj$ZiRjWx) zI3GnCtAPEgfZPJ8qXhk`fDyVU5MA1C7+)-Jz^T59?troD#Rh9^Qp}*DJ75Ot-vz>Y z+Vk%-Wgwm?~$&MRsWOh4S>>`i31l5`tS#yVABx!e&%q2UMVMTy*rfZ+>?T=KNzmC|oXF^@B+RY7 zoVd(Y*3LmebD}cFMhN_^i(=qdV^lG7_L+Ija8t+*B1aV+Wkt4WPFaARd{_)@*bED> z;WV(h%90ynmduSlop{O>TkYb2r=R>NW8XJq-~6krwc$lsC%OrQ)xVD- zgFgY+%7uco(bevvc<9`rX|lzW27ogrr$yw@B*Ll9)_x2GN<4nHQa^zF`(SHetZ}X` z9*(pd^oO`35Y4htse!|)Qd2MB9<|*xLW}9wHMiwH&dc5k$M=QECubVB8gyCv_oIM? zHm&`mx7(T)O>U+B3rX9wHc|HPmka5gu@uYHE`OdV+qBOgO=)^NtSP3JL(6t1DLb_= z2TKSm^?#SL(|akIO#WfXsK+VNGP)*Z?Z-3_;X?41f{UEk4?YCtSPs5nD6l*DFg|QI zV{8e+>RpG&g9&U?>dKU!s?jTnTACQsy}nzDvb_^b7T~OK+MHrx+oqlvFF)BNzy*`N z#4&Y7@56#)ui`i(gQL0yTbQ~uwq{CfVW6UF8K|0GJ99IT7IZ99o2kfR3aXpB2@x^KbCK+jK$` z*eWYlqiMLbI%M>GflSD~_C2@{hT@zp6dM=t1u|8lOkq*2@l@`wL^&0|%mpsp7B0rMztj&&QKP6j{cW-A*n^ETA3$>XE5K;u&5o~U)wWDR=uCGII5|pMeSPM z^rSPvtCDQOd5g_4z((73dfF-5hQ5$ik=9}fx3mfR%wU63#tm06F*djovFH$^p3|X5ZCyrW?*_MWs=^W)%-3r4ajvcy0i%SAuq8mJxjD2 zPrx=#Y$f9dCT`$QW#UEnk@h7o=K-PTjy%6a>V&r+j(OX<*MTrdU)I4w0*85%T)m@F zjWp*PXZe}%BS6K`iuacdIOjq?uV&e3pLABom5r;AfXJAOAncBzHg898gHiNU_UdR3 z-SPVTBVr#xx~Y=DDrCCDTG)9qniG4El4$*t$Z546O?OJ`HEH7?z#TM8*r&xZp(Lq` zfbK@oSm?Ny4I`V{Lt9W`b7!phU=d`}Z#Ko9D_Zb)*^x0GSVfyk3)qr4{6}E22s&Aa z^jL2e8iyXo>vNYn{|-L^ZZUL<^+l|Nl}WWiWfDY^8ccKwyiWRr0znvc(UKTJusD@O z4XN5d)h)It^+%B2E>56Nq^WF`u(T<~IYBD|FU%=~-$en#v_U(1A$80I43@bQtUL+3Z;$xX@#JZWL+CLOP=VBVMwL$OP9P($mW)^t% z9h5Bw3bTpIvWoh0YmbVRnq3rypQb9^S3&DS>n28K_Ofp08(_~C(E|o|Xj>rWEp3aC zp6CHR{j`{4DKDiBBRW{vi<>2C8(6)0Zts*^Cpw8&vm;@0Q*j!Kp60Y2fqE0v8&ZYq zM)h1T9edj7;7$&P;;zv=Z7KI7LFXr-6*`~LI;mx7HnN%UR}>0OT?qdIA5r^OV8L;D zyoa^y{5`_ZCPr=Ev#2{oM>@sz{-dK?nGV654jL?v-sEy=2`g8-mCLx=N4T7;tps3n zrG(2_oNTuexr)IF9I84i0)R1qry%SCA^{ehf`nbTk)l|} z-6obY0~LuSZ&CUlQbnzY@_|R4sZd$^{v1hqN^Onfd0=qLB~H$dC}`Rct7P(vQe&bg zJ4fCS@!>c1Yue{M4xlu+FXCe*GPn&&seV@yc4LRI(1zumTL1&9obq21tw;SNt)Dhp zvGGWH2u+PBmin&+W)nA|0KC(~pCD3rznt$x2Ih(7M?TP?(|`h@g+^U7JT4;feV?~| zvVJbHNHxPp9TT?@+6)ubxEXfGnLPSzYlh3V+hY4ExHu-yenQFxdOuiM@r&%H$W9OK zro0rP?l|ej;32yY!Ahv&0`oUV-udtwZax_f%uJ*}gKmm(XHM@#oU_(?!kQ}b#)lS8008#UF_;?F0WO8pP!`@*?X?p-V?Vb|ZUt5=G+(fIf zz&74-LcPm5GXPC&f2N`bzckSDOh(?uLaD#@gkHTkI{S;L9Se8 z&0+bO8&)$7x;~DNzbo+9jX#V;%Q32O8)Rzrbo{cdMe`!v78*sl@ePN?VB;I_x5WVi z{J}Y8&eLZCLA~DwxOiS|GXm#%we|QJ@@g02XO>q(x0HdWX&_f-BJem3T!J3}J4^#d z@S{Y?;R;BG;+GDr7+C1IBgbYzJQkLCJr(E)b}d)%-t3i7+sK1J$FaxsH0F>xl*5FpW!_r-haJD?^dc6dJFp|6}6hS0%BZZddb5ZTV z5i5tmIwUE()t-_rZpg}&swG|9fKetpc&g_j{)qaTrBGDrU(U!tW+l9hCl+>C5%+ysHjfOx(hAU52E07yziD(6ZxMtmI$b1_0`)5wTC^)I+XkkoJ; zql3tCjBa-n!G8Qw=3}^2)ngFNe$gLR!aN3nhZcQcu&4wohngU*_UqRq-``e+r zQrEZ~D%YD0*2hHkW>MVOh?J9EgK<;NM$@X>Gk$7U;$1K#queo8Np1~N%0z*-mXf@z zw^(7TXKxnkP|LE(D7z+ylvIkQlw=}KV?oL-Ols#A^ekUg3+s6Z7&f4f-fbvxHtaTa z0oEdFBShIT+OOB*$_yL)xF%ibae8JsMb(9j7-+&4t&IwJnHIqF5OxFpPL@pujNqew zEqJNofI-&!cgWd-Wh|ndO#P<_QtuK*W;R*ssti_obgLDtXL1edeB{ZOa4PU!DD?Lz zt1aiK{*^TD(H>0U16xA2FgP-HPF!WQRg=(jcSKJ{8Y0+7@Ddv@Hz6~u3U!8Kb)sV< z@4#E*9^}XbFUM2jb$kyt)a=>~xt6eu47lG)Sj2~th?#zBvdD!Sz5==BE@{u#g@k*RGWnH&Y;G&BymF@+v1i0{3c$5qGT>?usOP;}f2w6S^ zP!$cHlLT9B>n@IoKC#WH!2!*)oB=QswwULfV~Y~SmFdWd+4!Sm(UPFsFU@xC zQoK;R@bB3bUhUiXg^%+Lf832;?aTVY*H1;S{Pj4I zv4{(EGu8E5f8~H7%x-ZOvR=}EfqKFZRy40 z#_mk@>KWjMQ~M^Qtz_$$&McUw^()$JT{^LytX0})Px|R-eGXnnB7NwN^dUY8eHK9G ziy6^_<|Dv=Drz9@frmtBu1ud3yaCAt5&Ce4ARH|X?zFQ`bC7}fcyfcWn{hW(*9Wgb z<=gZbxpxK)k?D8X4&W%r21HiDi7ugSanRymP0J$?P*rK1t0O9%sedO9gWI5LE_}f` zvQ^R}4G6p78LrGhEo1eSTYL>Q9P44GNJy*K0iw6jUHb;;1Y@c1l)~LC|7K?L z+>HF*$&XB4eyCU4K1w|W4@Tq({&SH(sz17AG#PG1E|K(V&@5*#s>3YLXLi5@`0xbJ zTVvTyX56>)Q_6ztNacpzI55b-f`nE8f@3u>81$2Do6Gh>PkIfOh=IBR(IT>ya{8Fy za}kW0DtGK~yzIIarbXDbOpBE0$=X;sW>yCSfqJis2NYxcs(hrS%7aNX7 zNc*QzINhB(Hp^NEw2G>+7Xt_?1Sv@t6(>s)z7~1pjIFG*%;M957Fu36xLku(8dpB2Ex}NWKqVe9f5eAzpBm0YvW>S zyj+9Y7?W3uLj$cs^v7T#Cv45 zhG47EZG%EO?#$qg{MdgoJ{DOp;Kb-y9|9T=BW^cgi^imZ?nTTZmsSs%kyLexhPaE` zg4X?vi=30yx9N9|1nu~55}C1#reUupp^;WZl1s+4Mu%@gnVLOHM(Is}gpVMB;q2Zf5)37hvBd_m+sD*C<5KJ}%Z^f^8)s@}MV)Ly-KJ?3MG%6cM zHBc6c0|Vh>$no~gfYwtQ8$noA?a?X(KaD0L)$z5wPXQL+%9Zg82%>TOn$Oc<`+#uT zXX8E-+4~~b+&FOWjN9LZg}-zJSy5D>BF=ulrg4$ zF*j>U2OEtI0+~gmmd-odg-r-R6vhK#C&iA6(d%p%{dAHqb+n625~6Z&93fOL%j-U1 zOA1mBW&O8V4yMCd#>VjXP@dS~oWpATFQLcsi|~KzbXWq|KcION`0fR`^b2%dnD`K* z?w|J3n7~|}Qe;F~0 zejHFhQw-i_c!yj>w<~uarNW_rk$xt79Wt)CSbJM>ro3h?_KxAA@aPW%9m z2rsswtQR=5KSXeF4&4Haa~#+B5u!4|yNDLzW-mL_`628K`~q5MD+`TJ5#; zUqAxUY*29?fNCG^{c&jcV`f7Gg&y$R!4l2phCe}Ycd-f|?@Y!;*J{H>Zel-8c4pY# z3f{tZu0YI0H;dOGv=|n%9)9De0IzOUrv9N+v5-Shc&<1_r3A@8B$CoRxZ&sV{xrHZ z?dEkX5HdcIg7?=*%v)$jBcLD2MEsEJ$6a2WvxZp+Z7!PDm^t2E)y zG7bGEkoZfyLjfZysY7n~4+Lk=fDA=+q_;1UBW%M#YGr-t<$-3swgZ?x)r{4O#bsu% zV2`zo_D%aZum?^`hOxA=<;b(ofiV{D{X?MJ@5@)Ps2;~R!WwwNKjOiqaed)G5m(!x zwiz~Pju|+KO;Tf3y_u2DH^UCfciY;?43Ao-11-#M!5bX~Q|KAI3IUlyVfc#hS12g_ z7e4l|@XYW@ku3OmAFDM~7Ah(W6_tfS<4-0Fz8Q>Uq0%Y~l}Hv&v1pN*R%%&c(qo8C zibP}zk>N=txGY5#>|GZOfa{#>p0+z$JN<&}Y4=6zr58AyZdpWID04hwmdOH-g>OWh zVj$B!QT%-te-xr0;_n0a<3CiQQ+p1N zOg&eQRRz+CfNB!(R}wHk3HXKt9F+uoN&-$x0^TbD=OzL0;sNy1B;b`2a9t7r!-*6x zNdm5yfV-1`OC{j7Nx&Hr@b)C&I0<+n30NorpGpFJ3HW*v0M*3UvW`X4ij0u|*g7U! zCkDHq1{)T{TG$9)(8(frp)JF^xg6*cWNQ_Y zCNo#;IDD0vIh=r#Yd?y29XyqhngJF*dzo?2$~yPOWe<4upQGU5*GNqWWQoY>{!b&F z$SGjG1);W=fewh34gq=dG zHzEj}1i4y1iGc^zx-8xbNX>#W5J zd}8SWx;t!_sJcXmu5E1?k8EX~T;`F>tgFEkIvLzsm@Tf$74+<%+8Ttp(p!qMGZnfF zPHib3SvZ*MYKP)2H@L2bPJ<&J=ODPl8Nr-i$eR4RoU!2Ohvywgh4UKVoKr8AMPlwG z9v`J5<%@1Xd|MeMEo56SOSCsd(ofCFxZVTg!9eiu;Bpz~I`g-?AhS6|T`FIckAfYX z$b|ow^+6sk#SRn4d64<#xG1b+0#k|wowgyPsxJIEIhHP%ja*UtSwa+7D9N2&OfMrF z+3GsXA7QzRRzekB){>&a)hbqmhnHdQie*RCQ3wwX9&JwT{e0jLo7)Hq^*i#^Q%_-iyHY+x z%Cm8CHnMB%8ssSPtc{bIH9xN@{kIUNFcG1{g(C>st%%l6jH2}j@%-GT%x{Qe@dAMw z_BNV4wo?dyt8go%*b#~26SEGHo|xPg-06ofEj{AM^QmuwO8Cs06?hqwTUVtmW1oFB zLYD)zOcsa_;iLX0mv*`q`2Z#Bkh~gAvitT^V90t(a(L$fbZ&K2zO>b*VbkFT7c&!# z6WWh^B@^E%(eqJlcOV3ty_l7al*vYUl@l^e<-UC-bHEHIIixVjk&ANBHfa*TeeMp$ zN0Z!O%eO#dE_3}4IZs{&S1K20!`ALbz-tjU@}n$9)(?Py-7iM{>_@}tEUGk~iv#fO z=v*FPcwrYFb9AmmTKQCzizq!HGqNfKrm%txB2}bPO-2I>=Iy=>!c<@_2>?0+%_^-QxfuD=g~V3mQTiJV%n&uflf& z%VoUKW8l+hd+li8O-%U~P|V@JG9pE|ivvlPfv}r-L?vY0d3S?Ij}hrQOXMTyL7EBJ zJsW`nvNE(9^hOZ_!Lg`+)h*PngV6o_F6{u=irxSHOk3)+3;~+i2OrlpG#L}C1n%u<{ zIXJ0nt~AjAKD`>jwx;K;yI5ht^t2Sz(W2bhNSWJp3#g>*vn8x1lftc2!blQH%}hzz zPo&<>n(^@l|09Z~Z3?hFEU!mkS9e2ub+6W3#IVeY1XX0vSI2Z4u-5tAyO|Ja+SkP8 z<<4X9)wqq1A!)gAJM<(Vvm+^IYF@la zc=6{=yf~g{jHb$ud2!1@@Z#H(;>dZnmq8~UL75oigy-h))Y`dI>RoDPZ1aN1z@@ z3%H*xn;n3H<>8!~i{AV-w`r5tVJ#r80r~G+3&=TCm=))h;FsENZRQTHFWR)O>Q>MZ zzZ=oKVaKD|nADhDcPT>Bi;*O=S zZMdH+m8>!}yjz8cu4=V7d`BnDk0<8Wg(pmh`8(Ss2OgIe<^Wa5#yfcU0PflOWwmN zXpV=xXPH?S)!iP;U|Dq?0$$X~UyFRuU5RUP?v7jZZ>#-SwRh5Hl-_yuh^oRHuU z**|ONu;kk--V~lre5SHWJe##Bp_uW4Cl~-ljOxMst|%zri|3x;4YO zW=BW7UqQUnHVuxQ^LvUnZQ4QM{oA)@)8hR~;+?i<@PwVWE8etc3EpXK`Y*O*DkC$T ztLhzjeIId7+cDaK#}#MVu>|Mexdlu3eSK#e{A%K!wqfM=I}~@?umtxszyFJ^*EIZo zfH$*HgH1V|@`>%o z`3jxl#O9vUIZo`_Bs7Tan1lw=(Mf0!9hig$k+n%^5LuXn2C0WhXpnk1w=;cG4>$p& z6JMkr&hH#2^>9JwIH?Ehk?I6f>fxf!aZ(R!JI6^qOhSXy!z46FJxoG_)Wak+NIgtK zgVe($G)O%pH0YkbcT*qAg{|y_a-Lu%bmTp%!x(YG+LB-$sMd~Mh*e%5`ce|4;}z)l zei&-jxCAi)23STl4d)Cp4*J2(urO%x1eJ0CsTVPg;_zO@;f6F0?^7IT!%!eLw|SZS zV1#Ff8!QeCiu;?m-67w=l&lxbA`X%!!J!EjaQFk_Foe{CLrxZrcA&X4xgYCDE_m08 zpPhN|WD^gx{3aYHr5Eh}WJglt@|$_7?xt=Di^Qd+?*42sLoNR7v z;w27QB&S=Y<}=M~+=Mik=_B0EJo{`D&pOKu#v`0&TD6v~0L9Yshs`X+hUVt7n_;-Y z=bQNTJmuv}oeAyCzs|IOv5EHQDKB5?Ol(yC!N|*(o9LfZURv~pLs8+h_4>6Y+B(bB z%7d0q8AFAVe4RumHt900;fUsgwRv!2II#D}&A^(CpitQYznQ0MQ=4Q;^g9;EX9&N= zcs1L+%bW2(Som%BG82te{z-F@E%00UbK)fYxBU6jDfkmw*2MOr6?ek!#G7{9Q4@dO z5pBSpR3?SsX2Fb6l6)u!^B=Mn^~Hc5kHvBzh~X}$v$5DO8f<4-sXX7EDo-?U-jZkk zRC%I7@RmG(HC3KyK)WT+52ngfw~;M*el%5{ot^XiWU4$7Q(MaU>m-lfTls$0LF0QI zUv>k`28|YON3TIg{4q8pb2lzVw)ammYTN?<=L!6#^|h=<9pp#-GEYxb&zazcDdJd_ zljYksMI5VhvV1p95yvW>EZ_Di;#jSdR3BJOU})J{BuR43DN5+VR(fH_6|B(zO~k;UfYMqkgG+JcaP29%a=&KTw&2+Wd&sr+Br5vHvIk1#t{K@9Vw0uzf1QnarkOp%c^ zo_4ws%MK=|y}j;Aaox4Yd2w7`)&)H&xS+F^;4*$1agloMfXmFOaY?n^k$8dbrZ&CH zqR<*1iCk-VCY?T(Q|mWGz*@@ir^*unY{@g2ls86R}=V6{D`WnmG0ss>C2OZM^!6uTKNU5)F3 z5zo~i&8aa{iF+xCnJjKDm^WG6;ZwwIouWJ}P_Zzlrt3F917vFWJm27M@cWbCyDMjS zx^f_y9B}Ma${L1^``C*vBDE2TD?OU1X@}ENMC8ZO9%Kt8>@3umpbj2v3>tX0Y7)H#K)U2L3`uhH8y(V`7rH*-YLo_w{o=k`_WgN29ev}&Th+6=T$Ukh^DYl(H z&Xo-YT)l}@ZAS(4Ux_8{sw6Ltm_{TVzs&fwNOqt!21mrDrHB9b>?U6yqAl<65b&~1jS zk2dA>5psg;51{9QMO4~KQ{IFVv$1&B@8`}rAE%}JTI6SdY?REw4xFMtNTe#b^4XrR zBS;OD5D+0Xi2E)uJe>zp1Cq`nH3;~FiqwGV-YC$jgf_&x-nBfqp@A0eN`!RNpB&>?L1U?V924dYdxU%~^kcI(z&7cKqyRgU&C&wNIm}+?myxjW@uwmuK=K_pY8osWLNs zBLcXVduD3sPO0+X))8h)mZi$Fwo(=0a537#hY(J^Ptwo8mt0uo?Y|kXVmu1-@h1Ot z2lxn&3E--5K?jFkq6xqbQNgHCsW$c!H5f+*7s5B;Ymo)XK2K_)b?^w{JlU{W2;Yo% z=X+c)2jARr-tR2>maaFZH~HP4a61XP?nz*Yo7htYrbXm?RPvr3zwS{8@j$%fl1 zPOH-D8$K^#p;i^nv5MkWC2;vna>Z@l?fjxi}PbF5^0Nh@scU9NYP|5OnA{q@+I{CBvO&m>{#I9 zlCDj1w9(rp*RW?%jiiLi=1aJq!otSQiu0wg7Yr+`5v(1wHPQjYGyW%F_*kSdrNqp{ zJZw|il;nm9_6faQgC>gA30lfxwADyQXt@ZQ>hh@b39$3CExP=!h{eCTE@ReCvB&<& zmW!aV~$`6ksk0>>4K*RbxaH@ z-YzLc+?IrM-y*kEV(X&d|By9oEbaLiLZ%nk^kua6qdU%%{f_XBqWy{IIk&-Rey*h+ zua6ArZ(ffW<5D~p03rN)c125l`Us5TKBFGXLDwtRO*8>VE5n0|qiVVspi?!DTSQWu zWlQPJvcj@ONG>d^a`UpqY2nI^%(h`0?(CA9c?)XB#-X@;g!&4<5EaX{%5>PQf3PG` zPWr5AIeK9;g6Pnv5cJv*JTi@7VZq*q`R`*ZHj1-@tQ)qO0(y%$<{ci@8-b_FudF{8 z3Ll8{039ssk}XCUFKNaY=5slTs3b(JJ`mmR6S%o&Li@vv-AiC zeVl0rybii@e}qgzK*h%$K0Ohx=OQ?Uz7_Yo^rm>$+s3n4531-WcT>%f9%|Mt$h-qg z!cB9L(rk~4jQh3=F(iRbwj4{k5l7P_f!LM*v|iHj7AQG%v^QQV+k_8doU_*tJEZKoPbT!}_W7oE^9gzn}4z?@r4c`Kg zexZE`Yduo%JoA1p+>InG><|RiKZC;T*$2FlmN53=LfYhh3mkuga^i7>)-(ROmqGBb zJ#{@vd^Di7UHQ92Jm@mDMU=gd7-O?9a!T4inS6=Es=w zsD})Wd1yits$jeHIM+NHIM7=m->>K#ME(4x{dte~1vp=+PwErP)QsyRF|NP+dYEy1%;72O;o=kGRR8IE_@Adk9BzWc zV0b)q9;Xe+Gb7KdFuNh^cH8Iw3QmYjf5d#>f~D9v?ydb0)yx~;aI9E;dyR+v^i}IPQ;#o0^?USXX8j?3nN@#WUuM_8sxRfd^|!vV zqB^`d{5lH9Wnv$PNcI#%^o20mS(4j^3&94=RH3DnFMBWYMh5Z9jjtk~O@XV*+maNA z?#IM~>+^1KxxfbZgIDFYNnRBGPsq%>@YrsK|BR1CcrS$i!eC4;VP5D*d@b8{8Rx83 z>OU6rO>iW6YIPo0(CW8Mh%0C-^&`e`BY62f^cuT@G7|I7Ja3=zH6`2Rj6;W-b+!u+ z8NoRd0af?~IB910EqEqdbLBXw@RMMSlN?JS$5d!wa0L+x4g+Jnt*^Bz1s=Yiti6!OlSijO1B z2j%$*d455j&q%IsA9gmAPxSIG065;3{3^ukiVbCaaM zMMCbA=fm=Rhde)2`g|$xd|#fwl#qW#uDsLd5kl1)@$$|Q@_w{D$GlS!a;3Ke&pi^m z&wB#zUzS)L>%B+ni4yiJ~W%JUWSe6>7ZSN?E0?|e}p{IP`mT%La~&wnqIu3R@u?~&(lH)%Pn zo8iYw$m#MtPo7uGbG)0F@0Rz6n82r?q<3FM?(Hpo<&@S-Rmsr8SBBS zp&mlGsE4I&=wXT1O2~~8a$67ad8xcVAn&h}_qWRPJreR!y!SiL^swH)jd#!aeh($* zXA=8M33qx4ry$S%Uh;5WFUuX3kkfiuUu$|WTd zd`6ysE)c#i&%c%DKldI{T;XJ(){wwHmbjpgd6&y`O&@8zQl7i|cCi0ywVv8fE#YpR zoV-7Vuccx}XfGId8oqMS-MLN|KG(@t2~Wmtkgsm+j45FO%2s@pU`bAuxVpskI&0-? zT)z5oUoN4%8ea>c{#)ehjq)`McYP4b+wfJwInsB^*L&q_jx0=8Ts{f`8wYD|JZvM@F=Tme|YV8W^xai z+(QTBG9;&Duixw+ZY*A@T72E%Bt-a@+87|gyzVm;+?|YsD&#c+Mz1Cj)w)Waf?xqWEcBM33Q7=kT+n1n6_n=SwoJkAJhJ3aL6sOEM$;T~9o;mQ3ItsWv<0ZY zprApeg4Q~2a=5@7F6f7Xrs4OD8UEO@!_gOCuHPu=1t1r`N$o#rZXG4BIGWrBQkOwa{_{CKR(J-SFx z2PIelus2?}SKzPT6n5tM_SMBm^aYD zI+wYYp)IRu5D@EJ=3?Fu;mw7GFmI^g&2=zul<;nF-5}*g8{Q34?mXe$B-XQv&NsZ9 zq}&AIZF6mtauW@2o0OX*yjNYX3a`TOUKQRH;T>=t5MHI>9T46$;q4HsTSZldw?pci zA-of=6H>0)@J>j%nZoNWmbHp%46nD7`!`E?g>i*(-1pgrR~W~bV9 zQsLFc)e7$t!>biuz3|3~eXgPg!y6~{EfZcKE+FL^4KE<&nuXU0%i^}Q7+#~4^9k>! zxSOQhWrlZ?l=BPk;kbu|*JgMR3ojtNU&j4XcpZlKOW}12@Aq-P7hcftelNT(;e8tS zsqn5eyibLv6S@GP?Zo|vMrw>-q)xs-|FBaZP!vhBI+rs-yR>vy3*6==){;d*T zWqhTSyUy?`rQB-a70K#YMc*;JA{XmiBfQ4=Mk%+}@EWDujlx?Uzgl?TGrZNpyIFX* z#NQ&kTMX|O;oT~{$KxLt-X_C)TzIz&@2U8wgm;JGJte$59Xa$y+D-Vp@jUlGKo__z ze8l%scN}Pw(90yJUDBOGuW=`W+v83Jz22SfK-7@F8G6>FZ$-}G^k0MiAe}Y*F#R{6 z4+_=tAEy5sa{ie9I_M|qZ=me4^v{qUlkrzjmGO7b$r)Vsr$QeQdLol^PG;U@Wzx#5 z%bW{)zMIt!db`jEgg%jV1#*6o73YYHdl__L&udvBQ z&7Tt&M-SvP_l{iFe~0M5BbUqGk<0mg3l4w|EO;+2T;tV!YvVD_LT>|g;KvzRXJ+9I zh&Kx)U0TTbBPBh)Fdwy47On?(ZsD!*IrNC+=k$|N?N@}4QJ3@^2-?zb0ccmhOF=dF zM&aHnbaOwR7tYV2hd>whEbY(fK>vpmbLdK;D}~q~9}y|IM#{6OgALVs2A{iMzLyvyX!-DzvE`|L94K0*5o+99Z> zKui8y(4z)Dr^^Y7%iEW>9$NIjD%bDQHb$YtX}3qAV`<-yL0c`HRpnom>sZ>4VTlp6 z-Qsg5|6|@`7C+AAe~g~A_*}~W82!?U*Fey2`SS2E?-dJwHZT8Ja-StZIWVI)EdE>% z|6_E};^Q*^WAwhok3IMwqazl7Y>EF>x&DUU;s7B3s$Bm_`*ReEcSoai_vcZ(boU91 zKjXyzs$6~D>#cOk<6}0?5(5P_Xe8sAPOA;COn8S38Y`%zOtdH->vVe7ARcR!!|efT zP~4VunsKg1Jo4#OIx38|S{a1jGZ#r7`AllC(RK@u()fZNT6pBMslY~0!kXmrUU za;eVd?Y8o$!$z-Iy=jAu_F4I~%|>rneQ39h4q63-`%zQh`&M5XTOLM7tU_9B5RZI6 zy5Ark`F>P$o|fdX?ngZ~I$;&j7K3=Ki)oLb^t_qwH9!Xh>5(s{BO1xb7n74ei%Mb1 zEJ0z(5<#~o+~Cf36w_GElXCs3&gK<6`ctcohB^ii|MeuslkUQ-l+b2Dx`q<+moQJ) zP(rH>`Y~EvLYoZQ?jGwXp*aauPGwHC(*$|Ws69$KHP~pIqntL_ z=%mG%k3t_Cer>4+{z zBcKJzX^~b>BX1Km?#Z9NJ2F=ab z2(;6nC4%+{YF5hx9Temse}-~Sr=x;As!Ndk@C30XyTB_MEcHo`s%fkskJ^#3%~4G? z2K_!G*?AE)8I+m1-kM1(1m$4Gxt%lVQ9;dWV|uc)miB5MR&lO#7A4@pD_iSDnf;t| zs6*KTIw~>fqfF&o zpnu&=MsPCFQq7YQTtpp$Zb#pzI~UQj2D#FwJC{&`?3B7K-=d}}T?4n}TNDzMLw&Pm zJHJJBGnkh{m063Oms02=jh19JIX(2KL6?EIj84wcye=RwEuPCr*U(BGg0wBQQjZ`9 z+PT8nN_z$AmFlM>QRVz}BC4F968NJ4(%bcxpGpMj8vInQc@ib~S&SU?D>XiwB{jMM z^{sV=(XZ6x?C(XP`s~}C?KCw?XMpOWbOy+;5wvV^256Jv1+#zT?4X^3bRVvueS&n$ zuAoCkl1KLnI-!whxq^!3bKBx*RrU^NCshd2T7tAxkk%3;zma4uL0YYmXbIAG!`qPk zGiQkQ3esA-=!hV#rHf7&N!HRu?mDSmv~`4~Z@iw{ea>#0V|b53%T?4N zNNc&8HVD#MuBI(UlG}1M?bHZuIpn;W4jSGw(6W+F3esAxp}d8Wwp>G{f^=K1p$d&; zT&|&3!}}Gqe4Ew^(ps*i`vvK?TuVERB)8>S+N%+?9CKbv#|`f_Xz8JfMO?eqvWk`p z(ppxLUyyFgDq5|Pv}F}-H@t(ey6b4KAg$$kIwDAGxt>lKNp8#arru5~Q_!hgKU&*76u9$itz{kUHH8>vVm z>Cufe!|)1oe&qZfwF=T&ZlcwKw3eG_laXXCH_>*DM9WRI-|&Xykn3hTE=X&+h1~oB zL$i)q5dCrE3#gAN%< z)^Z0O(+FCUU3XB*rP3Cl_10!8wUOJknWhS2ow=^>Q-dI_a|?B7N!cH^P>)?M*|mkX z*l4|V7d>esx9cw2FG%bBAsrK>b>2h7e>h_zqt32+Be&~bY7(S% z-cLP(w9X&X&0130`D5B@P&LXypw&48T@TP+jZoh<$AgsBz-`g7#zQnhP#oQu zv(b8prW&+4XPE0DS}e##_XzSE^z)n%;Pn{vDtJGkO$PlQyr0lEgZ>WQc6!#JwA>Lu z`vkFn8R7aV<#`DITvV1@Kh2%y`Wd+!xm+AA&YkD_Ih6~_q)T!a z1GO67<+=5)C+SIp0)h@26hgTt>4ZU7(?QTkA9A zHmEyih3gsoHiPD^&iS_M7gV7U`tEY>qRoOF^iJ+Z>jm0rBo7PPW6-C94jQyM=R2+! z=$J;(aud+lX0Ac6qZg@8kRHJosYej*HFH%@_n>VP#isnKJ25z z2K{^9$F6;J!l1)>$AGdV?2Dt%^Zw#`jT#L4GVcr5>l895x%WST)*6)EJK6ahnc6}?m9eoG;PZtDH9>kV3K(2m|& zac|HTL3;k)q$dUGQF@d17)iFQH|dy07>UVoZ&F@6w@jCNi^>J*d4G%c2+~^Kq632T zSii-E@UP0b6l3-l9oIbQTo0uGRXK0Uua0|*3IdF^mUpO3kk;}JP0$GC7R0?nhitUo zdY4YxDB1NcP2dk-#w93eq)vK*tQ?8a||yPA;cw_>c+&=^8$yGL4|K1>Zrm&qnL5KhjYfCARiG6HJpi34YJ)+~_2~iHYS5eDrK+6< z{TaMewMP)Q^VPUs%6+}AA->=*t~6C92+uhRy5rnxjvzfN>8im<@=8rt9fESGqu{H! zbhTMfCS6-##b>Bpn^|Wjwe>BHAE4ZKGSXVcs3JjnZpWw|jbt{*s4a%KwQsU(jC$0d z2m8*8KTqv8=&`;_;?GwH40@(-L;P5E)S%rcH&)@JEBF^j|K2w)Zk)<8=wRP3T;o-# zK_B(~vo&5#Flcj5bNqNU$DrfFYZBz3ulo9#_XDkSZlA9B3)E&ox?M zs1l-4YQhW^lD6m>t5&N8<$kLCuOsqE5cjWEE#AhpH!JS@Y&GIRMtZlH zqiO^-D;~2ss=**0vpK3qkZ#!=b+aJdhdFAiM$)o5YQK#}CCpLohq#6~s_8c_VXm4Z zD2L|so02e3ZT|`LniaQfzS{Y)mgJFNpte4udECy0%KfND+@nRRM38RvA~j-%F1NH_ zO~NA8DoFQXu^RiBF2{XXtQHH>eORno1?fI4Rx31;J}g$73~yz>d9KB3n?c_Z^sGVL zmc?qHAnrqI!eUkTxUP@;ute?O$w;^JVs%Uqx9rRKiU=IORvtg-~@wp^@AVt8W( z(DZkKK1{A-QaJ4EkXhiX$glklVLF0-)0O~O4!s0(A;FHb2)a5QN z{!79tCEq`RQQDIGWy1Ap#&eq2P;4cxR!cL2HZ8O}tUg52^f@+WFe5liam=_jheNb>6ViRv(@H2uZIpQyD49q9jR;!o9t zS9A@f=?4-YQLP5O-T#Bc9jeEmgFug~Ee8Fr|6dcIP>&k)aeqqsx!P~gr~TbYPpV@! z%1e4$rTj+M5I>+_(z7aWpGE}(1}FVeZGKIokpr5XyVQPzmgb$C^t?K1kQe9$g~JB^ z<0qYW8Q6YmC4_K7+irQ&V`+%q8f1`H(ht_f<%I#H$ z47zu~rAe=;P5U+P(E+}sH`HN+ULEj3;@hg^x0?6PfGd*@s-8DB`e;B;((lxMgT5HB z#(G~JH7KQ|F7bVpa6n5Ilr$#%UX6WAqah`0ti!6tpmBnZzpZ&yf)d`*sIH_n;jr3n z(52vgpqSG`w-;SrvL@*RRdkTCwt^2-QxPL=1s|vy;b|ZFfm&+N+x_oGx$QREp7f#G zZKJ1?K2ll#$y##g?f!d`K2~KmdL!u*wbDk1lm4tW+32&RPt{=?QS#^Nq(S>idL{ow zWxX5LnVI}owbn+($tToS8=af{g*s-ViODBb!tZpsccA4Ts>qyC8t{}47#ZQC&?Ms zCY$$Va+X!_L0D%(O13rDpc~vdDY@2un^%;QXC1S7!&36C4Sxui8g;5-bE>e)>4CRa4$$HvQFB(`jlcT??||vm!%A_9yREq{-Kl-Yp>0_Hf4}i_EA{p zx|G4z41;cPZ%P?r?X`J#rwp}@81#pd=HxQV`A1#u4<)OThg(H9+LAoNnqZ@!Cy%lg z+vxS=aw}w`Pm<5GZnjZE%2;c=jfzsnTYGFYCS`(k*hUwnTxik9;o9p{Dy#wH`66qZjkYA$Si5cX^W<69AsfA(JjXg|qfe6O zS$RiwTRtjDNU5{RZB&%9(3)eTF)52Jzl|~*3_H+9`oZ__>+h|uxi?!0Azm>e6;_3IIe3{a2%`nIs_+UzhwbbS%rCwq6*r<1E(Ar~A>cH1ix~zjXuQV0EG5P1P&ht}O zSYr*!gU+k08iNj?)mK|L+h|wHHP%jp20-Vv)*hSpW=fBha!l(y(Ep>9>#Pwr`Z8s; zwZfp`sNn`{gUw4yU1L3Jqu#0OtP=)}Lk;V#luyGtOH(&kBWzTa`aP@FM(3yAY;7=T zDr(qhZ8d22z=uDz7rfgn`b_JbJ#bgb?N*IJOHtoutHI{InR2JK-Jllmeqimg zc^{>0u~I(QS~{WgZmYiyP9gWm4HAoa&q z)^V-#?f#9a+pG~bYEOO0nqi~v)a_Q2jaH{VY^}7>O{tGsn{D*{)W@txZS#&WUNyRU#{3TrbuTr13@@({4>a$k4jSi;%(yFo1hpD@)RvUem`hwMCqpwnT zTYGGj(Cb&$A%hNLZ`@;@w9)pYSFF6h>e>(Xe>!QeRc@m_N&jZevC$hz`z*hW4kx{C zt+ml-N&Bt)ZA8g$Si5YLnS8)HXrtofx2+R4nwWgh%KBTl_H&cpwMN)zX7VAc#zqTL z-?v(A)Si0ST5Y4%seiDx+UWbKN33UU^l0iItphgtRq9dexQz~`{>gHm2-p5u>M^U# zMhU$>vt}5ycHrGf$F0pa+Me`R>xhjeCZDjz{#|QXI}rQ3wbn-MsVA-bZFDg8AJ#4# z;Scs4?k{w?@1h3F(PX3TNlwR;Haa&s&XMwESaM==f}_Dk?WswQl{Pw%2CW^~n40HkwR!ES`Hmi&_gZR! zV~fo@m|E!AY4g5HEpnW+c?rGxJF>pgwcmuclsL+4v^{B%W2%kLO)hmTwb8`np^lJ2 z-$h%>95>s%_SE5yZ8q<<)De!|Ht%5SD91sY_f=}SgMZeYW4@aPCiFVbQDM*xZu||g z<5596#vJEZQN)ru#vJF^WhD8eG0w5iAdYayIS$2?+YnRkSWG$jvr*K(ap2<#;~WM5 zS>OxQMFHVJZU7k z)zch%4B~V6G)IE`m6k3y-LW&O+;m5t@N`5y-BD`Lf&Rt4rsJ=il(hOl|6#qV9s3RX z)4+#ZGaV~%jT?^AYaM%{bk;i73Qy~-b!?82JQyRnEk^Rm7|CNXl6zt#55!11i_sJ_ zf3=PyF_I@@B=ce<6XJCLc+6@Yd4lvP)jG;zBuir?$Hquj#7NGFkz8z$HE2w)T1Q8` zt}lJigkG~8BN8>5G3cURb@89x7Q-aVtlPV+e>co#l61eC~|8wpt!MDy<;1` zB0N0ujgGqFNZ%VBPYO?ue4}HJL0yCVy}-k_hO^E!gO>O5I%Z@?w6r){qqMX*77I^n z0WU^!WsKyC7|FFUk~halZjO=MX3*wAt9rFKN^&A?X?5HmRj$=hE#yB$tPnZ z>tZCEVkGy*NQPo0SI0;mijmwDBYA&}=_yBY8MRlK<-YFKPetG}p~K`w0ENt$(A` z5=|eGoD-lH{o}NB-*nFJ2dZe8q$6c5s*-%pIa8YD75;jR=?|ssgOYwCDqqWREsEY2 z8j-}`LP^dsP>W82@?Vv)TyBFN^XT;H8ZLsQMKvPNv#E%46#jNFoVMt@lB4T=>y)&^w1n+8EU9b! zdihy-EU)c6I=u<`ikMpTlRUTEK|3VpPf=9&Lgz&HTBn2gT(%ojk#2*|XL*YT=CaO5 zgsV0DzbT_gx9YY0*j8EeU(HGE!~M+@s{2)pv_(2!kE+hE?&C(h*8pnKWuOk~lv1aU zBx}$u4AZL%IOoO!E_*AegYE)#(gUC_dIB_#c7w*FUbj1eeuH!(y#bm;he4C+&!8z} z^>w>bDI2sG4FXN03Q#xIfu>VKU)`5eYv}~nC3VF~UGY*^g4C5LbtOq%$x>H})RiiA z^^&^Mq%ODAl`eHOyYkcZF^dsz>+@Ni((Rpq*B9dd|lp^Qp*0 zr%#ux>c@3`CaZFxq)(UO97SH6t7}xGckt+aoL4%$x9m__r}kQXia5U%RMAYK+((Np zm9*ZAk4xFxki&o9%->7ofX8;>@0+>QqsX^tr^ufzoeW8fj*1Mo%92u!GnCT%yPk{a zv~J0?Vz!WrK=Ie)h?e!fX_@_3%jmkapV2u?E#u$xUaa?AoxfW2M9b*1+UMxj>fKZK zQTt3iwt7@~FVm|S5_HbKBwDQ z^hka^_nS+8y`KLk-2bUIe7&C2Tlhbf6J78BrvH03{J-P&zvK4bv4L+E&7LjFJzM@P zIfv>K_vvXpQ~DgoF)g37I5O!!h~t?dpe`B?if=yzRT5!ZvH>Q#_^P2d>hAlJM$<7pF65JRPPB=m=hiX#yR^ z>j++l@m;3J@H&du5xfrLyHAhfbquegcpbs(Fn+uA2wsP&f==Rf1h2z18R^MLPeytw zoxtljUdQk{iq{dm4%0N`;hPKaI*Qj3ybj|tv`$rpvQ@xUqKE(fV;$+gR$-d1_^$#d zgRZyAB)?qf1o`Hvsq$BJHA3rzE*07&)Tdq@bYnuI$X}t}8FW*xx%B>^O}(yA|43Vh zPnv!>XkCI&eLiSQFQ58i(7nBeDE@1@RZ?TM)L5;$vL5JFt@wKZJSuMR!@brC_ZFdd zs?5QU_qtOR41NZ5z~Eh=!v?((Y7W4W5_wymFQFbcg?N7IIN?{>Zcg`(oS=6Ug zIeUv_7Fu{aa)y=;1sw^Q0vQt?jV;j6-|J9ponLxiTCsHjXt$bF8cO^-`uHHYOrNKD z(DpnnD}6Yv+6tAfOn9EImYnsaKT8{F-G=l?>%LMRtE299pgT%;A^)iR*J(@8hGdt= zdb;#A0t1-g8O2;P-QNxj5LT!WBTTAGL;$+wJG+{`&dz0A1I8{BQ zEs^OI)bmf{j8)%q77rxiXAVZozK&&L)N%=3wN(@ zKegV0=1;9Z4Y|erGUo1f)HOc4(fuiXIpl6oZu#@nYbeit(a;fT;~e9L-shg|m^8HB zHQ6x(wA%4e--@(q$HJkvxSuD_&_7!vEm!)l(}q~E9Nez4hW%*mT4%bW9UAsy2Cu_> z^$gvJ^}BB9>a>G&gL?;ZI)?nheUR=zT?gs@q1Qp%&xXE;`P!KNGCpw08eX<`4}HUZ zROaQV^!+I9f#eY7&HTuHT=GwfuR9L;xU`ev6|ZpYOZyb1cA!`X=Su^*ta0_(BP)@MA|;=vh?HPi+t+YVIAqk z>Q}?M($}F?E7PB{ULUq9{T9c&!!}yCIF5i`p*|b7CVix~d55d)d+GN$a?3WQ-{}}s z_Jj1f>VmRl*F7kk?7GuYhn!C_3v7WcW%s6Ep*qSQ0$l;RL|s?*2;{$8_C)%FG6Fjs zTgskE-{E+;?D_Pm#NY8WRld!sM$$Esu9I|~q?by1sid1E-6UyzTP?m3s!6C5?d19T zS=k%z6vg>jl2ah*5=oayx?IvVQfh*v>mFH@YKBKawjzf|&5B)>rN3nafu@(U!tMl|~+r$#i_Nlr*|>Lh2Wt&#jXp-Y8MmHlhA;{9ubjK<5>(`CO( zuao?cNN{cj?=u<)2~|B)gcdx0t#mo$F$c zn(Sg9o9xRq1sLHeD%iqoEzoTtRPcH=u{obKI{vlrCm+^e21TjAQPUMl;xYd><< zTl>{71`kc!j~YKq->VEFTvQCxtrE z9{lyD$ea}F#IARH+Hti!lQK>Uby}S6Rxg*uW$^0Q=6K%mW?5233d+XuUR>5EqX6l( zPR_x%r&-*#66}&rcZ%$rDbUHSU7hn;db!2X!UT)$t<3s;S#idL7^Oj=?++@^C{~}8 zO~~LCItBEvWiyaN=S;}>$l_g~3Hs+`_@$PWqGz?}30W!UT;~pnHowKSY_PJBv%xAn zr#OR4VF!V?Uzpw`+$L#ZleCcc%1bgfL1wM9$$EKk6ZCWY{ldM|Dm|wo<4$Wh=oZnl zMaE}~^lJ-bzDVB!on0CCOMA9Udv;3gJEitA>}8*&{~bMAnUUz^*-sbR8+4na7p&0or``qnrBFw& z`wCY(GchBTt^w3o*eLWpS0ChDAICW*3CwjQ4kMh&5;HO&nK(X^Dd$|DIJ7q;CH-RJ zMMyi6HiA|rJ;wP-?+azg7n8nVZgNIGxXD97dy-$YAd@@==@*kP26d!#GB@RE(Cbq~ zf6DvJP5FYksTqC1O&tY#edAno~diQml@BTjLr$5H|>92rJ9m>6)TE=v@(EHEfbjfhZ5$Zgb(@mq8 z7EPV$Y%8p&;vV$~JygZ*-!+|S&kUwph3*o1NGMfvev!}$p>&bt2(1v>Ahc(u=&4zs zfVyfqT_LnVXphjXLU##0B$Q@}q|gSTJwmq%JtUN7vrL1~9-&)>?h<-PD9vG+BB2#R z8-(@<-70jK&_hCLuE-0m5ZWNLN9b0eyM!JRO7lcsXob)Qp*=#k3f(32kWiX0@e{dp@)Q0oyZHV5ZWNLN9b0eyM!JRN()3@Xob)Qp*=#k3f(32kWgAE@RHVEwzx=ScsEL@>ILU#$J zZ;5816+#~mHN-7VDF$~jYot`=H!Im=fFeJA7Hj1M!8W&9=M?-^fZ z;75}(Gct2C3o{2~4$d5%IW}`r=8Vj_nTs-)WwvCtWnPuJD)Yw7Et&UZK9Kp7%x5x_ zv(mE`Wi8F}WHo19p4E}nm32+lby;h&)@N{|eB70``g6xLu=4^lVs_b>yw`AX$eNXnb?48-KWWSz$Zccqp zOHO-EFz2e=>vNyYeJ?jL&z+Z>*PPdtcWd6Ac~9g$pZDv$SMz?GcPwv2?|HrJdtcf6 zj^6k5e!TZny?@brckfqvzuWuc-kp=5NT~lz&(LZ}Z>I|9$@P{4ew4`lR*A?$fW&kUk^(jO#P0&$K=> z`^@dLsLv&R8v9(?XI-ByeIDuaLZ9FEInw7upM-+Eg3^Mq1v3g37hF={Ex4?pvtUKR zw+mJme7E4{f;$TCF8EQw_JYR?o-Wu`@auwi3O*>v>pQIPd3`7Ko!hsy?^S)T>w9zG zEq#-4&zX#y&=lNprs9rqD1Jd@7^TrU_|}VZ{Fci3)DJg{CAjq)h+Do=-0Tg(OwP#o z$`Ph(GsiiJHVEyX&gok+CxXUjaXKT5(;IW!5e3|l8wA~w%lY?7`U#=G7P>L_IdGTe zG1n{fj@*}!L;0MOl0PC2zgwO^2DC7L0_eGtGevS1NY2LGKOp~{f=@up3qA*(A?c+; z@5tR6PjpG)k3p}?XWCTAI@<~#Li(jbZs)oEN)w1C^cxPUb2JU)a{iBn%Q+UcV7wHy zf?D`W6#fOAm7o?~k1@oPd5oe(-;t4BgK@PGVWvUmE>Mf^#?380EvPb)GXm72a+M7_ zTIGU{QN59W9;ijRf2J0@R{TH4^C{s6`<)8tE=ji+-rafc{jS54uB*L;ho+7Jjql0;G3> zTJ(gPi1g1uEqX;wLi$Bei(Xcfk^U8^MZZy%NWTDT(H=Do>0g6d^pcv6^lnfKznD@D z`k|T$dPLQNexzoDo=|f^$(j$UtOcN!wFu*SGk%jKg|gI3puN=dpgHQ-pn2+L&_U`7 z+8>{UvCSp^ZJ%K@ipJq~dm7E8x%icrOEG5w>cnhaN9&;L4*DTIfM5CfIlgS*CHgno zkKf#RAHSw^40qoDpg5JHvJ_7Is#FbEJ6a$50Ll9g&@Sw+@4)`iw=>swZ%b-5L^uC^@o z8|S4Zikv%dav(1=YhUAJ3r|AJ-i-KAM|}3uSe8@zB7=& z6R+2R{{yez;`K3JbCCBiUXS6m*7;uFO6!k!{Rywn@cJuW@AZAe`U0j$Ec&y zauz;fB^17CrQ$ukaH})5@Ls%D(tCZs>&(GBr}Od3DO^E?crPiuA7!&0LkovG^HDCp z@W*)nSmhU%!XCHR~e z{!OS2GaCdL>+t%0?cVx%-j+^pFz5@kQ}yim-NBHz zjpj_AQfZRN3whd`Je^G@qt3e`MAOm40O_bzpv5DjiFhA&>UZTyJwl-yQtIC(c$f+ zk|wX;+u{j%M~K#GD?;9O9)KWCxX>SH^!V$q^mO{TM6kZm?+FI$N1c^}uWLU`newx8 z#+;RN-dQ>4pOrKAtekOY<%}G0cGk$Vv(7#YBhNk#BhNk%BhNk(BhNk*BhNk-BhNk< z_?^kK&D5x~kH)BxR9iD`MWeSv>^v$R1Ov8JB!aDV00agY&y<}(1Z*29pd5mmzdvm}f2_~-< zP?bO638%4gOzxCGz;BYavWQyLLzTfRYXY7oEQ~r&@N#KKZ4Ju?y`i9pA`yNMQgzsM z%nx}xFbLdER#UCJBe~V^J6=CTJH1UlxQHvgCS!gV7T}e>pts3n825wGsjlnvvk$f~>S`lRNShq;F*MmDid$^nJ z&At{Ysbq(+pxqam913-w;y=#pnWBy6%o3*<#+ij`Ys3*sTkYhTMeT`(Fi+3FmO8t{ zte9v3+Hb*@$QRvJCs@sDd zde_1Vj0R^Gj~LWh_z@F2v%o@+-`6A?73zxyXBMxm(ft5Dv%uxv&UUYV^r+!Yem}JZ z8v~tw-!ij%4xiP9#l!1B@210fyiM9{s@#Mwy)#x%YiDuZ=dg3zCBmC}aQJj@J8PpT z!83?aHQb8{1fZvS+7(^!9a<^ZK)g_V%eq2dn&NALyYjZMt3ens&nsTX)LcDVG8c9F zU~a^5g19+&GOwr2h}QUm9A!*ppAz7}0>9TY!xL=P!3RPBPut|qPEU8F%5dA^f2DD{ z%j}K0oU~Kw($%1atku+}foYKTi{4Ocph>q&LZ#vJyv^Yt2ri@Xa#T{+iWWBkg?x=s z4VfJ5Zf~6D4R-kv9dWwSl_)!v99nd)u!pqg55+cIxwqzQFhh?~Hk*4`LsLaFd%N=PT;Mu^3l0&O(Cat0EJ z+05L`Gu89#bp|@niYpOc1<`U7Iz=iur9@?*tpiipX%|78QT3EApPzRe7%ZAt1*h)` zMfZ0)e#qG#2;O_Vb?81-wt5iTi3aR{?ZE(=IIaCkUuU45<3U8Qk+IZDBY5_e-p*!! zV0k158jy!lS`;zzl|F8iSr=?1=whkB?3|u1GO6+trr5n~gUnoi-c3BX4 z~RVc%y`=1iD&UPaD0dK2J+~AP55rl1vO6KGa;v&SW;WP%)55 zPq2BlhPx5AX|G=!>Qrx|ug&8pGly))W^!v0GPi;QJ7;sK@_X=8+j{;YsweaI?cuS| z<37K`?+eYAa0v4s8T*AMWUljcwjf-PIDc|y3p@;Bayx(u=gjU3&2E;!|8(B0F26sL zJ2lY7L#+*EeyAHkIZu6)PdtD>GIVqNo)AY5_(gCtLhUWOGb}HMk+3;O4KG}z=5c4j z?l3HeefbKr$cz`@p@M~k*M~R-y$bn=g^3Auiy3p&<@L1lsRYp+7LDXC4EUN5qOi#e z#^wTdG6jJaRZsN=I|4y%Rx*T`ZJb58myyA!HjXafox7xH@}V&Uwm-e6;=PZmXlsa>DU;5@J2vqDl)b<}b{OMc1byen|9 zU(TB=1(sb#9XbuM60WR7gl1t6nbOs4r?EEJ#d6m0MyVSiuT;1US)Evy?VvDQV$APg z`jKWva5**mDA-AXj(RpNU!yNX^S%CNs>Ai#LJA-6@rzZy6(!UfBo9sZhN7IWv5v_6 z1&4>Xb5xD+Bw84?aO45!)Qx&O;Vy&TM)(P-zTF#UK@5}TYeD-6GL-@BY=n8h3Tk7~ z$xYg`Xd&5DJnex{D^?r0^ShRbBZ2it34kGn#eI}Xj^fp$UnQ5rYt#ptOX|cEVcPL? zKNulwM)k|kfF|nVdkK?%^3eq zTDF{~>1z@3N`Yo%GvPxVJaYYukeNO`i0vYwj2 za?v-#*U~BpJ%$6I2La=#usevJH4{heGYHGYN}KXX}36Ty_* zzIlmx-nPJ%c0lv5TUQDH4P&Ryk<^KhiR}mDsSSb0jKfFS`zr$--E{%-aNMb{uj&Fi zm@-2$l6c5uxV~}!qQ*sriLb@ z#*5TC=8xrBDj$qu>Ojz~45Y8XGJvH+}2?emsp{-FCI%nCV6bjf$tnlz9Auk?W za$xhl9C01D&!n+&>2Q58I)xMDU<3?^utW|c#bJHD#~vBJZCYU?ecvWSHd5}_baE8c zFN?^?J(*_6y_inQWtUFM<(5v$WtJrM<&{oGRF9IYI-M(5b2=HRVpODxQF7I$1tPVL ziqtkLQrqYe1j`6|aAAu}Rc~ixYxeP-6dbOcL&Rozd13=#d*vkO)p*-m5UFyK?g zwDYOXh=rPTA8~sro55u6k=f|F$*$9`uMsD>5H*J69un_}i$OUOCOPhD=cru^b~Cst zjxTVq;gefPmYpwHiKAXi0JjcsVWu}|XH@SZe0^SlAJ*+9)~tQq?B=Mlv&5INF0-_w z>zIsFRWv@$a-{U>CTX%ywMnVrbZL`)s&rIer7x!!JYAw$cc)qRrTJ6=YL-!+(#>LK zBK6?uLDHpl;nT`S+nMQLbe8Ewbk?aVMo@D*SKQnf@Q38-i)jXzrMk}U>D~}da(t__ zP&O_(22}?Ea593I2c3uWF)jzpg*P_(s2gnd4}>fjnwfNf=zDKKn4RHaAsa10eS6NK z6K=Z^A7WGCmZ6y(^@O7OAr@eY)p;)W!p?*6b-2dH%D|Pguf5J249O{&LP*Wy&4z-l zUCoGk;bO4;o-)s<1ST9jZFcSaQ{{g7(&AtG;Y z5sL^CZg<)iwL&D=;c3K(0ykn?_wead{1Ae7Tx;rH@YFPWJGDXM^xx>k^g~ye+Zb^d z;VdZ-J>dv6qF^N+b>b3G&JnR3J=4A>KIjHp1O6s7yuGRuM+qi3@{fjY#gFf zHX;Oh9%^c)0duN+xDXlmWLCg!wFm===y3r%e+`BGDRjGzUT=zNLtHN zE_HVBjZ`>H
    $aQO+X-Q+fIS#1Cp*vn@Gx_JJn1`mR}IJDqCR0s8=HTY+fFb|Vx zF%J77d>+Am{zLOdQFmP@;^1zUk>ZsBZ!<>U$FZx{$2TVY9FLlfzGjwThZ$zXl!;`w z$+IkY7QKy0JSkp7Kf-FGX1gW)oTOQ2a~~ui;KY3FrOkqxC22-MduGlN2MAlO;)5M- zPCB~K?D~2MH)fBTf65ba9LRj!YC9-r`)}e?9G~U+rjKt+d9h$8obC-t`*1zRQL%}f z`KHVh;>Cr5N9fJd6Xd&@DV0-wo$|c0TP{_mt-ubAUO~B-RrppR2wUg7NgX3hZg0Y~ z1}?`79tR*`kH1kmh#K{|M;^)(0v}v6;}Kx^E-(^9wgp>oj>09RZY19s#fqN!+OQL6 zM9eW_FCCJFX8Ca48C^x}l_K^bc!@fM{~_L!B#mzQ^~V~p9l&FDbjzI&;yk%T2nM=f zJUqhU$1nlQ;keL!WN}^m2?ntkcn*!`n;oGO$NTxqJsk*`_@rMiQHV4YW26uD=xued z))Q)M#U&yp7MC(|D;Iu;W^Rq9Qx(M9jKss ze*OF<^XsP7*4NISx}atn1-e4e50A_gpTodxajLQlhS46fKV5==OW!N8b%!0Kd1PVV zG0H&Tmd@8^IH<64_N=Pv>FCK&{B`n#3*o{rH)wJf_6IBl3{idTCr4&@{kT_=Yx=qX zuMxK3aAs$>46@iOSaOyRJFM{IFm16|L8cEi{OJ%cFDbStzPN4DM}0oy^2)Fm5gb0& zO=mZC1_B{jI8kdr%E)0kX3gD1~?f@fCo_KO$W!iH1eoibY+JC@6XtEz4mN8RF zi>hZ$o>N^PlZOZ9uq01sNS@vBT;VdY9>?fFBf-CmAx6w_ZkHFsb$UM4%T)q4KYKsX zpEi->8lJVBGm(a}J?J}z(UF7g=*Yo#bmU+=I&!cb9Tl=f4$Pw?2jqBOJ!{ zYa?z>%-Ipo5&Z#Jw}`=W)anpTyEZ9gdrl6|VGan=G`B7QSB6iRad6FskEl6dJ^*8K zwDC&3AdYY`93XG$0+vJr=7hKX>L3r=>`r~&=3TW~-*LNA zRtF`VoG;;IUBEn0std^DI`|Fq4)+aXstcG$cRGQGcd}35xt-3C#C&X;a07J#T&e_WH0d(?fu*j2~BeaYHE`L5JoCu&e2c*k#G(tGON3 zm!7q_ldJWtFf!+rPw|EDa7!Mu=wfirh~e?6o^XZit-?=?b=08+C0>+{P3K!?M4%=Z z3)b)%z?|`g5hGIYJlN;M+VR+(Z+h_H4y_13`QxB8IY?a4zAT{U9~Po7VyfFbxC_OHBSdvnEmc^Rvca)_Y{`DaBke7s zpL5_8F6Y+DQqe=p*|@gRLj{&3W_N}+dgRFBXvXmmkH6cy5p8wo4h8j!hJ+--x=km- zt`xz!UZ7R{94Mlg>xCD=q21dA6UI~R@Pd=m8r&R~6xtsQ>aaPgli^~f8Ky#e@UhA` z5W#ZwA`V9ESc{v3sG-HtCaC92Yc`jEFwJoH5k24>i{qo$xC(A8`!JbT+kta#v<26C zF2j{M=VH+!gKAbTfA5p+3K008vd_5l} z9hQZEi1;g+)Z6yZ8w z&p zKPEjKPg41_)s7cXC-Z{cqM3bGn>85?sIZ$b7R?&tyg-^d z5vfslRiME$o#P-o6>=4j7=bd}#Ol+k;fh;O>2fqOi1&6hkQ>4IMaU~6+@|AkpHCXy zEPr9qhMW+oC4;2q3uW*oqYYzF??lu%Mh1%8F$TkYAvoMJ9@a5-D|nz<@ajYj0o1{% zX4DfvOH?8cKo{~Txmjw*`IV^pe`+L@>uS0Nf53D#jo^1j8D8JQ>k^{eX7tpL+PGCb zF-9gaoKNXGXE+T9Z=~pHmonrifsVyQeP^NVe*B+;wsXsyU>f{%(u3)zRTuQfKup5x ze7txfwarDQYy_t60t_J!S`kfv#Cb?fL3%RM*7J>31_p4v!e!kR*~K&Gh^)3+4v z;g)_F+zPySW${`ITfv#8WF&@MPXw3X)i)9HBhbuZnuAJt*4t1G+dFq%TTc=4x=<;v zwD5{!_fUk_72t4_!wzHw)&oo8Nh%t*{_Kq#CCjXc$}!86oRyemo_DsN(K7GlKqJ5z z0n1~broGnJH4+a5(To5Z#0}L8f;+@6!;5C&V~$$&3e)DG*wCYM)c7KU2Mm(R<3K* z@@j4g#!atJJ(XpUk9En~EojZ*brX?gSIRcXroiq)O)P*$UXx>CK1CF5Mv*o~90RFR1HpfnZT>|@iZqNl7is{l+b_&yw$6MTF$g*oWb0za+YVjc)Oex!C7v8z^ zY_~k&+-#nHo@~yGZOHRkP|c%0)r=5(whLt&nuKb3GvEcKw+P;x^z4j6^UgOukoV$A zkh~BxI0l}sQaXk1pvQQ4fJU^-!m2`AJ89mWI)OZBsLnZcBV!jFF3YaC2;E|=x4mf1 zsx30Nx^2AIk4GDLiF13oeH<0A_nr*z8#~A0;m{jsKs+8>lioCWi{+NGm2urFwM4dK z&cVhppc<{J21iE$^Dxx-&IJ)jrAf8BhF{gZ0~ z?msBik5OdyP-7TeY1QBt+l->v(-O^6p6!E3oy;*{HjmU;;%BHeG zT?tK>gGcE>Y=FFSwF!n$%Q9$U-~^zVgH>#Q>SCTofF@VDla+GTVJ!nb+xnBrmvxx7PCYIs?4D=3kTlUqReJ}q7Z zEF~Cn93Qgg7BSQCCXZ6nYLVXvE}rt@#h)=6j=v3N+0ir#XqV7;fEC+2Oe?w@Zg0!%YxE>C+(*!bc~WC$VgH zHCn;-a*iKC3NLT!xvLU@S6TXRG8jSYLp=;$x9ca4NO@nV^QCBTlIJu8>=;Pa$x zUT4;LCgX3Bj_8SCm(Fn*8$ei_O6EZP91Pn6QV5Q9U|WwCM-93IYdo2a?;J?YCw2Z* zbe#ity|C(_Q%@#7qk_Ch+{K8Op7BJE!KBWumnn+&ebqD@G2MKb5;g-p*f>aIFsWx< zhU{+~7>AE+qh;IR^F|eT;qaH8ci5|hSEvqwW}^X-6&49hBcV?CJQQAw(VGg}F0T;P z^8?-i$HED4RU8VAha2DxnQ=G*(%i^gvg13OTN@`k%tBPadw||_d2z7e>xelLMuwvl zUV!J|73;LrCHff0hR7~Q?{8xH*W_I_-x zdUNLk2_Is@a*R(MEImk*<(SPuMTHCmub3*E4^22MC z*R2|tf!f1U!)?-`GB2Fil6pfAALzR=!|RGLm*-0y!-wn(#nE#I^my|5YLrCSd?zpq zvmeIl zn;6_obW9)q__(Bf>rVn4dZ@r?1b*IIl3{TZG4!Jl3NSq4R7-H~1X6u86Ei zoq6^^7FAS^npKo4aK84#yJvJs)vC?Y>v2 zDKtZx#s?0b3B9P#KFQoD_FnAwc`=4>1UNAAV+iz(0(<{((>WYd-F#y=3hiTWIvXpi z8iTL-F$A;*Xj8e4Z5P(wt+FQg6Nry zw4qY0M~CQoQE{!6F}4t~9bQ%eSOw2n_^QT>IE!1!oBnt5uo3WnHXh~}K6mhrrpNvC zOP3=07Vd{4_lHBoJUKSx^~?TNpIy#=@p8Hbgr@xO(>f26w;Yx8QrFi+r@LuBnzv%?*$MDfJ-Z)vQn3y~UBXcW z%W?O@=Jc;ZsMs=J6GHX;AYZ)KSX|qL!&7}*8}Vv-uRGI%4zDF$Vj47X!*u)@ZP{$a z(L)hl^s&u6H4ae=xQt)cfUf;tbvq*eHCLuP`n<#QzR$OXk$7}6 zb`0J-!XbA=o>wDF>hp<83_CU)uQH$_Y=l)%5O(q$T1-ae;m|_6f@m8$)rGJoQskZG}{|r$>2EC7VAyeNomVx>pr_riMDjM-LNOo!*bRO+{<|l z^2L9-crbpfz?YCC#C&2OCB$y7T;BrJ6U2abNVLJX3i{?Gwy)vcL}i{na!I9#r z4w;WBrEti65ik;8em7ih;q=Kzd(D4c?=-k=e!daDsS7_e94F876RO!XBmduEPwbdPxwreQ7c(?&i9HOaW}(Ntd;h3_4;wCYm5fWZue&45`*GrLC-8)z=T&5o`@9Us$nb|r5}4nKUHM^T#3W^LOHCk zbKnco@TIAKV#%dAoSZJ@x!Jlz_-a&d7yRsluLk&-%KzMe@PG6Rks8zqj_+KElgP)Ci?qExZBdP~H< zC>ok6Eu7OAXlVl-8fi@_B_E_CjF1*f^TB|n78DGO9|{Jh&_V;1+7E^4w}JHcf1bV0 z%Y9Q5q0r{ubJkw(>silw*7LU3y}PTW*sZM=ba~DaV$ZhZ^CT`b_eRH)e>k#_UMoE= zoC?cb?yAY0?`i94S%USZUA}!;?=>Lr1faP+w1`^K?p&Y+UD!QME{}zh$LZI-R6fR{ z|0tgPhj6gyVbLz1)6RBjtFn)3V!MsJxE+^i;3Jsq;Ax|iFQSqE>HO5j50p=`TFYEr zmbcCTCAGv*7cnoEsuk6#qgMHLTd5#S1Fx4xB**gNS^Z)!!E%vI5M#xr?r?O#*^iv; z!2=7Ixi*}wJ&SvLc@*VDD>s<-rLm&H4XpUHZ0>0CmH8w;`pNkqA7X0UACVY*3c$W4 z_3!(^`(Emy%LlDV7)0B98B;Pb!g{za9sC|X3BS*vbq;-e!dA}r&V1eC!&2m=n`?JT z&b#9ie|xi6iOiU&P`}cZ1w}M@^MQq=yKL??Iqhd?u4V`2LR?I1co$Q2=Z=d)KT?;C zE8!?c<38NmTK+a4&|Lw3on!8ElJhdCB(66~-n&27<+}_-^&*Ae?XP8(h6^Cu2o^*X zb0$M-^0$5|Nf)79`PhE;?74Fs2SaXghiMN=SJZC2(X3&eN8i9Yi;*u*bZ;iIbH@cu zFwn}}7~$yPTGTyV2@)8xbN2@@sIrTU!|zu8jNveB=w<1$-0R)|E@M5Oe0@LQmc0nU z4jo6rn7o1e-2o6jw*$O5@%d#KNt{KRUY=Oo@b5rupUBvM3?p4!F6_DJpq!tYu1F>9 zp{S8^84hwJYGtX;O0A8bj`{ro$-n5J+PIfP#Oq`lO@2o@l~zwCt0da?i&*nLB|A>L zYcGZ`l*qDr$^ia#!kvn}5FzfixSsI}e`5zou0C=Lm$-IS)Vm)iHd1JBwQr|_7c^E8 zqie*vtEO8u0)MU#Z&#}3TQSgj=*bl=;VYP?t^ZEuAhWjkVjGSf3Wp2K{GY`%Bdq4k&YVPCt zIC8hqj6-FedC#OKmQ3n?YluV5*qdv)>T`D51Qn2oa?P>DPZ8=Sm;6F5-zPEVD~ ziJkp~!Q^BV5X-wkS5brDbdT2!fc2&hKKyR&O@MHVU8nNd?*{5JPUmNSyUAPrKx%=+ z?lElMZJb5G?Fat$4YNu8-A0fa6bLe&e7iCYH!R*QW^Kz+Abd)nl4doZ>dkg*Q5Jca zl$%!bNy#;~lZth1(+@FELu}d!0q>F_vd-md3pcdo&Xs3c3{}(KKDFndZ23r+X&pu6 zj65zMY*(u0etYB3$4ny}XNu0nJj7_{Lo5r~aIpLk?k^`pq3N8ZWOs|fsgK~?RX-Aj zdmmEn)l)oqcQ0PC|E@UJ-Z}P@RRJ~U#aic`4oBIo&bI~rUOZ;+opX%!^IOKtWA1d$ zE;}v5sPXi~-Yh_JT?hVmqS6G1wASd;q;`TTSEN)Xxs=rIZn&5TTrN(zq15iAiPL+? z-HVLhpN)vqxuz#>CSm1_LwdISXgcBD9lAU19OH4*A$BKmu_Cz^#WjPwUC0jx2rBEc zgj!y4akCG%;)}9t`&x$HKJZ!|S#;CvWMv$d9q?(32b-F7O;Cro<@b)`=84iVsN_!s zdXMsqCjVL)EokK=y>3TF$u0Yn3tmzYp{`*CNi*lQ?#W-IC$SOV;bMf+A9bX?=zrIi zkg_FdCHu$^RTZE6OXs|HdE}i`qDjke(fazDhRd^I3&oQ^`6NL2slGV#qH(hSDU|O% zmuAb_;&x46AjlWtd{-KsJA#*QzKFH8x?=MM>N-=C4aQ%-8($TPa*=L&WCLT(Z$0aR zfL|4vdn2?1w`&<=9Nltu`{BWjOw(QO(#}t{yH=AA7wT`NR~JaKz@$ASLcYqqLLuk?ns+MaG&iqi6RLlzlisQ^R=fm9&hxaBl@d0c|7Tl z!XIaNq#D1P9_C5Xo=Tr<)8}Y3eK{_HW%}1~=3Q2s31B}NJD}-tyB78>Wa{KIbwZ|2n0f17`^<7r=ZS$cI%=~X z9c{-b{8^Okvq;wqUbw|N8P3(oskoZFO4mhP5}vW}RICQ4;?d+Agl*lnvVCA zm4^9}axg!ADJioR^)Qd03Gt-KlBP%+8Uvyhx}+)(kFRq(gmSh69W`r_rO%K?_Ecwi&s^f$qz$7sIb6);&40;S8&;}K z$~KdJ&a|^m7Uh&C>Nm?YPSyveY;=;>=q00*qm!VCCknQ0g+BEfRgA2y+I!o?>fbcF zDT^3%E2&Pb5UFi9RwsI_tHy2Wq+cfePNzS*Egg3mGRu6P)uv7DP&!VkXR0sR6su=4 zfEG3kvR>adkj1+KwXYxzbp~EM2E1(OGQ5JO8ay&ADhfJUlMnftajynrXrbsfd z*R~P%+NflgaZUmBN|7Z&QJ~HOKh}~7(ETzwJpmo zz?Up_({gG<;pMW&TR$h44Q21yfFhX{_SNJ{OFM!Bg1TsRuBvEC6OowePrJmSI*}|N zL3SbO7Y4@#;1D^}!4<2!4jTueYP#N3lYa|YR|4{{s`V8Bt!qJB_XrNeFpYRv9b?Z& zafbYl5IIea1kjX{5<0iVD8cl=nJ@e|k9u&6A-`f#P*;6m7$+3W)QMCahkS~Ya%haK zgdRc1Azpx9O`hk|rw&BSt3p{)5SEe07}gI81IUE;F?j*NaC9{~nPoXOOa@F|u;_3j z;sdZ*fpkccniO-18%;2-)+s>=0m^FKpn*R#V%;MFN&JPTnvhYlo1!@Fs3Vpw&Z}eA z=vZ!v>n(A8uE)@pyC~q@>wH&**YhpC)S3W#m5}695mr_`Q!)x@mGG#CYNGs&2rL?p z-C$1?(v}#lt|l*P)VeyMWVLBeSa@}UZsTly0}UnNt+2Dut*Fzzw1#x~3e<29=W z9&OdA+?|yVqK?*RIJ%7$QGHM{(4<~a@RIh?%gpl-?`}20nMn-vLdrC`ETpWg8&X{p z7$Ie4{g8425>i%{h7@X&1PuhZ-0>h(7%X+1FQu%jJk+$*$|@UGK!=J8skX?6q?HwU zsx6w$!iqfA7R@GMMV@MlW*}OTr`n|gLx*=HZz}R5^XbA=9xsbOM1My*PxvROF52fD&|>R(I=|f`C^V| zmu+W_Em$uk+ljYHz_I2bX^AFVtc;2kl3sa6L5@stfRb(9D%$ox&+Ud>M`ByIA95Xw zZ7y%v^&Wf`+T20Nb>z0WdB}C_wz(@I*U{VNYM~sp4ba=-kB$mb76Z{CapC%oV&a_e zpR(XRy=4bEuN|JSlVPn{7IpO)CLuD*DHy}LLZn--$d^l{@x*uZpwEk9*T+$6vj()MDJ8U z-Q&f(aHYXmii6BwDHCq0+UPYUCJiu*iMe{e*&Ul2j@0~T6g;$5%{Ucv|8BKOza6RT znPi>?J;k~T`U+A70|mK)6{}41L>$)o&U(OuX%ThG5`8~PKr20BN`d&z(ewyVHCV-? z>2V=udRX{Gj34I1BjP1mJ;Wf(Sf*5L9!;YBI?BGU!xUh zsv9OjKbt1-T!vw-xxkY^Gp!tfMSIPfQAKDk%ESvYZ$xcx8QQOr6G}q|48{TxR$JvN za!sA-o26uJ0s+!DMjO$f%n##eEc0%-7Bhir6Y4`#5EeS^{t*_i+=a9^o&3)m*1t`n zn!fF-@$_xctKlz-WJ;j~zN~7Km#p%4=%g+W4cWaK*{FCn=?x7%Ix1yA`isLRM1Z`Q znP9dGd212kHyc_XSnu-J=u3sN>2z71xWHOt#lJh6zB`)!r|nPFF$3egXea@_ow6Yh zQwSKQ|HaJLVsMQD-C2=>KK*aY>av4TA+a~zbRPY7+>Q9@;+um06aY+=eeDJPym?0) zcfZF^ay`zg?!QyiePUkx?j2oVh*ot%9R3$ZSN|TvPOmXavlL)trCY?z^E9r`c2f9O zim0a;9>@Gs{5GnKzo(nB-TEnf4LYUvkiyPN+ABNaKzymd5~lF77du(ng(!L20CIWt zQlUCmXYMOdALAa?$JuI%nGVyatF!F7w5xqh&mpfa)2yt{R?`6%dOP|tT1q=ku+4su8gjvTm{g;rI-AuAI7)g7x(fOVQU$_plBYu61z19g z^-53{%&!2K3g@9`EdLtv<<8J_C z3!jy}#Xw%x)IXM9vtWE9`PGJBM&sk)k$6G*`mtG&Z` zG795pzXEg!7@@Hgq5Kbm8J&e}K-4cNxYY1u0`x-{275p=&$`8rjbdp-z0hwFgA<`d zMK3U#DY4FqrlUAD3&7Io(vi%Y8Xh3Nje2*!6l&$Y;97(M5+^M9@|lgR1qW;AZxQ)j zwgD_Cy~kbRv(dclI$40`Zj~EhVO^|Wx>oilQ(yx2S%@E5>*SD@8Mb#vzi>Q7MQ%Lq zNyoGW3EtK^yv#BgAE%ymVattAV%C}W+0aa#wnNxp+IfOTD5i0Gu)wLJt?a*GSorcR zZILhbT| z_`sp;D!xg%L8hpvdUW{^>#Ce(qQMnCn)-_5?o*dSeEnbCVYFA16^jNw)TT2IcQ)=mjuxdhZ zwVI|{EXO(w0?%f3&IBD)K;gVwap4kKBom#HGzuQz+a$aW8TWb~_J#`ZFib?un6)Cq zYMYpBTz=vG0GOJNs?ago zEKWqWXCaohXR*n*XAO2{M&;kMDGzi)>X5>;D6Lcw!@wt@7z+6gWS}vtA zDm~2-fJ|RVEqQMAAw%4d@%wkKgylANk{t{qbj3|2IAH z)is}9YqL5C&>*`|>-gj8Q4{>ObJ?0LD-pZ#Wo{p{1e;O&{CxFbBBhpZHT_4YmjvYx z3hg_AjM4){iUNP4^^s&BOIK}}OD+Nsx5nH?+VKo6!R$HEnDNdcWp8U_wF=)E5OqXi=!lt9!Ng^g{Pi;nt#JQd%w?_U)6uX<`p#0J{R|76hv7Z zWqL+eMzRn5r>uyNLu%VoWwRgN;>Nr6w(NDV_LBFlEqmd+Dtzx~H2$es%^ybo4`N`L S;Mjmv_%n+i{`~*L!2bZ_BWOSX literal 0 HcmV?d00001 diff --git a/InstallerSamples/MachineLearning/06_Pipelines/06_MatrixPipeline_Regression.pas b/InstallerSamples/MachineLearning/06_Pipelines/06_MatrixPipeline_Regression.pas new file mode 100644 index 000000000..173ecef62 --- /dev/null +++ b/InstallerSamples/MachineLearning/06_Pipelines/06_MatrixPipeline_Regression.pas @@ -0,0 +1,32 @@ +// В этом примере MatrixPipeline используется +// для задачи регрессии. + +uses MLABC; + +begin + var ds := Datasets.MoscowHousing; + + var features := ['area', 'rooms', 'floor', 'max_floor', 'distance_to_metro', 'building_age']; + + var (trainDs, testDs) := ds.TrainTestSplit(testRatio := 0.2, seed := 42); + + var XTrain := trainDs.Data.ToMatrix(features); + var yTrain := trainDs.Data.ToVector(ds.Target); + + var XTest := testDs.Data.ToMatrix(features); + var yTest := testDs.Data.ToVector(ds.Target); + + var pipe := + MatrixPipeline.Build( + new StandardScaler, + new LinearRegression + ); + + pipe.Fit(XTrain, yTrain); + var pred := pipe.Predict(XTest); + var r2 := RegressionMetrics.R2(yTest, pred); + + Println('MatrixPipeline для задачи регрессии'); + Println; + Println($'R² на тестовой выборке: {r2:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/06_Pipelines/07_MatrixPipeline_KNN_WithScaling.pas b/InstallerSamples/MachineLearning/06_Pipelines/07_MatrixPipeline_KNN_WithScaling.pas new file mode 100644 index 000000000..3f73ae425 --- /dev/null +++ b/InstallerSamples/MachineLearning/06_Pipelines/07_MatrixPipeline_KNN_WithScaling.pas @@ -0,0 +1,47 @@ +// В этом примере сравнивается KNNClassifier +// без масштабирования и с MatrixPipeline. +// +// Важно: в pipeline шаг StandardScaler выполняется автоматически. +// То есть MatrixPipeline здесь эквивалентен цепочке: +// StandardScaler -> KNNClassifier. + +uses MLABC; + +begin + var (X, y) := Datasets.MakeClassification( + n := 400, + nFeatures := 4, + nInformative := 2, + nRedundant := 0, + noise := 0.15, + classSep := 1.0, + seed := 42 + ); + + for var i := 0 to X.RowCount - 1 do + begin + X[i, 2] := X[i, 2] * 100; + X[i, 3] := X[i, 3] * 1000; + end; + + var (XTrain, XTest, yTrain, yTest) := Validation.TrainTestSplit(X, y, 0.25, seed := 42); + + var knn := new KNNClassifier(5); + knn.Fit(XTrain, yTrain); + var acc1 := ClassificationMetrics.Accuracy(yTest, knn.Predict(XTest)); + + // Здесь масштабирование выполняется внутри pipeline автоматически. + var pipe := + MatrixPipeline.Build( + new StandardScaler, + new KNNClassifier(5) + ); + + pipe.Fit(XTrain, yTrain); + var acc2 := ClassificationMetrics.Accuracy(yTest, pipe.Predict(XTest)); + + Println('KNNClassifier без масштабирования и с масштабированием'); + Println; + Println($'Без масштабирования : Accuracy = {acc1:F3}'); + Println($'С MatrixPipeline и масштабированием: Accuracy = {acc2:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/07_Clustering/01_KMeans_SelectK_ByInertia.pas b/InstallerSamples/MachineLearning/07_Clustering/01_KMeans_SelectK_ByInertia.pas new file mode 100644 index 000000000..9d6fadfbc --- /dev/null +++ b/InstallerSamples/MachineLearning/07_Clustering/01_KMeans_SelectK_ByInertia.pas @@ -0,0 +1,41 @@ +// В этом примере подбирается число кластеров для KMeans. +// +// Используются данные MakeBlobs с тремя естественными группами. +// Для каждого значения k выводится inertia: +// чем она меньше, тем ближе объекты к центрам своих кластеров. +// +// Идея метода: +// при увеличении k inertia всегда уменьшается, +// но после "правильного" числа кластеров выигрыш обычно становится небольшим. + +uses MLABC; + +begin + var (X, yTrue) := Datasets.MakeBlobs( + n := 300, + centers := 3, + nFeatures := 2, + clusterStd := 0.8, + centerBox := 6.0, + shuffle := True, + seed := 42 + ); + + Println('Подбор числа кластеров для KMeans'); + Println; + Println('k inertia'); + Println('-' * 18); + + for var k := 2 to 6 do + begin + var model := new KMeans(k, seed := 42); + model.Fit(X); + Println($'{k,-4} {model.Inertia,10:F3}'); + end; + + Println; + Println('Интерпретация результата:'); + Println('- Inertia всегда уменьшается при росте k.'); + Println('- Ищут момент, после которого уменьшение становится не таким заметным.'); + Println('- В этом примере естественно ожидать около 3 кластеров.'); +end. diff --git a/InstallerSamples/MachineLearning/07_Clustering/02_KMeans_vs_DBSCAN.pas b/InstallerSamples/MachineLearning/07_Clustering/02_KMeans_vs_DBSCAN.pas new file mode 100644 index 000000000..8dc9bb6c5 --- /dev/null +++ b/InstallerSamples/MachineLearning/07_Clustering/02_KMeans_vs_DBSCAN.pas @@ -0,0 +1,41 @@ +// В этом примере сравниваются два алгоритма кластеризации: +// KMeans и DBSCAN. +// +// KMeans хорошо работает, когда кластеры компактны и похожи по форме. +// DBSCAN лучше справляется со сложной геометрией кластеров и не требует +// заранее задавать их число. + +uses MLABC; + +begin + var (X, yTrue) := Datasets.MakeMoons( + n := 400, + noise := 0.08, + shuffle := True, + seed := 1 + ); + + var kmeans := new KMeans(2, seed := 42); + kmeans.Fit(X); + var yKMeans := kmeans.PredictLabels(X); + + var dbscan := new DBSCAN(0.22, 5); + dbscan.Fit(X); + var yDBSCAN := dbscan.PredictLabels(X); + + var ariKMeans := ClusteringMetrics.AdjustedRandIndex(yTrue, yKMeans); + var ariDBSCAN := ClusteringMetrics.AdjustedRandIndex(yTrue, yDBSCAN); + + Println('Сравнение KMeans и DBSCAN'); + Println; + Println($'Число кластеров у KMeans: {kmeans.ClustersCount}'); + Println($'Число кластеров у DBSCAN: {dbscan.ClustersCount}'); + Println; + Println($'KMeans: ARI = {ariKMeans:F3}'); + Println($'DBSCAN: ARI = {ariDBSCAN:F3}'); + Println; + Println('Интерпретация результата:'); + Println('- KMeans разбивает данные на компактные области вокруг центров.'); + Println('- DBSCAN умеет находить кластеры сложной формы.'); + Println('- Для двух "лун" DBSCAN обычно лучше отражает реальную структуру данных.'); +end. diff --git a/InstallerSamples/MachineLearning/07_Clustering/03_DBSCAN_SelectEps.pas b/InstallerSamples/MachineLearning/07_Clustering/03_DBSCAN_SelectEps.pas new file mode 100644 index 000000000..26671e814 --- /dev/null +++ b/InstallerSamples/MachineLearning/07_Clustering/03_DBSCAN_SelectEps.pas @@ -0,0 +1,44 @@ +// В этом примере подбирается параметр eps для DBSCAN. +// +// eps задаёт радиус окрестности точки. +// Если eps слишком мал, алгоритм считает много точек шумом. +// Если eps слишком велик, разные группы могут слиться в один кластер. + +uses MLABC; + +begin + var (X, yTrue) := Datasets.MakeMoons( + n := 400, + noise := 0.08, + shuffle := True, + seed := 1 + ); + + Println('Подбор параметра eps для DBSCAN'); + Println; + Println('eps кластеры шум ARI'); + Println('-' * 34); + + foreach var eps in [0.10, 0.14, 0.18, 0.22, 0.26, 0.30] do + begin + var model := new DBSCAN(eps, 5); + model.Fit(X); + + var labels := model.PredictLabels(X); + + var noiseCount := 0; + foreach var clusterLabel in labels do + if clusterLabel = -1 then + noiseCount += 1; + + var ari := ClusteringMetrics.AdjustedRandIndex(yTrue, labels); + + Println($'{eps,4:F2} {model.ClustersCount,10} {noiseCount,6} {ari,8:F3}'); + end; + + Println; + Println('Интерпретация результата:'); + Println('- При слишком маленьком eps шумовых точек становится слишком много.'); + Println('- При слишком большом eps разные группы могут сливаться.'); + Println('- Хороший eps даёт разумное число кластеров и высокое значение ARI.'); +end. diff --git a/InstallerSamples/MachineLearning/07_Clustering/04_RussianCities_KMeans.pas b/InstallerSamples/MachineLearning/07_Clustering/04_RussianCities_KMeans.pas new file mode 100644 index 000000000..2b2ece286 --- /dev/null +++ b/InstallerSamples/MachineLearning/07_Clustering/04_RussianCities_KMeans.pas @@ -0,0 +1,45 @@ +// В этом примере KMeans применяется к реальным данным о российских городах. +// +// Для кластеризации используются два признака: +// log_population и density. +// Это позволяет группировать города не по координатам, +// а по масштабу и плотности. + +uses MLABC; + +begin + var ds := Datasets.RussianCities; + var df := ds.Data; + + df := df.WithColumnFloat('density', row -> row.Float('population') / row.Float('area')); + df := df.WithColumnFloat('log_population', row -> Ln(row.Float('population'))); + + var features := ['log_population', 'density']; + + var pipe := + UDataPipeline.Build( + features, + new StandardScaler, + new KMeans(3, seed := 42) + ); + + var labels := pipe.FitPredict(df); + df.AddIntColumn('cluster', labels, nil); + + Println('Кластеризация российских городов'); + Println; + Println('Признаки: log_population, density'); + Println('Число кластеров: 3'); + + for var cluster := 0 to 2 do + begin + Println; + Println($'Кластер {cluster + 1}:'); + + df.Filter(row -> row.Int('cluster') = cluster) + .SortBy('population', descending := True) + .Select(['city', 'population', 'density']) + .Head(5) + .Print; + end; +end. diff --git a/InstallerSamples/MachineLearning/07_Clustering/05_ClusteringMetrics.exe b/InstallerSamples/MachineLearning/07_Clustering/05_ClusteringMetrics.exe new file mode 100644 index 0000000000000000000000000000000000000000..cbbb00830267c33170250159a69c1c750f54fa24 GIT binary patch literal 100864 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 ds.ClassName(kv.Key) + ' → ' + kv.Value); + Println; + + ds.Data.Print(20); +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/02_AgeFareScatter.pas b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/02_AgeFareScatter.pas new file mode 100644 index 000000000..96cc90765 --- /dev/null +++ b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/02_AgeFareScatter.pas @@ -0,0 +1,16 @@ +uses MLABC, PlotML; + +begin + var ds := Datasets.TitanicRu; + var df := ds.Data + .Filter(row -> row.IsValid('Возраст') and row.IsValid('ЦенаБилета')); + + var age := df.ToVector('Возраст'); + var fare := df.ToVector('ЦенаБилета'); + var y := df.GetIntColumn('Выжил'); + + Plot.Points(age, fare, y, size := 5); + Plot.XLabel('Возраст'); + Plot.YLabel('Цена билета'); + Plot.Title := 'Титаник: возраст, цена билета и выживание'; +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/03_Preprocessing.pas b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/03_Preprocessing.pas new file mode 100644 index 000000000..a5b9d32ef --- /dev/null +++ b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/03_Preprocessing.pas @@ -0,0 +1,24 @@ +uses MLABC; + +begin + var ds := Datasets.TitanicRu; + var df := ds.Data; + + Println('До предобработки:'); + df.Select(['Id', 'Имя', 'Выжил', 'Класс', 'Пол', 'Возраст', 'ПортПосадки']).Head(10).Print; + Println; + + // Убираем служебный номер и длинное текстовое поле. + df := df.Drop(['Id', 'Имя']); + + // Заполняем пропуски в возрасте средним значением. + var ageImputer := new Imputer(['Возраст']); + df := ageImputer.FitTransform(df); + + // Заполняем пропуски в порту посадки константой. + var portImputer := new Imputer('Саутгемптон', ['ПортПосадки']); + df := portImputer.FitTransform(df); + + Println('После предобработки:'); + df.Select(['Выжил', 'Класс', 'Пол', 'Возраст', 'ПортПосадки']).Head(10).Print; +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/04_AgeHistogramBySurvival.pas b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/04_AgeHistogramBySurvival.pas new file mode 100644 index 000000000..73d2a0e54 --- /dev/null +++ b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/04_AgeHistogramBySurvival.pas @@ -0,0 +1,23 @@ +uses MLABC, PlotML; + +begin + var ds := Datasets.TitanicRu; + var df := ds.Data.Filter(row -> row.IsValid('Возраст')); + + var diedAges: array of real := df + .Filter(row -> row.Int('Выжил') = 0) + .ToVector('Возраст').Data; + + var survivedAges: array of real := df + .Filter(row -> row.Int('Выжил') = 1) + .ToVector('Возраст').Data; + + Println($'Пассажиров с известным возрастом: {df.RowCount}'); + Println($'Не выжили: {diedAges.Length}'); + Println($'Выжили: {survivedAges.Length}'); + + Plot.HistMany([diedAges,survivedAges], bins := 20, colors := [Colors.IndianRed, Colors.SteelBlue], alpha := 0.45, legend := ['не выжили','выжили']); + Plot.Title := 'Титаник: возраст выживших (голубой) и невыживших (красный)'; + Plot.XLabel('Возраст'); + Plot.YLabel('Число пассажиров'); +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/05_Classification.pas b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/05_Classification.pas new file mode 100644 index 000000000..a157130a7 --- /dev/null +++ b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/05_Classification.pas @@ -0,0 +1,32 @@ +uses MLABC; + +begin + var ds := Datasets.TitanicRu; + var features := ['Класс', 'Пол', 'Возраст', 'БратьяИСупруги', 'РодителиИДети', 'ЦенаБилета', 'ПортПосадки']; + + var df := ds.Data.Drop(['Id', 'Имя']); + + var (trainDf, testDf) := + df.StratifiedTrainTestSplit(ds.Target, testRatio := 0.2, seed := 42); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + features, + new Imputer(['Возраст']), + new Imputer('Саутгемптон', ['ПортПосадки']), + new OneHotEncoder('Пол'), + new OneHotEncoder('ПортПосадки'), + new StandardScaler, + new LogisticRegression(learningRate := 0.1, epochs := 2000) + ); + + pipe.Fit(trainDf); + + var pred := pipe.Predict(testDf); + var y := pipe.GetEncodedLabels(testDf); + + Println('Классификация выживания на Титанике (логистическая регрессия)'); + Println($'Accuracy = {Metrics.Accuracy(y, pred):F3}'); +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/06_ModelComparison.pas b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/06_ModelComparison.pas new file mode 100644 index 000000000..f0e024d27 --- /dev/null +++ b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/06_ModelComparison.pas @@ -0,0 +1,48 @@ +uses MLABC; + +begin + var ds := Datasets.TitanicRu; + var df := ds.Data.Drop(['Id', 'Имя']); + + // Заполняем пропуски. + var ageImputer := new Imputer(['Возраст']); + df := ageImputer.FitTransform(df); + + var portImputer := new Imputer('Саутгемптон', ['ПортПосадки']); + df := portImputer.FitTransform(df); + + // Кодируем категориальные признаки числами. + var sexEncoder := new LabelEncoder('Пол'); + df := sexEncoder.FitTransform(df); + + var portEncoder := new LabelEncoder('ПортПосадки'); + df := portEncoder.FitTransform(df); + + var features := ['Класс', 'Пол', 'Возраст', 'БратьяИСупруги', 'РодителиИДети', 'ЦенаБилета', 'ПортПосадки']; + var X := df.ToMatrix(features); + var y := df.GetIntColumn('Выжил'); + + var (Xtrain, Xtest, ytrain, ytest) := Validation.TrainTestSplit(X, y, testRatio := 0.2, seed := 42); + + var scaler := new StandardScaler; + scaler.Fit(Xtrain); + var XtrainScaled := scaler.Transform(Xtrain); + var XtestScaled := scaler.Transform(Xtest); + + var lr := new LogisticRegression(learningRate := 0.01, epochs := 2000); + lr.Fit(XtrainScaled, ytrain); + var predLR := lr.Predict(XtestScaled); + + var tree := new DecisionTreeClassifier(maxDepth := 5, minSamplesLeaf := 3, minSamplesSplit := 6); + tree.Fit(Xtrain, ytrain); + var predTree := tree.Predict(Xtest); + + var forest := new RandomForestClassifier(nTrees := 100, maxDepth := 6, minSamplesLeaf := 3, minSamplesSplit := 6); + forest.Fit(Xtrain, ytrain); + var predForest := forest.Predict(Xtest); + + Println('Сравнение моделей на TitanicRu'); + Println($'LogisticRegression: Accuracy = {Metrics.Accuracy(ytest, predLR):F3}'); + Println($'DecisionTreeClassifier: Accuracy = {Metrics.Accuracy(ytest, predTree):F3}'); + Println($'RandomForestClassifier: Accuracy = {Metrics.Accuracy(ytest, predForest):F3}'); +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/07_PredictProba.pas b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/07_PredictProba.pas new file mode 100644 index 000000000..e225ea479 --- /dev/null +++ b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/07_PredictProba.pas @@ -0,0 +1,39 @@ +uses MLABC; + +begin + var ds := Datasets.TitanicRu; + var features := ['Класс', 'Пол', 'Возраст', 'БратьяИСупруги', 'РодителиИДети', 'ЦенаБилета', 'ПортПосадки']; + + var df := ds.Data.Drop(['Id', 'Имя']); + + var (trainDf, testDf) := + df.StratifiedTrainTestSplit(ds.Target, testRatio := 0.2, seed := 42); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + 'Выжил', + features, + new Imputer(['Возраст']), + new Imputer('Саутгемптон', ['ПортПосадки']), + new OneHotEncoder('Пол'), + new OneHotEncoder('ПортПосадки'), + new StandardScaler, + new LogisticRegression(learningRate := 0.01, epochs := 2000) + ); + + pipe.Fit(trainDf); + + var pred := pipe.Predict(testDf); + var y := pipe.GetEncodedLabels(testDf); + var proba := pipe.PredictProba(testDf); + var classes := pipe.GetClassLabels; + + Println('TitanicRu: вероятности классов'); + Println($'Accuracy = {Metrics.Accuracy(y, pred):F3}'); + Println; + Println('Вероятности для первого пассажира тестовой выборки:'); + + for var j := 0 to classes.Length - 1 do + Println($' {classes[j]}: {proba[0, j]:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/08_CrossValidation.pas b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/08_CrossValidation.pas new file mode 100644 index 000000000..99aa63799 --- /dev/null +++ b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/08_CrossValidation.pas @@ -0,0 +1,58 @@ +uses MLABC; + +function BuildPipe(features: array of string): DataPipeline; +begin + Result := + DataPipeline.Build( + TaskKind.tkClassification, + 'Выжил', + features, + new Imputer(['Возраст']), + new Imputer('Саутгемптон', ['ПортПосадки']), + new OneHotEncoder('Пол'), + new OneHotEncoder('ПортПосадки'), + new StandardScaler, + new RandomForestClassifier(nTrees := 100, maxDepth := 6, minSamplesLeaf := 3, minSamplesSplit := 6) + ); +end; + +begin + var ds := Datasets.TitanicRu; + var features := ['Класс', 'Пол', 'Возраст', 'БратьяИСупруги', 'РодителиИДети', 'ЦенаБилета', 'ПортПосадки']; + var df := ds.Data.Drop(['Id', 'Имя']); + + var (trainDf, testDf) := + df.StratifiedTrainTestSplit(ds.Target, testRatio := 0.2, seed := 42); + + var pipe := BuildPipe(features); + pipe.Fit(trainDf); + + var pred := pipe.Predict(testDf); + var y := pipe.GetEncodedLabels(testDf); + var testAccuracy := Metrics.Accuracy(y, pred); + + var total := 0.0; + var folds := Validation.StratifiedKFold(df.GetIntColumn('Выжил'), 5, seed := 1); + var foldsCount := 0; + + foreach var (trainIdx, testIdx) in folds do + begin + var foldTrain := df.TakeRows(trainIdx); + var foldTest := df.TakeRows(testIdx); + + var foldPipe := BuildPipe(features); + foldPipe.Fit(foldTrain); + + var foldPred := foldPipe.Predict(foldTest); + var foldY := foldPipe.GetEncodedLabels(foldTest); + + total += Metrics.Accuracy(foldY, foldPred); + foldsCount += 1; + end; + + var cvAccuracy := total / foldsCount; + + Println('Оценка RandomForestClassifier двумя способами'); + Println($'Accuracy на тестовой выборке: {testAccuracy:F3}'); + Println($'Средняя Accuracy по кросс-валидации: {cvAccuracy:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/09_FeatureImportance.pas b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/09_FeatureImportance.pas new file mode 100644 index 000000000..3f39e4992 --- /dev/null +++ b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/09_FeatureImportance.pas @@ -0,0 +1,31 @@ +uses MLABC; + +begin + var ds := Datasets.TitanicRu; + var df := ds.Data.Drop(['Id', 'Имя']); + + var ageImputer := new Imputer(['Возраст']); + df := ageImputer.FitTransform(df); + + var portImputer := new Imputer('Саутгемптон', ['ПортПосадки']); + df := portImputer.FitTransform(df); + + var sexEncoder := new LabelEncoder('Пол'); + df := sexEncoder.FitTransform(df); + + var portEncoder := new LabelEncoder('ПортПосадки'); + df := portEncoder.FitTransform(df); + + var features := ['Класс', 'Пол', 'Возраст', 'БратьяИСупруги', 'РодителиИДети', 'ЦенаБилета', 'ПортПосадки']; + var X := df.ToMatrix(features); + var y := df.GetIntColumn('Выжил'); + + var model := new RandomForestClassifier(nTrees := 100, maxDepth := 6, minSamplesLeaf := 3, minSamplesSplit := 6, seed := 42); + model.Fit(X, y); + + var imp := model.FeatureImportances; + + Println('Важность признаков для RandomForestClassifier'); + for var i := 0 to features.Length - 1 do + Println($'{features[i],-18}: {imp[i]:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/10_SurvivalBySexAndClass.pas b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/10_SurvivalBySexAndClass.pas new file mode 100644 index 000000000..6f6061c9f --- /dev/null +++ b/InstallerSamples/MachineLearning/08_Datasets/TitanicRu/10_SurvivalBySexAndClass.pas @@ -0,0 +1,26 @@ +uses MLABC; + +function SurvivalRate(df: DataFrame): real; +begin + Result := df.GetIntColumn('Выжил').Average; +end; + +begin + var ds := Datasets.TitanicRu; + var df := ds.Data; + + Println('Доля выживших по полу:'); + foreach var sex in |'муж', 'жен'| do + begin + var sub := df.Filter(r -> r.Str('Пол') = sex); + Println($' {sex,-4}: {100 * SurvivalRate(sub):F1}%'); + end; + + Println; + Println('Доля выживших по классу:'); + for var cls := 1 to 3 do + begin + var sub := df.Filter(r -> r.Int('Класс') = cls); + Println($' класс {cls}: {100 * SurvivalRate(sub):F1}%'); + end; +end. diff --git a/InstallerSamples/MachineLearning/08_Datasets/_Synthetic/05_MakeSpiral.pas b/InstallerSamples/MachineLearning/08_Datasets/_Synthetic/05_MakeSpiral.pas index b10004b32..ec70706e5 100644 --- a/InstallerSamples/MachineLearning/08_Datasets/_Synthetic/05_MakeSpiral.pas +++ b/InstallerSamples/MachineLearning/08_Datasets/_Synthetic/05_MakeSpiral.pas @@ -11,6 +11,6 @@ begin seed := 1 ); - Plot.Title('MakeSpiral синтетический датасет'); + Plot.Title := 'MakeSpiral синтетический датасет'; Plot.Points(X.Col(0), X.Col(1), LabelsToInts(y), size := 5); end. \ No newline at end of file diff --git a/InstallerSamples/MachineLearning/09_Visualization/01_Scatter_Iris.pas b/InstallerSamples/MachineLearning/09_Visualization/01_Scatter_Iris.pas new file mode 100644 index 000000000..76e41786d --- /dev/null +++ b/InstallerSamples/MachineLearning/09_Visualization/01_Scatter_Iris.pas @@ -0,0 +1,28 @@ +// В этом примере строится scatter plot для двух признаков датасета Iris. +// +// По оси X откладывается длина чашелистика, +// по оси Y — ширина чашелистика. +// +// Цвет точки показывает класс цветка. + +uses MLABC, PlotML; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + + // Берем два признака для двумерного графика. + var sepalLength := df.ToVector('sepal_length'); + var sepalWidth := df.ToVector('sepal_width'); + + // Кодируем классы в числа, чтобы раскрасить точки по видам Iris. + var y := df.EncodeLabels(ds.Target); + + Println('Scatter plot для датасета Iris'); + Println; + Println('По оси X: sepal_length'); + Println('По оси Y: sepal_width'); + + Plot.Points(sepalLength, sepalWidth, y, size := 6); + Plot.SetLabels('Iris: scatter plot', 'sepal_length', 'sepal_width'); +end. diff --git a/InstallerSamples/MachineLearning/09_Visualization/02_PairPlot_Iris.pas b/InstallerSamples/MachineLearning/09_Visualization/02_PairPlot_Iris.pas new file mode 100644 index 000000000..d09018688 --- /dev/null +++ b/InstallerSamples/MachineLearning/09_Visualization/02_PairPlot_Iris.pas @@ -0,0 +1,26 @@ +// В этом примере строится pair plot для всех признаков датасета Iris. +// +// На диагонали расположены гистограммы отдельных признаков, +// а вне диагонали — scatter plot для всех пар признаков. +// +// Цвет точки показывает класс цветка. + +uses MLABC, PlotML; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + + // Преобразуем признаки в матрицу. + var X := df.ToMatrix(ds.Features); + + // Кодируем классы в числа, чтобы раскрасить точки по видам Iris. + var y := df.EncodeLabels(ds.Target); + + Println('Pair plot для датасета Iris'); + Println; + Println('На диагонали показаны гистограммы признаков.'); + Println('Вне диагонали показаны scatter plot для всех пар признаков.'); + + Plot.PairPlot(X, LabelsToInts(y), ds.Features); +end. diff --git a/InstallerSamples/MachineLearning/09_Visualization/03_Histogram_Iris.pas b/InstallerSamples/MachineLearning/09_Visualization/03_Histogram_Iris.pas new file mode 100644 index 000000000..dc0999d07 --- /dev/null +++ b/InstallerSamples/MachineLearning/09_Visualization/03_Histogram_Iris.pas @@ -0,0 +1,21 @@ +// В этом примере строится гистограмма одного признака датасета Iris. +// +// Гистограмма показывает, как распределены значения признака +// и в каких диапазонах они встречаются чаще. + +uses MLABC, PlotML; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + + // Берем длину лепестка. + var petalLength := df.ToVector('petal_length'); + + Println('Гистограмма признака petal_length'); + Println; + Println('Гистограмма помогает увидеть распределение значений признака.'); + + Plot.Hist(petalLength, bins := 12); + Plot.SetLabels('Iris: histogram', 'petal_length', 'count'); +end. diff --git a/InstallerSamples/MachineLearning/09_Visualization/04_Heatmap_Correlation.pas b/InstallerSamples/MachineLearning/09_Visualization/04_Heatmap_Correlation.pas new file mode 100644 index 000000000..fc2ce8641 --- /dev/null +++ b/InstallerSamples/MachineLearning/09_Visualization/04_Heatmap_Correlation.pas @@ -0,0 +1,25 @@ +// В этом примере строится heatmap матрицы корреляций +// для числовых признаков датасета Iris. +// +// Тепловая карта помогает быстро увидеть, +// какие признаки связаны друг с другом сильнее. + +uses MLABC, PlotML; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + + // Вычисляем матрицу корреляций для числовых столбцов. + var corrDf := Statistics.CorrelationMatrix(df); + var corrNames := corrDf.Schema.ColumnNames.Skip(1).ToArray; + var corr := corrDf.ToMatrix(corrNames); + + Println('Heatmap матрицы корреляций для Iris'); + Println; + Println('В строках и столбцах расположены признаки.'); + Println('Внутри каждой клетки показан коэффициент корреляции.'); + + Plot.Heatmap(corr, corrNames); + Plot.Title := 'Iris: correlation heatmap'; +end. diff --git a/InstallerSamples/MachineLearning/09_Visualization/05_Surface_ClassificationBoundary.pas b/InstallerSamples/MachineLearning/09_Visualization/05_Surface_ClassificationBoundary.pas new file mode 100644 index 000000000..a22d6b54a --- /dev/null +++ b/InstallerSamples/MachineLearning/09_Visualization/05_Surface_ClassificationBoundary.pas @@ -0,0 +1,29 @@ +// В этом примере Surface показывает, +// как модель делит плоскость на области классов. + +uses MLABC, PlotML; + +begin + var (X, y) := Datasets.MakeMoons( + n := 300, + noise := 0.18, + seed := 42 + ); + + var model := new DecisionTreeClassifier(maxDepth := 5, minSamplesSplit := 6, minSamplesLeaf := 3, seed := 42); + model.Fit(X, y); + var acc := ClassificationMetrics.Accuracy(y, model.Predict(X)); + + var x1 := X.Col(0); + var x2 := X.Col(1); + var labels := LabelsToInts(y); + + Println('Граница решений для DecisionTreeClassifier'); + Println; + Println('Цветной фон показывает области, которые модель относит к разным классам.'); + Println($'Accuracy = {acc:F3}'); + + Plot.Surface(x1, x2, 80, 80, G -> model.PredictLabels(G), Palettes.Pastel); + Plot.Points(x1, x2, labels, size := 6); + Plot.Title := $'DecisionTreeClassifier: decision boundary (Acc = {acc:F3})'; +end. diff --git a/InstallerSamples/MachineLearning/09_Visualization/06_Surface_LogisticVsTree.pas b/InstallerSamples/MachineLearning/09_Visualization/06_Surface_LogisticVsTree.pas new file mode 100644 index 000000000..3e43b17bf --- /dev/null +++ b/InstallerSamples/MachineLearning/09_Visualization/06_Surface_LogisticVsTree.pas @@ -0,0 +1,34 @@ +// В этом примере сравниваются границы решений +// логистической регрессии и дерева решений. + +uses MLABC, PlotML; + +begin + var (X, y) := Datasets.MakeMoons( + n := 300, + noise := 0.18, + seed := 42 + ); + + var logreg := new LogisticRegression(learningRate := 0.05, epochs := 1000); + logreg.Fit(X, y); + var accLR := ClassificationMetrics.Accuracy(y, logreg.Predict(X)); + + var tree := new DecisionTreeClassifier(maxDepth := 5, minSamplesSplit := 6, minSamplesLeaf := 3, seed := 42); + tree.Fit(X, y); + var accTree := ClassificationMetrics.Accuracy(y, tree.Predict(X)); + + var x1 := X.Col(0); + var x2 := X.Col(1); + var labels := LabelsToInts(y); + + var fig := Plot.Grid(1, 2); + + fig[0,0].Surface(x1, x2, 80, 80, G -> logreg.PredictLabels(G), Palettes.Pastel); + fig[0,0].Points(x1, x2, labels, size := 6); + fig[0,0].Title := $'LogisticRegression (Acc = {accLR:F3})'; + + fig[0,1].Surface(x1, x2, 80, 80, G -> tree.PredictLabels(G), Palettes.Pastel); + fig[0,1].Points(x1, x2, labels, size := 6); + fig[0,1].Title := $'DecisionTreeClassifier (Acc = {accTree:F3})'; +end. diff --git a/InstallerSamples/MachineLearning/09_Visualization/07_Surface_TreeVsForest.pas b/InstallerSamples/MachineLearning/09_Visualization/07_Surface_TreeVsForest.pas new file mode 100644 index 000000000..8fbf6be96 --- /dev/null +++ b/InstallerSamples/MachineLearning/09_Visualization/07_Surface_TreeVsForest.pas @@ -0,0 +1,37 @@ +// В этом примере сравниваются границы решений +// дерева решений и случайного леса. + +uses MLABC, PlotML; + +begin + var (X, y) := Datasets.MakeCircles( + n := 300, + noise := 0.3, + factor := 0.5, + flipProb := 0.08, + scale := 3.0, + seed := 42 + ); + + var tree := new DecisionTreeClassifier(maxDepth := 6, minSamplesSplit := 6, minSamplesLeaf := 3, seed := 42); + tree.Fit(X, y); + var accTree := ClassificationMetrics.Accuracy(y, tree.Predict(X)); + + var forest := new RandomForestClassifier(nTrees := 100, maxDepth := 6, minSamplesSplit := 6, minSamplesLeaf := 3, seed := 42); + forest.Fit(X, y); + var accForest := ClassificationMetrics.Accuracy(y, forest.Predict(X)); + + var x1 := X.Col(0); + var x2 := X.Col(1); + var labels := LabelsToInts(y); + + var fig := Plot.Grid(1, 2); + + fig[0,0].Surface(x1, x2, 80, 80, G -> tree.PredictLabels(G), Palettes.Pastel); + fig[0,0].Points(x1, x2, labels, size := 6); + fig[0,0].Title := $'DecisionTree (Acc = {accTree:F3})'; + + fig[0,1].Surface(x1, x2, 80, 80, G -> forest.PredictLabels(G), Palettes.Pastel); + fig[0,1].Points(x1, x2, labels, size := 6); + fig[0,1].Title := $'RandomForest (Acc = {accForest:F3})'; +end. diff --git a/InstallerSamples/MachineLearning/10_RealTasks/01_Iris_EndToEnd_Pipeline.exe b/InstallerSamples/MachineLearning/10_RealTasks/01_Iris_EndToEnd_Pipeline.exe new file mode 100644 index 0000000000000000000000000000000000000000..2cd94563ddf9adc10674ec748854b5357d3b4e6e GIT binary patch literal 146944 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 V3IVq$y0Z6g2t0oa#{X9g{6B7V9qRx9 literal 0 HcmV?d00001 diff --git a/InstallerSamples/MachineLearning/10_RealTasks/01_Iris_EndToEnd_Pipeline.pas b/InstallerSamples/MachineLearning/10_RealTasks/01_Iris_EndToEnd_Pipeline.pas new file mode 100644 index 000000000..f45b4ebe8 --- /dev/null +++ b/InstallerSamples/MachineLearning/10_RealTasks/01_Iris_EndToEnd_Pipeline.pas @@ -0,0 +1,28 @@ +// Полный пример задачи классификации: +// загружаем датасет, делим его на выборки, +// обучаем pipeline и оцениваем качество. + +uses MLABC; + +begin + var ds := Datasets.Iris; + var (trainDs, testDs) := ds.StratifiedTrainTestSplit(testRatio := 0.3, seed := 42); + + var pipe := DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression(learningRate := 0.05, epochs := 1000) + ); + + pipe.Fit(trainDs.Data); + + var pred := pipe.Predict(testDs.Data); + var yTest := pipe.GetEncodedLabels(testDs.Data); + var acc := ClassificationMetrics.Accuracy(yTest, pred); + + Println('Классификация Iris: полный пример'); + Println; + Println($'Accuracy = {acc:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/10_RealTasks/02_MoscowHousing_EndToEnd_Pipeline.exe b/InstallerSamples/MachineLearning/10_RealTasks/02_MoscowHousing_EndToEnd_Pipeline.exe new file mode 100644 index 0000000000000000000000000000000000000000..47c74c137e5d7daf4c8f0e7c193988bf5adc0a16 GIT binary patch literal 153088 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 OAF%&lcl-Yu1OFFV-K&ZK literal 0 HcmV?d00001 diff --git a/InstallerSamples/MachineLearning/10_RealTasks/02_MoscowHousing_Pipeline.pas b/InstallerSamples/MachineLearning/10_RealTasks/02_MoscowHousing_Pipeline.pas new file mode 100644 index 000000000..ac7c05a0f --- /dev/null +++ b/InstallerSamples/MachineLearning/10_RealTasks/02_MoscowHousing_Pipeline.pas @@ -0,0 +1,34 @@ +// Полный пример задачи регрессии: +// загружаем датасет, делим его на выборки, +// обучаем pipeline и оцениваем качество. + +uses MLABC; + +begin + var ds := Datasets.MoscowHousing; + var df := ds.Data; + + var features := ['rooms', 'area', 'kitchen_area', 'floor', 'floors_total', 'metro_minutes', 'renovation']; + var target := 'price'; + + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 42); + + var pipe := DataPipeline.Build( + TaskKind.tkRegression, + target, + features, + new OneHotEncoder('renovation'), + new StandardScaler, + new LinearRegression + ); + + pipe.Fit(trainDf); + + var pred := pipe.Predict(testDf); + var yTest := testDf.ToVector(target); + var r2 := Metrics.R2(yTest, pred); + + Println('Прогноз цен на жильё: полный пример'); + Println; + Println($'R² = {r2:F3}'); +end. diff --git a/InstallerSamples/MachineLearning/13_Seminars/seminar1.pas b/InstallerSamples/MachineLearning/13_Seminars/seminar1.pas index 8fec04bab..5ea75164c 100644 --- a/InstallerSamples/MachineLearning/13_Seminars/seminar1.pas +++ b/InstallerSamples/MachineLearning/13_Seminars/seminar1.pas @@ -7,7 +7,7 @@ begin df.Schema.Println; // Заполняем пропуски в числовых столбцах средним значением - var imputer := new Imputer('population', 'lat', 'lon'); + var imputer := new Imputer(['population', 'lat', 'lon']); df := imputer.FitTransform(df); // Кодируем категориальные признаки @@ -17,5 +17,5 @@ begin var encoder2 := new LabelEncoder('federal_district'); df := encoder2.FitTransform(df); - df.PrintPreview(6); -end. \ No newline at end of file + df.Print; +end. diff --git a/InstallerSamples/MachineLearning/13_Seminars/seminar2.pas b/InstallerSamples/MachineLearning/13_Seminars/seminar2.pas index c34be1d11..3ca94a083 100644 --- a/InstallerSamples/MachineLearning/13_Seminars/seminar2.pas +++ b/InstallerSamples/MachineLearning/13_Seminars/seminar2.pas @@ -13,7 +13,7 @@ begin ]); // Препроцессинг - var imputer := new Imputer('population', 'lat', 'lon'); + var imputer := new Imputer(['population', 'lat', 'lon']); df := imputer.FitTransform(df); var le1 := new LabelEncoder('region_name'); @@ -35,4 +35,4 @@ begin var preds := model.Predict(X); Println('RMSE:', Metrics.RMSE(y, preds):0:3); -end. \ No newline at end of file +end. diff --git a/InstallerSamples/MachineLearning/13_Seminars/seminar3.pas b/InstallerSamples/MachineLearning/13_Seminars/seminar3.pas index be8f0e523..94d701724 100644 --- a/InstallerSamples/MachineLearning/13_Seminars/seminar3.pas +++ b/InstallerSamples/MachineLearning/13_Seminars/seminar3.pas @@ -4,7 +4,7 @@ begin var df := CsvLoader.Load('towns_russia.csv', inferCategorical := true); df := df.Select(['population', 'lat', 'lon', 'region_name', 'federal_district']); - var imputer := new Imputer('population', 'lat', 'lon'); + var imputer := new Imputer(['population', 'lat', 'lon']); df := imputer.FitTransform(df); var le1 := new LabelEncoder('region_name'); @@ -28,4 +28,4 @@ begin var preds := model.Predict(Xscaled); Println('RMSE:', Metrics.RMSE(y, preds):0:3); -end. \ No newline at end of file +end. diff --git a/InstallerSamples/MachineLearning/13_Seminars/seminar4.pas b/InstallerSamples/MachineLearning/13_Seminars/seminar4.pas index b4822961b..4995eb64b 100644 --- a/InstallerSamples/MachineLearning/13_Seminars/seminar4.pas +++ b/InstallerSamples/MachineLearning/13_Seminars/seminar4.pas @@ -8,10 +8,11 @@ begin var target := 'price'; - var (trainDf, testDf) := df.TrainTestSplit(0.2, 42); + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 42); var pipe := DataPipeline.Build( // сборка pipeline: target + features + шаги + TaskKind.tkRegression, target, // целевая переменная features, // список признаков new OneHotEncoder('renovation'), // DataFrame-уровень: кодирование категории diff --git a/InstallerSamples/MachineLearning/13_Seminars/seminar5_synthetic.pas b/InstallerSamples/MachineLearning/13_Seminars/seminar5_synthetic.pas new file mode 100644 index 000000000..d5b4cba7d --- /dev/null +++ b/InstallerSamples/MachineLearning/13_Seminars/seminar5_synthetic.pas @@ -0,0 +1,122 @@ +uses MLABC, PlotML; + +function ToIntArray(v: Vector): array of integer; +begin + Result := v.Data.Select(t -> integer(t)).ToArray; +end; + +function BinLabels(v: Vector; bins: integer := 8): array of integer; +begin + Result := new integer[v.Length]; + + var vmin := v.Min; + var vmax := v.Max; + + if vmax = vmin then + exit; + + var w := (vmax - vmin) / bins; + + for var i := 0 to v.Length - 1 do + begin + var k := trunc((v[i] - vmin) / w); + if k >= bins then k := bins - 1; + if k < 0 then k := 0; + Result[i] := k; + end; +end; + +procedure DrawDataset(cell: Cell; X: Matrix; labels: array of integer; title: string); +begin + var x1 := X.Col(0); + var x2 := X.Col(1); + + // защита от рассинхронизации + if labels.Length <> X.RowCount then + raise new Exception('Labels length mismatch'); + + cell.SetPalette(Palettes.Bright); + cell.Points(x1, x2, labels, size := 5); + cell.Title := title; +end; + +begin + var fig := Plot.Grid(2, 3); + + // --- Blobs + var (X1, y1) := Datasets.MakeBlobs( + n := 300, + centers := 3, + nFeatures := 2, + clusterStd := 0.7, + clusterStdVar := 0.4, + centerBox := 5.0, + classBalance := 1.0, + noisePoints := 20, + shuffle := True, + seed := 1 + ); + DrawDataset(fig[0,0], X1, ToIntArray(y1), 'MakeBlobs'); + + // --- Moons + var (X2, y2) := Datasets.MakeMoons( + n := 300, + noise := 0.1, + shuffle := True, + seed := 2 + ); + DrawDataset(fig[0,1], X2, ToIntArray(y2), 'MakeMoons'); + + // --- Circles + var (X3, y3) := Datasets.MakeCircles( + n := 300, + noise := 0.08, + factor := 0.45, + classBalance := 0.5, + flipProb := 0.0, + scale := 3.0, + shuffle := True, + seed := 3 + ); + DrawDataset(fig[0,2], X3, ToIntArray(y3), 'MakeCircles'); + + // --- Spiral + var (X4, y4) := Datasets.MakeSpiral( + n := 300, + noise := 0.03, + turns := 2.5, + shuffle := True, + seed := 4 + ); + DrawDataset(fig[1,0], X4, ToIntArray(y4), 'MakeSpiral'); + + // --- Regression (биннинг) + var (X5, y5) := Datasets.MakeRegression( + n := 300, + nFeatures := 2, + nInformative := 2, + noise := 0.02, + coefScale := 1.0, + bias := 0.0, + nonlinearStrength := 3.0, + shuffle := True, + seed := 5 + ); + var labels := ArrFill(X5.RowCount, 0); + DrawDataset(fig[1,1], X5, labels, 'MakeRegression'); + + // --- Classification + var (X6, y6) := Datasets.MakeClassification( + n := 300, + nFeatures := 2, + nInformative := 2, + nRedundant := 0, + noise := 0.2, + classSep := 2.5, + flipProb := 0.05, + classBalance := 0.5, + shuffle := True, + seed := 6 + ); + DrawDataset(fig[1,2], X6, ToIntArray(y6), 'MakeClassification'); +end. \ No newline at end of file diff --git a/InstallerSamples/WhatsNew/3_11_1/DataFrameABC1.pas b/InstallerSamples/WhatsNew/3_11_1/DataFrameABC1.pas index ad4257d69..364429ddc 100644 --- a/InstallerSamples/WhatsNew/3_11_1/DataFrameABC1.pas +++ b/InstallerSamples/WhatsNew/3_11_1/DataFrameABC1.pas @@ -32,10 +32,10 @@ begin Kat,21,NA '''); - df.Filter(row -> row.Int('age') > 20).Println; - - df.GroupBy('age').Mean('score').Println; - + df.Filter(row -> row.Int('age') > 20).Print; + Println; + df.GroupBy('age').Mean('score').Print; + Println; var stat := df.Describe('score'); Println($'score: count={stat.Count}, min={stat.Min}, max={stat.Max}, mean={stat.Mean}, std={stat.Std.Round(3)}'); end. \ No newline at end of file diff --git a/Libraries/WeifenLuo.WinFormsUI.Docking.dll b/Libraries/WeifenLuo.WinFormsUI.Docking.dll index c979b73e183c9127870d1bd547b1e91d970fe6f4..3d620c6fe411a06143735fb1737875e2e0aaa7b8 100644 GIT binary patch delta 143 zcmZoTAlYz0azY2QSLXMP-BTtrMKCohPT8(Fg|RxFWwPqt#_bD!Guo^dkV+4@>uK}G zt+~YO*Q3Ykr?$UY#Z!#sIee`~dJlHkJSY delta 143 zcmZoTAlYz0azY2Q(8I=!-BTtrePL`?oU&bU3S)ITib2 begin var tmp := LabelsToInts(bad); end, 'LabelsToInts must reject non-integer labels'); +end. diff --git a/TestSuite/_MachineLearning/Core/002_vector_tointarray_strict.pas b/TestSuite/_MachineLearning/Core/002_vector_tointarray_strict.pas new file mode 100644 index 000000000..69d8b0873 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/002_vector_tointarray_strict.pas @@ -0,0 +1,15 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ok := new Vector(Arr(1.0, 1.9999999999999, -3.0000000000001)); + var a := ok.ToIntArray; + + Check(a.Length = 3, 'Length mismatch'); + Check(a[0] = 1, 'First value mismatch'); + Check(a[1] = 2, 'Second value mismatch'); + Check(a[2] = -3, 'Third value mismatch'); + + var bad := new Vector(Arr(1.2, 2.0)); + CheckRaises(procedure -> begin var tmp := bad.ToIntArray; end, 'ToIntArray must reject non-integer values'); +end. diff --git a/TestSuite/_MachineLearning/Core/003_dataframe_setschema_validation.pas b/TestSuite/_MachineLearning/Core/003_dataframe_setschema_validation.pas new file mode 100644 index 000000000..6eb200e1c --- /dev/null +++ b/TestSuite/_MachineLearning/Core/003_dataframe_setschema_validation.pas @@ -0,0 +1,15 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('Msk', 'Spb')); + df.AddIntColumn('Age', Arr(20, 30)); + df := df.SetCategorical(['City']); + + var badName := new DataFrameSchema(Arr('Town', 'Age'), Arr(ColumnType.ctStr, ColumnType.ctInt), Arr(true, false)); + CheckRaises(procedure -> begin df.SetSchema(badName); end, 'SetSchema must reject name mismatch'); + + var badType := new DataFrameSchema(Arr('City', 'Age'), Arr(ColumnType.ctBool, ColumnType.ctInt), Arr(true, false)); + CheckRaises(procedure -> begin df.SetSchema(badType); end, 'SetSchema must reject type mismatch'); +end. diff --git a/TestSuite/_MachineLearning/Core/004_groupby_bool_key_schema.pas b/TestSuite/_MachineLearning/Core/004_groupby_bool_key_schema.pas new file mode 100644 index 000000000..7b7f9fa10 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/004_groupby_bool_key_schema.pas @@ -0,0 +1,17 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddBoolColumn('Flag', Arr(true, false, true)); + df.AddFloatColumn('X', Arr(1.0, 2.0, 3.0)); + df := df.SetCategorical(['Flag']); + + var g := df.GroupBy('Flag').Sum('X'); + + Check(g.Schema.ColumnCount = 2, 'Unexpected column count'); + Check(g.Schema.NameAt(0) = 'Flag', 'Key column name mismatch'); + Check(g.Schema.ColumnTypeAt(0) = ColumnType.ctBool, 'Key column type must stay bool'); + Check(g.GetColumn(0).Info.Name = 'Flag', 'Physical key column name mismatch'); + Check(g.GetColumn(0).Info.ColType = ColumnType.ctBool, 'Physical key column type must stay bool'); +end. diff --git a/TestSuite/_MachineLearning/Core/005_head_tail_empty_schema.pas b/TestSuite/_MachineLearning/Core/005_head_tail_empty_schema.pas new file mode 100644 index 000000000..08b7734ed --- /dev/null +++ b/TestSuite/_MachineLearning/Core/005_head_tail_empty_schema.pas @@ -0,0 +1,28 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +procedure CheckSameSchema(a, b: DataFrame; prefix: string); +begin + Check(a.Schema.ColumnCount = b.Schema.ColumnCount, prefix + ': column count mismatch'); + for var i := 0 to a.Schema.ColumnCount - 1 do + begin + Check(a.Schema.NameAt(i) = b.Schema.NameAt(i), prefix + $': name mismatch at {i}'); + Check(a.Schema.ColumnTypeAt(i) = b.Schema.ColumnTypeAt(i), prefix + $': type mismatch at {i}'); + Check(a.Schema.IsCategoricalAt(i) = b.Schema.IsCategoricalAt(i), prefix + $': categorical mismatch at {i}'); + end; +end; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('Msk', 'Spb', 'Kzn')); + df.AddIntColumn('Age', Arr(20, 30, 40)); + df := df.SetCategorical(['City']); + + var h := df.Head(0); + var t := df.Tail(0); + + Check(h.RowCount = 0, 'Head(0) must have 0 rows'); + Check(t.RowCount = 0, 'Tail(0) must have 0 rows'); + CheckSameSchema(df, h, 'Head(0)'); + CheckSameSchema(df, t, 'Tail(0)'); +end. diff --git a/TestSuite/_MachineLearning/Core/006_standardizeall_preserves_nonnumeric_types.pas b/TestSuite/_MachineLearning/Core/006_standardizeall_preserves_nonnumeric_types.pas new file mode 100644 index 000000000..7d71eb8ab --- /dev/null +++ b/TestSuite/_MachineLearning/Core/006_standardizeall_preserves_nonnumeric_types.pas @@ -0,0 +1,16 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('Msk', 'Spb')); + df.AddIntColumn('Age', Arr(20, 30)); + + var z := Statistics.StandardizeAll(df); + + Check(z.Schema.ColumnCount = 2, 'Unexpected column count'); + Check(z.Schema.ColumnTypeAt(0) = ColumnType.ctStr, 'String column type must be preserved'); + Check(z.Schema.ColumnTypeAt(1) = ColumnType.ctFloat, 'Numeric column must become float'); + Check(z.GetColumn(0).Info.ColType = ColumnType.ctStr, 'Physical string column type must be preserved'); + Check(z.GetColumn(1).Info.ColType = ColumnType.ctFloat, 'Physical numeric column must become float'); +end. diff --git a/TestSuite/_MachineLearning/Core/007_normalizeall_preserves_nonnumeric_types.pas b/TestSuite/_MachineLearning/Core/007_normalizeall_preserves_nonnumeric_types.pas new file mode 100644 index 000000000..a8d468bb3 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/007_normalizeall_preserves_nonnumeric_types.pas @@ -0,0 +1,16 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('Msk', 'Spb')); + df.AddIntColumn('Age', Arr(20, 30)); + + var z := Statistics.NormalizeAll(df); + + Check(z.Schema.ColumnCount = 2, 'Unexpected column count'); + Check(z.Schema.ColumnTypeAt(0) = ColumnType.ctStr, 'String column type must be preserved'); + Check(z.Schema.ColumnTypeAt(1) = ColumnType.ctFloat, 'Numeric column must become float'); + Check(z.GetColumn(0).Info.ColType = ColumnType.ctStr, 'Physical string column type must be preserved'); + Check(z.GetColumn(1).Info.ColType = ColumnType.ctFloat, 'Physical numeric column must become float'); +end. diff --git a/TestSuite/_MachineLearning/Core/008_transformlabels_unknown_class_raises.pas b/TestSuite/_MachineLearning/Core/008_transformlabels_unknown_class_raises.pas new file mode 100644 index 000000000..b9fc9e954 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/008_transformlabels_unknown_class_raises.pas @@ -0,0 +1,21 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var trainDf := new DataFrame; + trainDf.AddStrColumn('Target', Arr('cat', 'dog', 'cat')); + trainDf := trainDf.SetCategorical(['Target']); + + var testDf := new DataFrame; + testDf.AddStrColumn('Target', Arr('cat', 'fox')); + testDf := testDf.SetCategorical(['Target']); + + var classes: array of string; + var yTrain := trainDf.EncodeLabels('Target', classes); + + Check(yTrain.Length = 3, 'Encoded training labels length mismatch'); + Check(classes.Length = 2, 'Unexpected class count'); + + CheckRaises(procedure -> begin var tmp := testDf.TransformLabels('Target', classes); end, + 'TransformLabels must reject unseen target classes'); +end. diff --git a/TestSuite/_MachineLearning/Core/009_mape_rejects_nan_inf.pas b/TestSuite/_MachineLearning/Core/009_mape_rejects_nan_inf.pas new file mode 100644 index 000000000..7a891f37e --- /dev/null +++ b/TestSuite/_MachineLearning/Core/009_mape_rejects_nan_inf.pas @@ -0,0 +1,17 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var yTrue := new Vector(Arr(1.0, 2.0, 4.0)); + var good := new Vector(Arr(1.0, 2.2, 3.8)); + var m := Metrics.MAPE(yTrue, good); + Check(m >= 0.0, 'MAPE must be non-negative'); + + var withNaN := new Vector(Arr(1.0, real.NaN, 3.0)); + CheckRaises(procedure -> begin var tmp := Metrics.MAPE(yTrue, withNaN); end, + 'MAPE must reject NaN in predictions'); + + var withInf := new Vector(Arr(1.0, real.PositiveInfinity, 3.0)); + CheckRaises(procedure -> begin var tmp := Metrics.MAPE(yTrue, withInf); end, + 'MAPE must reject Infinity in predictions'); +end. diff --git a/TestSuite/_MachineLearning/Core/010_onehot_preserves_other_metadata.pas b/TestSuite/_MachineLearning/Core/010_onehot_preserves_other_metadata.pas new file mode 100644 index 000000000..f6d1917be --- /dev/null +++ b/TestSuite/_MachineLearning/Core/010_onehot_preserves_other_metadata.pas @@ -0,0 +1,22 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('Msk', 'Spb', 'Kzn')); + df.AddStrColumn('Region', Arr('North', 'South', 'North')); + df.AddIntColumn('Age', Arr(20, 30, 40)); + df := df.SetCategorical(['City', 'Region']); + + var enc := new OneHotEncoder('Region'); + var res := enc.FitTransform(df); + + Check(res.HasColumn('City'), 'Untouched categorical column must stay in result'); + Check(not res.HasColumn('Region'), 'Source column must be removed after one-hot encoding'); + Check(res.Schema.IsCategorical('City'), 'Untouched categorical metadata must be preserved'); + Check(res.Schema.ColumnTypeAt(res.ColumnIndex('City')) = ColumnType.ctStr, 'Untouched column type must stay string'); + Check(res.HasColumn('Region_North'), 'First one-hot column missing'); + Check(res.HasColumn('Region_South'), 'Second one-hot column missing'); + Check(res.Schema.ColumnTypeAt(res.ColumnIndex('Region_North')) = ColumnType.ctInt, 'One-hot columns must be int'); + Check(not res.Schema.IsCategorical('Region_North'), 'One-hot columns must not be categorical'); +end. diff --git a/TestSuite/_MachineLearning/Core/011_imputer_preserves_schema.pas b/TestSuite/_MachineLearning/Core/011_imputer_preserves_schema.pas new file mode 100644 index 000000000..00269c321 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/011_imputer_preserves_schema.pas @@ -0,0 +1,20 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('Msk', 'Spb', 'Kzn')); + df.AddIntColumn('Age', Arr(20, 0, 40), Arr(true, false, true)); + df := df.SetCategorical(['City']); + + var imp := new Imputer(['Age']); + var res := imp.FitTransform(df); + + Check(res.Schema.ColumnCount = df.Schema.ColumnCount, 'Schema column count must stay the same'); + Check(res.Schema.NameAt(0) = 'City', 'City name mismatch'); + Check(res.Schema.NameAt(1) = 'Age', 'Age name mismatch'); + Check(res.Schema.ColumnTypeAt(0) = ColumnType.ctStr, 'City type must stay string'); + Check(res.Schema.ColumnTypeAt(1) = ColumnType.ctFloat, 'Age type must become float for mean imputation'); + Check(res.Schema.IsCategoricalAt(0), 'City categorical flag must be preserved'); + Check(not res.Schema.IsCategoricalAt(1), 'Age categorical flag must stay false'); +end. diff --git a/TestSuite/_MachineLearning/Core/012_imputer_constant_preserves_int_type.pas b/TestSuite/_MachineLearning/Core/012_imputer_constant_preserves_int_type.pas new file mode 100644 index 000000000..e9193b853 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/012_imputer_constant_preserves_int_type.pas @@ -0,0 +1,20 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddIntColumn('Age', Arr(20, 0, 40), Arr(true, false, true)); + + var imp := new Imputer(25, ['Age']); + var res := imp.FitTransform(df); + + Check(res.Schema.ColumnCount = 1, 'Unexpected column count'); + Check(res.Schema.NameAt(0) = 'Age', 'Column name mismatch'); + Check(res.Schema.ColumnTypeAt(0) = ColumnType.ctInt, 'Constant int imputation must preserve int type'); + Check(res.GetColumn(0).Info.ColType = ColumnType.ctInt, 'Physical column type must stay int'); + + var age := res.GetIntColumn('Age'); + Check(age[0] = 20, 'First value mismatch'); + Check(age[1] = 25, 'Imputed value mismatch'); + Check(age[2] = 40, 'Third value mismatch'); +end. diff --git a/TestSuite/_MachineLearning/Core/013_groupby_count_bool_key_schema.pas b/TestSuite/_MachineLearning/Core/013_groupby_count_bool_key_schema.pas new file mode 100644 index 000000000..5c5065040 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/013_groupby_count_bool_key_schema.pas @@ -0,0 +1,17 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddBoolColumn('Flag', Arr(true, false, true)); + df.AddFloatColumn('X', Arr(1.0, 2.0, 3.0)); + df := df.SetCategorical(['Flag']); + + var g := df.GroupBy('Flag').Count; + + Check(g.Schema.ColumnCount = 2, 'Unexpected column count'); + Check(g.Schema.NameAt(0) = 'Flag', 'Key column name mismatch'); + Check(g.Schema.ColumnTypeAt(0) = ColumnType.ctBool, 'Key column type must stay bool'); + Check(g.GetColumn(0).Info.Name = 'Flag', 'Physical key column name mismatch'); + Check(g.GetColumn(0).Info.ColType = ColumnType.ctBool, 'Physical key column type must stay bool'); +end. diff --git a/TestSuite/_MachineLearning/Core/014_dataframe_cursor_invalid_accessor_raises.pas b/TestSuite/_MachineLearning/Core/014_dataframe_cursor_invalid_accessor_raises.pas new file mode 100644 index 000000000..778e54803 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/014_dataframe_cursor_invalid_accessor_raises.pas @@ -0,0 +1,15 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddIntColumn('Age', Arr(20, 30)); + + var cur := df.GetCursor; + Check(cur.MoveNext, 'Cursor must move to first row'); + + CheckRaises(procedure -> begin var s := cur.Str(0); end, + 'Cursor.Str on int column must raise'); + CheckRaises(procedure -> begin var b := cur.Bool(0); end, + 'Cursor.Bool on int column must raise'); +end. diff --git a/TestSuite/_MachineLearning/Core/015_transformlabels_unknown_int_class_raises.pas b/TestSuite/_MachineLearning/Core/015_transformlabels_unknown_int_class_raises.pas new file mode 100644 index 000000000..b878c13d6 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/015_transformlabels_unknown_int_class_raises.pas @@ -0,0 +1,21 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var trainDf := new DataFrame; + trainDf.AddIntColumn('Target', Arr(10, 20, 10)); + trainDf := trainDf.SetCategorical(['Target']); + + var testDf := new DataFrame; + testDf.AddIntColumn('Target', Arr(10, 30)); + testDf := testDf.SetCategorical(['Target']); + + var classes: array of string; + var yTrain := trainDf.EncodeLabels('Target', classes); + + Check(yTrain.Length = 3, 'Encoded training labels length mismatch'); + Check(classes.Length = 2, 'Unexpected class count'); + + CheckRaises(procedure -> begin var tmp := testDf.TransformLabels('Target', classes); end, + 'TransformLabels must reject unseen integer target classes'); +end. diff --git a/TestSuite/_MachineLearning/Core/016_mape_skips_zero_targets.pas b/TestSuite/_MachineLearning/Core/016_mape_skips_zero_targets.pas new file mode 100644 index 000000000..fbd8f3a3e --- /dev/null +++ b/TestSuite/_MachineLearning/Core/016_mape_skips_zero_targets.pas @@ -0,0 +1,13 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var yTrue := new Vector(Arr(0.0, 2.0, 4.0)); + var yPred := new Vector(Arr(100.0, 1.0, 5.0)); + + var m := Metrics.MAPE(yTrue, yPred); + + Check(not real.IsNaN(m), 'MAPE must not become NaN when yTrue contains zeros'); + Check(not real.IsInfinity(m), 'MAPE must not become Infinity when yTrue contains zeros'); + Check(Abs(m - 0.375) < 1e-12, 'MAPE must ignore zero targets in the average'); +end. diff --git a/TestSuite/_MachineLearning/Core/017_r2_zero_variance_returns_zero.pas b/TestSuite/_MachineLearning/Core/017_r2_zero_variance_returns_zero.pas new file mode 100644 index 000000000..d7298990b --- /dev/null +++ b/TestSuite/_MachineLearning/Core/017_r2_zero_variance_returns_zero.pas @@ -0,0 +1,10 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var yTrue := new Vector(Arr(5.0, 5.0, 5.0)); + var yPred := new Vector(Arr(5.0, 5.0, 5.0)); + + var r2 := Metrics.R2(yTrue, yPred); + Check(Abs(r2 - 0.0) < 1e-12, 'R2 must return 0.0 when target variance is zero'); +end. diff --git a/TestSuite/_MachineLearning/Core/018_head_regular_slice.pas b/TestSuite/_MachineLearning/Core/018_head_regular_slice.pas new file mode 100644 index 000000000..090415345 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/018_head_regular_slice.pas @@ -0,0 +1,16 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('A', 'B', 'C', 'D')); + df.AddIntColumn('Age', Arr(10, 20, 30, 40)); + + var h := df.Head(2); + + Check(h.RowCount = 2, 'Head(2) row count mismatch'); + Check(h.GetStrColumn('City')[0] = 'A', 'Head first row mismatch'); + Check(h.GetStrColumn('City')[1] = 'B', 'Head second row mismatch'); + Check(h.GetIntColumn('Age')[0] = 10, 'Head first age mismatch'); + Check(h.GetIntColumn('Age')[1] = 20, 'Head second age mismatch'); +end. diff --git a/TestSuite/_MachineLearning/Core/019_tail_regular_slice.pas b/TestSuite/_MachineLearning/Core/019_tail_regular_slice.pas new file mode 100644 index 000000000..d2403cca0 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/019_tail_regular_slice.pas @@ -0,0 +1,16 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('A', 'B', 'C', 'D')); + df.AddIntColumn('Age', Arr(10, 20, 30, 40)); + + var t := df.Tail(2); + + Check(t.RowCount = 2, 'Tail(2) row count mismatch'); + Check(t.GetStrColumn('City')[0] = 'C', 'Tail first row mismatch'); + Check(t.GetStrColumn('City')[1] = 'D', 'Tail second row mismatch'); + Check(t.GetIntColumn('Age')[0] = 30, 'Tail first age mismatch'); + Check(t.GetIntColumn('Age')[1] = 40, 'Tail second age mismatch'); +end. diff --git a/TestSuite/_MachineLearning/Core/020_filter_preserves_schema.pas b/TestSuite/_MachineLearning/Core/020_filter_preserves_schema.pas new file mode 100644 index 000000000..a991640fa --- /dev/null +++ b/TestSuite/_MachineLearning/Core/020_filter_preserves_schema.pas @@ -0,0 +1,15 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('A', 'B', 'C')); + df.AddIntColumn('Age', Arr(10, 20, 30)); + df.AddFloatColumn('Score', Arr(1.0, 2.0, 3.0)); + df := df.SetCategorical(['City']); + + var res := df.Filter(r -> r.Int('Age') >= 20); + + Check(res.RowCount = 2, 'Filtered row count mismatch'); + CheckSchemaMatchesColumns(res, Arr(true, false, false)); +end. diff --git a/TestSuite/_MachineLearning/Core/021_takerows_preserves_schema_and_order.pas b/TestSuite/_MachineLearning/Core/021_takerows_preserves_schema_and_order.pas new file mode 100644 index 000000000..b817d9c92 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/021_takerows_preserves_schema_and_order.pas @@ -0,0 +1,16 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('A', 'B', 'C')); + df.AddIntColumn('Age', Arr(10, 20, 30)); + df := df.SetCategorical(['City']); + + var res := df.TakeRows([2, 0]); + + Check(res.RowCount = 2, 'TakeRows row count mismatch'); + Check(res.GetStrColumn('City')[0] = 'C', 'First row order mismatch'); + Check(res.GetStrColumn('City')[1] = 'A', 'Second row order mismatch'); + CheckSchemaMatchesColumns(res, Arr(true, false)); +end. diff --git a/TestSuite/_MachineLearning/Core/022_select_preserves_order_and_metadata.pas b/TestSuite/_MachineLearning/Core/022_select_preserves_order_and_metadata.pas new file mode 100644 index 000000000..ffcfae121 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/022_select_preserves_order_and_metadata.pas @@ -0,0 +1,19 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('A', 'B')); + df.AddIntColumn('Age', Arr(10, 20)); + df := df.SetCategorical(['City']); + + var res := df.Select(['Age', 'City']); + + Check(res.Schema.ColumnCount = 2, 'Column count mismatch'); + Check(res.Schema.NameAt(0) = 'Age', 'First selected column mismatch'); + Check(res.Schema.NameAt(1) = 'City', 'Second selected column mismatch'); + Check(res.Schema.ColumnTypeAt(0) = ColumnType.ctInt, 'Age type mismatch'); + Check(res.Schema.ColumnTypeAt(1) = ColumnType.ctStr, 'City type mismatch'); + Check(res.Schema.IsCategoricalAt(1), 'City categorical flag must be preserved'); + CheckSchemaMatchesColumns(res, Arr(false, true)); +end. diff --git a/TestSuite/_MachineLearning/Core/023_rename_keeps_schema_and_column_name_consistent.pas b/TestSuite/_MachineLearning/Core/023_rename_keeps_schema_and_column_name_consistent.pas new file mode 100644 index 000000000..6f51ee3b3 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/023_rename_keeps_schema_and_column_name_consistent.pas @@ -0,0 +1,16 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('A', 'B')); + df.AddIntColumn('Age', Arr(10, 20)); + + var res := df.Rename('Age', 'Years'); + + Check(not res.HasColumn('Age'), 'Old column name must disappear'); + Check(res.HasColumn('Years'), 'New column name must appear'); + Check(res.Schema.NameAt(1) = 'Years', 'Schema must contain new column name'); + Check(res.GetColumn(1).Info.Name = 'Years', 'Physical column name must match schema'); + CheckSchemaMatchesColumns(res, Arr(false, false)); +end. diff --git a/TestSuite/_MachineLearning/Core/024_join_no_collision_names_contract.pas b/TestSuite/_MachineLearning/Core/024_join_no_collision_names_contract.pas new file mode 100644 index 000000000..820a64ac9 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/024_join_no_collision_names_contract.pas @@ -0,0 +1,21 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left := new DataFrame; + left.AddIntColumn('id', Arr(1, 2)); + left.AddStrColumn('name', Arr('A', 'B')); + + var right := new DataFrame; + right.AddIntColumn('id', Arr(1, 2)); + right.AddFloatColumn('score', Arr(10.0, 20.0)); + + var res := left.Join(right, 'id'); + + Check(res.Schema.ColumnCount = 3, 'Join column count mismatch'); + Check(res.Schema.NameAt(0) = 'id', 'First join column mismatch'); + Check(res.Schema.NameAt(1) = 'name', 'Second join column mismatch'); + Check(res.Schema.NameAt(2) = 'score', 'Right unique column must stay without prefix'); + Check(res.GetColumn(2).Info.Name = 'score', 'Physical right column name mismatch'); + CheckSchemaMatchesColumns(res, Arr(false, false, false)); +end. diff --git a/TestSuite/_MachineLearning/Core/025_join_collision_names_contract.pas b/TestSuite/_MachineLearning/Core/025_join_collision_names_contract.pas new file mode 100644 index 000000000..fa8f6483b --- /dev/null +++ b/TestSuite/_MachineLearning/Core/025_join_collision_names_contract.pas @@ -0,0 +1,21 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left := new DataFrame; + left.AddIntColumn('id', Arr(1, 2)); + left.AddFloatColumn('feature', Arr(1.0, 2.0)); + + var right := new DataFrame; + right.AddIntColumn('id', Arr(1, 2)); + right.AddFloatColumn('feature', Arr(10.0, 20.0)); + + var res := left.Join(right, 'id'); + + Check(res.Schema.ColumnCount = 3, 'Join column count mismatch'); + Check(res.Schema.NameAt(0) = 'id', 'First join column mismatch'); + Check(res.Schema.NameAt(1) = 'feature', 'Left feature name mismatch'); + Check(res.Schema.NameAt(2) = 'right_feature', 'Right conflicting column must get prefix'); + Check(res.GetColumn(2).Info.Name = 'right_feature', 'Physical conflicting right column name mismatch'); + CheckSchemaMatchesColumns(res, Arr(false, false, false)); +end. diff --git a/TestSuite/_MachineLearning/Core/026_drop_preserves_order_and_metadata.pas b/TestSuite/_MachineLearning/Core/026_drop_preserves_order_and_metadata.pas new file mode 100644 index 000000000..f7da06f46 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/026_drop_preserves_order_and_metadata.pas @@ -0,0 +1,18 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('A', 'B')); + df.AddIntColumn('Age', Arr(10, 20)); + df.AddFloatColumn('Score', Arr(1.0, 2.0)); + df := df.SetCategorical(['City']); + + var res := df.Drop(['Score']); + + Check(res.ColumnCount = 2, 'Drop column count mismatch'); + Check(res.RowCount = 2, 'Drop row count mismatch'); + Check(res.Schema.NameAt(0) = 'City', 'First remaining column mismatch'); + Check(res.Schema.NameAt(1) = 'Age', 'Second remaining column mismatch'); + CheckSchemaMatchesColumns(res, Arr(true, false)); +end. diff --git a/TestSuite/_MachineLearning/Core/027_join_multikey_contract.pas b/TestSuite/_MachineLearning/Core/027_join_multikey_contract.pas new file mode 100644 index 000000000..d4ba3c1ba --- /dev/null +++ b/TestSuite/_MachineLearning/Core/027_join_multikey_contract.pas @@ -0,0 +1,23 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left := new DataFrame; + left.AddIntColumn('k1', Arr(1, 1, 2)); + left.AddStrColumn('k2', Arr('A', 'B', 'A')); + left.AddFloatColumn('x', Arr(10.0, 20.0, 30.0)); + left := left.SetCategorical(['k2']); + + var right := new DataFrame; + right.AddIntColumn('k1', Arr(1, 2)); + right.AddStrColumn('k2', Arr('A', 'A')); + right.AddFloatColumn('y', Arr(100.0, 200.0)); + right := right.SetCategorical(['k2']); + + var res := left.Join(right, ['k1', 'k2']); + + Check(res.HasColumn('x'), 'Left payload column missing'); + Check(res.HasColumn('y'), 'Right payload column missing'); + Check(res.RowCount = 2, 'Multi-key join row count mismatch'); + CheckSchemaMatchesColumns(res); +end. diff --git a/TestSuite/_MachineLearning/Core/028_right_join_different_keys_contract.pas b/TestSuite/_MachineLearning/Core/028_right_join_different_keys_contract.pas new file mode 100644 index 000000000..083662000 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/028_right_join_different_keys_contract.pas @@ -0,0 +1,18 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left := new DataFrame; + left.AddIntColumn('id_left', Arr(1, 2)); + left.AddStrColumn('name', Arr('A', 'B')); + + var right := new DataFrame; + right.AddIntColumn('id_right', Arr(2, 3)); + right.AddFloatColumn('score', Arr(20.0, 30.0)); + + var res := left.Join(right, ['id_left'], ['id_right'], jkRight); + + Check(res.HasColumn('score'), 'Right payload column missing'); + Check(res.RowCount = 2, 'Right join row count mismatch'); + CheckSchemaMatchesColumns(res); +end. diff --git a/TestSuite/_MachineLearning/Core/029_full_join_contract.pas b/TestSuite/_MachineLearning/Core/029_full_join_contract.pas new file mode 100644 index 000000000..2dea05aea --- /dev/null +++ b/TestSuite/_MachineLearning/Core/029_full_join_contract.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left := new DataFrame; + left.AddIntColumn('id', Arr(1, 2)); + left.AddStrColumn('name', Arr('A', 'B')); + + var right := new DataFrame; + right.AddIntColumn('id', Arr(2, 3)); + right.AddFloatColumn('score', Arr(20.0, 30.0)); + + var res := left.Join(right, 'id', jkFull); + + Check(res.RowCount = 3, 'Full join row count mismatch'); + Check(res.HasColumn('name'), 'Left payload column missing'); + Check(res.HasColumn('score'), 'Right payload column missing'); + CheckSchemaMatchesColumns(res); +end. diff --git a/TestSuite/_MachineLearning/Core/030_labelencoder_first_appearance.pas b/TestSuite/_MachineLearning/Core/030_labelencoder_first_appearance.pas new file mode 100644 index 000000000..ac12ca922 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/030_labelencoder_first_appearance.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('Region', Arr('South', 'North', 'South', 'East')); + df := df.SetCategorical(['Region']); + + var enc := new LabelEncoder('Region'); + enc.Fit(df); + + var res := enc.Transform(df); + var labels := res.GetIntColumn('Region'); + + Check(labels[0] = 0, 'First appearance of South must get code 0'); + Check(labels[1] = 1, 'First appearance of North must get code 1'); + Check(labels[2] = 0, 'Repeated South must keep code 0'); + Check(labels[3] = 2, 'First appearance of East must get code 2'); +end. diff --git a/TestSuite/_MachineLearning/Core/031_dataadapters_encode_str_contract.pas b/TestSuite/_MachineLearning/Core/031_dataadapters_encode_str_contract.pas new file mode 100644 index 000000000..3bd47aa3e --- /dev/null +++ b/TestSuite/_MachineLearning/Core/031_dataadapters_encode_str_contract.pas @@ -0,0 +1,25 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('Target', Arr('cat', 'dog', 'cat', 'fox')); + df := df.SetCategorical(['Target']); + + var classes: array of string; + var y := df.EncodeLabels('Target', classes); + + Check(y.Length = 4, 'Encoded label length mismatch'); + Check(classes.Length = 3, 'Class count mismatch'); + Check(classes[0] = 'cat', 'First class must follow first appearance'); + Check(classes[1] = 'dog', 'Second class must follow first appearance'); + Check(classes[2] = 'fox', 'Third class must follow first appearance'); + Check(y[0] = 0, 'First encoded label mismatch'); + Check(y[1] = 1, 'Second encoded label mismatch'); + Check(y[2] = 0, 'Third encoded label mismatch'); + Check(y[3] = 2, 'Fourth encoded label mismatch'); + + var y2 := df.TransformLabels('Target', classes); + for var i := 0 to y.Length - 1 do + Check(y2[i] = y[i], $'TransformLabels mismatch at {i}'); +end. diff --git a/TestSuite/_MachineLearning/Core/032_dataadapters_encode_int_contract.pas b/TestSuite/_MachineLearning/Core/032_dataadapters_encode_int_contract.pas new file mode 100644 index 000000000..d59fb2760 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/032_dataadapters_encode_int_contract.pas @@ -0,0 +1,25 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddIntColumn('Target', Arr(10, 20, 10, 30)); + df := df.SetCategorical(['Target']); + + var classes: array of string; + var y := df.EncodeLabels('Target', classes); + + Check(y.Length = 4, 'Encoded label length mismatch'); + Check(classes.Length = 3, 'Class count mismatch'); + Check(classes[0] = '10', 'First class must follow first appearance'); + Check(classes[1] = '20', 'Second class must follow first appearance'); + Check(classes[2] = '30', 'Third class must follow first appearance'); + Check(y[0] = 0, 'First encoded label mismatch'); + Check(y[1] = 1, 'Second encoded label mismatch'); + Check(y[2] = 0, 'Third encoded label mismatch'); + Check(y[3] = 2, 'Fourth encoded label mismatch'); + + var y2 := df.TransformLabels('Target', classes); + for var i := 0 to y.Length - 1 do + Check(y2[i] = y[i], $'TransformLabels mismatch at {i}'); +end. diff --git a/TestSuite/_MachineLearning/Core/033_full_join_different_keys_contract.pas b/TestSuite/_MachineLearning/Core/033_full_join_different_keys_contract.pas new file mode 100644 index 000000000..f86e74926 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/033_full_join_different_keys_contract.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left := new DataFrame; + left.AddIntColumn('id_left', Arr(1, 2)); + left.AddStrColumn('name', Arr('A', 'B')); + + var right := new DataFrame; + right.AddIntColumn('id_right', Arr(2, 3)); + right.AddFloatColumn('score', Arr(20.0, 30.0)); + + var res := left.Join(right, ['id_left'], ['id_right'], jkFull); + + Check(res.RowCount = 3, 'Full join with different keys row count mismatch'); + Check(res.HasColumn('name'), 'Left payload column missing'); + Check(res.HasColumn('score'), 'Right payload column missing'); + CheckSchemaMatchesColumns(res); +end. diff --git a/TestSuite/_MachineLearning/Core/034_dataframe_setschema_failure_is_atomic.pas b/TestSuite/_MachineLearning/Core/034_dataframe_setschema_failure_is_atomic.pas new file mode 100644 index 000000000..c360b593a --- /dev/null +++ b/TestSuite/_MachineLearning/Core/034_dataframe_setschema_failure_is_atomic.pas @@ -0,0 +1,21 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('Msk', 'Spb')); + df.AddIntColumn('Age', Arr(20, 30)); + df := df.SetCategorical(['City']); + + CheckRaises(procedure -> begin + df.SetSchema(new DataFrameSchema( + Arr('Town', 'Age'), + Arr(ColumnType.ctStr, ColumnType.ctInt), + Arr(true, false) + )); + end, 'SetSchema must reject mismatched schema'); + + Check(df.ColumnCount = 2, 'ColumnCount must stay unchanged after failed SetSchema'); + Check(df.Schema.NameAt(0) = 'City', 'Schema must stay unchanged after failed SetSchema'); + CheckSchemaMatchesColumns(df, Arr(true, false)); +end. diff --git a/TestSuite/_MachineLearning/Core/035_dataframe_add_duplicate_column_is_atomic.pas b/TestSuite/_MachineLearning/Core/035_dataframe_add_duplicate_column_is_atomic.pas new file mode 100644 index 000000000..1b883cf7f --- /dev/null +++ b/TestSuite/_MachineLearning/Core/035_dataframe_add_duplicate_column_is_atomic.pas @@ -0,0 +1,15 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddIntColumn('Age', Arr(10, 20, 30)); + + CheckRaises(procedure -> begin + df.AddIntColumn('Age', Arr(1, 2, 3)); + end, 'Duplicate AddIntColumn must be rejected'); + + Check(df.ColumnCount = 1, 'ColumnCount must stay unchanged after duplicate AddIntColumn'); + Check(df.HasColumn('Age'), 'Original column must stay present'); + CheckSchemaMatchesColumns(df, Arr(false)); +end. diff --git a/TestSuite/_MachineLearning/Core/036_setcategorical_returns_independent_dataframe.pas b/TestSuite/_MachineLearning/Core/036_setcategorical_returns_independent_dataframe.pas new file mode 100644 index 000000000..8d84802c3 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/036_setcategorical_returns_independent_dataframe.pas @@ -0,0 +1,18 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('Msk', 'Spb')); + df.AddIntColumn('Age', Arr(20, 30)); + + var df2 := df.SetCategorical(['City']); + df.AddIntColumn('X', Arr(1, 2)); + + Check(df.ColumnCount = 3, 'Original DataFrame must have the new column'); + Check(df2.ColumnCount = 2, 'SetCategorical result must keep its own column list'); + Check(not df2.HasColumn('X'), 'SetCategorical result must not see later columns from source DataFrame'); + Check(df2.Schema.NameAt(0) = 'City', 'First schema name must stay intact'); + Check(df2.Schema.NameAt(1) = 'Age', 'Second schema name must stay intact'); + CheckSchemaMatchesColumns(df2, Arr(true, false)); +end. diff --git a/TestSuite/_MachineLearning/Core/037_metrics_accuracy_rejects_non_integer_labels.pas b/TestSuite/_MachineLearning/Core/037_metrics_accuracy_rejects_non_integer_labels.pas new file mode 100644 index 000000000..4aeda3a2d --- /dev/null +++ b/TestSuite/_MachineLearning/Core/037_metrics_accuracy_rejects_non_integer_labels.pas @@ -0,0 +1,11 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var yTrue := new Vector(Arr(0.49, 1.0)); + var yPred := new Vector(Arr(0.51, 1.0)); + + CheckRaises(procedure -> begin + var a := Metrics.Accuracy(yTrue, yPred); + end, 'Accuracy must reject non-integer class labels'); +end. diff --git a/TestSuite/_MachineLearning/Core/038_metrics_accuracy_rejects_nan_inf.pas b/TestSuite/_MachineLearning/Core/038_metrics_accuracy_rejects_nan_inf.pas new file mode 100644 index 000000000..b9c041ebc --- /dev/null +++ b/TestSuite/_MachineLearning/Core/038_metrics_accuracy_rejects_nan_inf.pas @@ -0,0 +1,11 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var yTrue := new Vector(Arr(0.0, 1.0)); + var yPred := new Vector(Arr(real.NaN, 1.0)); + + CheckRaises(procedure -> begin + var a := Metrics.Accuracy(yTrue, yPred); + end, 'Accuracy must reject NaN and Infinity in class labels'); +end. diff --git a/TestSuite/_MachineLearning/Core/039_join_preserves_categorical_flags_on_right_nonkey_columns.pas b/TestSuite/_MachineLearning/Core/039_join_preserves_categorical_flags_on_right_nonkey_columns.pas new file mode 100644 index 000000000..22a8256ab --- /dev/null +++ b/TestSuite/_MachineLearning/Core/039_join_preserves_categorical_flags_on_right_nonkey_columns.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left := new DataFrame; + left.AddIntColumn('Id', Arr(1, 2)); + left.AddStrColumn('Name', Arr('A', 'B')); + + var right := new DataFrame; + right.AddIntColumn('Id', Arr(1, 2)); + right.AddStrColumn('City', Arr('Msk', 'Spb')); + right := right.SetCategorical(['City']); + + var joined := left.Join(right, 'Id'); + + Check(joined.HasColumn('City'), 'Joined DataFrame must contain right non-key column City'); + Check(joined.IsCategorical('City'), 'Right categorical non-key column must stay categorical after Join'); + CheckSchemaMatchesColumns(joined, Arr(false, false, true)); +end. diff --git a/TestSuite/_MachineLearning/Core/040_groupby_keys_are_marked_categorical.pas b/TestSuite/_MachineLearning/Core/040_groupby_keys_are_marked_categorical.pas new file mode 100644 index 000000000..78210e66d --- /dev/null +++ b/TestSuite/_MachineLearning/Core/040_groupby_keys_are_marked_categorical.pas @@ -0,0 +1,14 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddBoolColumn('Flag', Arr(true, false, true)); + df.AddFloatColumn('X', Arr(1.0, 2.0, 3.0)); + df := df.SetCategorical(['Flag']); + + var g := df.GroupBy(['Flag']).Count; + + Check(g.IsCategorical('Flag'), 'GroupBy key must stay categorical'); + CheckSchemaMatchesColumns(g, Arr(true, false)); +end. diff --git a/TestSuite/_MachineLearning/Core/041_labelencoder_transform_type_drift_raises_contract_error.pas b/TestSuite/_MachineLearning/Core/041_labelencoder_transform_type_drift_raises_contract_error.pas new file mode 100644 index 000000000..56ee4226a --- /dev/null +++ b/TestSuite/_MachineLearning/Core/041_labelencoder_transform_type_drift_raises_contract_error.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var trainDf := new DataFrame; + trainDf.AddStrColumn('City', Arr('Msk', 'Spb', 'Msk')); + trainDf := trainDf.SetCategorical(['City']); + + var testDf := new DataFrame; + testDf.AddIntColumn('City', Arr(1, 2, 3)); + testDf := testDf.SetCategorical(['City']); + + var enc := new LabelEncoder('City'); + enc.Fit(trainDf); + + CheckRaises(procedure -> begin + var res := enc.Transform(testDf); + end, 'LabelEncoder must raise a clear contract error on column type drift'); +end. diff --git a/TestSuite/_MachineLearning/Core/042_onehot_transform_type_drift_raises_contract_error.pas b/TestSuite/_MachineLearning/Core/042_onehot_transform_type_drift_raises_contract_error.pas new file mode 100644 index 000000000..51abc08e7 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/042_onehot_transform_type_drift_raises_contract_error.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var trainDf := new DataFrame; + trainDf.AddStrColumn('City', Arr('Msk', 'Spb', 'Msk')); + trainDf := trainDf.SetCategorical(['City']); + + var testDf := new DataFrame; + testDf.AddIntColumn('City', Arr(1, 2, 3)); + testDf := testDf.SetCategorical(['City']); + + var enc := new OneHotEncoder('City'); + enc.Fit(trainDf); + + CheckRaises(procedure -> begin + var res := enc.Transform(testDf); + end, 'OneHotEncoder must raise a clear contract error on column type drift'); +end. diff --git a/TestSuite/_MachineLearning/Core/043_join_different_keys_with_right_name_collision.pas b/TestSuite/_MachineLearning/Core/043_join_different_keys_with_right_name_collision.pas new file mode 100644 index 000000000..7452f5e39 --- /dev/null +++ b/TestSuite/_MachineLearning/Core/043_join_different_keys_with_right_name_collision.pas @@ -0,0 +1,20 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left := new DataFrame; + left.AddIntColumn('left_id', Arr(1, 2)); + left.AddStrColumn('Name', Arr('A', 'B')); + + var right := new DataFrame; + right.AddIntColumn('right_id', Arr(1, 2)); + right.AddStrColumn('left_id', Arr('X', 'Y')); + right := right.SetCategorical(['left_id']); + + var joined := left.Join(right, Arr($'left_id'), Arr($'right_id'), jkInner); + + Check(joined.HasColumn('left_id'), 'Joined DataFrame must keep left key column'); + Check(joined.HasColumn('right_left_id'), 'Joined DataFrame must rename conflicting right non-key column'); + Check(joined.IsCategorical('right_left_id'), 'Renamed right categorical non-key column must stay categorical'); + CheckSchemaMatchesColumns(joined, Arr(false, false, true)); +end. diff --git a/TestSuite/_MachineLearning/Examples/001_iris_07_pipeline_smoke.pas b/TestSuite/_MachineLearning/Examples/001_iris_07_pipeline_smoke.pas new file mode 100644 index 000000000..25a7a52db --- /dev/null +++ b/TestSuite/_MachineLearning/Examples/001_iris_07_pipeline_smoke.pas @@ -0,0 +1,27 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression + ); + + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 3); + + pipe.Fit(trainDf); + + var pred := pipe.Predict(testDf); + var y := pipe.GetEncodedLabels(testDf); + + Check(pred.Length = testDf.RowCount, 'Predict length mismatch'); + Check(y.Length = testDf.RowCount, 'Encoded labels length mismatch'); + Check(Metrics.Accuracy(y, pred) > 0.8, 'Pipeline accuracy is unexpectedly low'); +end. diff --git a/TestSuite/_MachineLearning/Examples/002_iris_04_traintestsplit_smoke.pas b/TestSuite/_MachineLearning/Examples/002_iris_04_traintestsplit_smoke.pas new file mode 100644 index 000000000..f16cb4cd8 --- /dev/null +++ b/TestSuite/_MachineLearning/Examples/002_iris_04_traintestsplit_smoke.pas @@ -0,0 +1,20 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var (Xtrain, Xtest, ytrain, ytest) := Validation.TrainTestSplit(X, y, 0.2, 1); + + var model := new LogisticRegression; + model.Fit(Xtrain, ytrain); + + var pred := model.Predict(Xtest); + + Check(pred.Length = ytest.Length, 'Prediction length mismatch'); + Check(Metrics.Accuracy(ytest, pred) > 0.8, 'TrainTestSplit example accuracy is unexpectedly low'); +end. diff --git a/TestSuite/_MachineLearning/Examples/003_preprocessing_minmax_smoke.pas b/TestSuite/_MachineLearning/Examples/003_preprocessing_minmax_smoke.pas new file mode 100644 index 000000000..bca0aad07 --- /dev/null +++ b/TestSuite/_MachineLearning/Examples/003_preprocessing_minmax_smoke.pas @@ -0,0 +1,17 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var X := new Matrix(5, 2); + X[0,0] := 1; X[1,0] := 2; X[2,0] := 3; X[3,0] := 10; X[4,0] := 20; + X[0,1] := 100; X[1,1] := 120; X[2,1] := 150; X[3,1] := 300; X[4,1] := 500; + + var scaler := new MinMaxScaler; + scaler.Fit(X); + var Xscaled := scaler.Transform(X); + + Check(Abs(Xscaled.ColumnMin(0) - 0.0) < 1e-12, 'First column min must be 0'); + Check(Abs(Xscaled.ColumnMax(0) - 1.0) < 1e-12, 'First column max must be 1'); + Check(Abs(Xscaled.ColumnMin(1) - 0.0) < 1e-12, 'Second column min must be 0'); + Check(Abs(Xscaled.ColumnMax(1) - 1.0) < 1e-12, 'Second column max must be 1'); +end. diff --git a/TestSuite/_MachineLearning/Examples/004_preprocessing_onehot_smoke.pas b/TestSuite/_MachineLearning/Examples/004_preprocessing_onehot_smoke.pas new file mode 100644 index 000000000..822470c6a --- /dev/null +++ b/TestSuite/_MachineLearning/Examples/004_preprocessing_onehot_smoke.pas @@ -0,0 +1,18 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('A', 'B', 'C')); + df.AddStrColumn('Region', Arr('North', 'South', 'North')); + df := df.SetCategorical(['City', 'Region']); + + var enc := new OneHotEncoder('Region'); + var res := enc.FitTransform(df); + + Check(res.HasColumn('City'), 'City must stay in result'); + Check(res.HasColumn('Region_North'), 'Region_North must exist'); + Check(res.HasColumn('Region_South'), 'Region_South must exist'); + Check(not res.HasColumn('Region'), 'Source region column must be removed'); + Check(res.RowCount = df.RowCount, 'Row count must stay the same'); +end. diff --git a/TestSuite/_MachineLearning/Examples/005_preprocessing_imputer_smoke.pas b/TestSuite/_MachineLearning/Examples/005_preprocessing_imputer_smoke.pas new file mode 100644 index 000000000..348b78c25 --- /dev/null +++ b/TestSuite/_MachineLearning/Examples/005_preprocessing_imputer_smoke.pas @@ -0,0 +1,16 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('A', 'B', 'C')); + df.AddIntColumn('Population', Arr(10, 0, 30), Arr(true, false, true)); + + var imp := new Imputer(['Population']); + var res := imp.FitTransform(df); + + Check(res.RowCount = df.RowCount, 'Row count must stay the same'); + Check(res.HasColumn('City'), 'City must stay in result'); + Check(res.HasColumn('Population'), 'Population must stay in result'); + Check(res.GetColumn(res.ColumnIndex('Population')).Info.ColType = ColumnType.ctFloat, 'Mean-imputed population must become float'); +end. diff --git a/TestSuite/_MachineLearning/Examples/006_upipeline_kmeans_smoke.pas b/TestSuite/_MachineLearning/Examples/006_upipeline_kmeans_smoke.pas new file mode 100644 index 000000000..3494ef0be --- /dev/null +++ b/TestSuite/_MachineLearning/Examples/006_upipeline_kmeans_smoke.pas @@ -0,0 +1,17 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + + var pipe := + UDataPipeline.Build( + ds.Features, + new StandardScaler, + new KMeans(3, seed := 1) + ); + + var labels := pipe.FitPredict(ds.Data); + + Check(labels.Length = ds.Data.RowCount, 'UDataPipeline labels length mismatch'); +end. diff --git a/TestSuite/_MachineLearning/Regression/001_join_schema_names.pas b/TestSuite/_MachineLearning/Regression/001_join_schema_names.pas new file mode 100644 index 000000000..f3c4923b7 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/001_join_schema_names.pas @@ -0,0 +1,29 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left1 := new DataFrame; + left1.AddIntColumn('id', Arr(1, 2)); + left1.AddIntColumn('feature1', Arr(10, 20)); + + var right1 := new DataFrame; + right1.AddIntColumn('id', Arr(1, 2)); + right1.AddIntColumn('feature2', Arr(100, 200)); + + var joined1 := left1.Join(right1, 'id'); + Check(joined1.HasColumn('feature2'), 'Expected feature2 without prefix'); + Check(not joined1.HasColumn('right_feature2'), 'Unexpected right_feature2 without collision'); + CheckSchemaMatchesColumns(joined1); + + var left2 := new DataFrame; + left2.AddIntColumn('id', Arr(1, 2)); + left2.AddIntColumn('feature', Arr(10, 20)); + + var right2 := new DataFrame; + right2.AddIntColumn('id', Arr(1, 2)); + right2.AddIntColumn('feature', Arr(100, 200)); + + var joined2 := left2.Join(right2, 'id'); + Check(joined2.HasColumn('right_feature'), 'Expected right_feature on collision'); + CheckSchemaMatchesColumns(joined2); +end. diff --git a/TestSuite/_MachineLearning/Regression/002_pipeline_predictlabels.pas b/TestSuite/_MachineLearning/Regression/002_pipeline_predictlabels.pas new file mode 100644 index 000000000..11078f51d --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/002_pipeline_predictlabels.pas @@ -0,0 +1,44 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 3); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression + ); + + pipe.Fit(trainDf); + + var classes := pipe.GetClassLabels; + var pred := pipe.Predict(testDf); + var predLabels := pipe.PredictLabels(testDf); + var y := pipe.GetEncodedLabels(testDf); + var trueLabels := testDf.GetStrColumn(ds.Target); + + Check(classes.Length > 0, 'classes must not be empty'); + Check(pred.Length = testDf.RowCount, 'Predict length mismatch'); + Check(predLabels.Length = testDf.RowCount, 'PredictLabels length mismatch'); + Check(y.Length = testDf.RowCount, 'GetEncodedLabels length mismatch'); + + for var i := 0 to testDf.RowCount - 1 do + begin + var pi := Round(pred[i]); + var yi := Round(y[i]); + + Check(Abs(pred[i] - pi) < 1e-12, $'Predict[{i}] is not an encoded integer'); + Check((pi >= 0) and (pi < classes.Length), $'Predict[{i}] out of range'); + Check(predLabels[i] = classes[pi], $'PredictLabels[{i}] does not decode Predict[{i}]'); + + Check(Abs(y[i] - yi) < 1e-12, $'GetEncodedLabels[{i}] is not an encoded integer'); + Check((yi >= 0) and (yi < classes.Length), $'GetEncodedLabels[{i}] out of range'); + Check(classes[yi] = trueLabels[i], $'GetEncodedLabels[{i}] does not decode to true target'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/003_logistic_predictlabels_contract.pas b/TestSuite/_MachineLearning/Regression/003_logistic_predictlabels_contract.pas new file mode 100644 index 000000000..de7b6da52 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/003_logistic_predictlabels_contract.pas @@ -0,0 +1,29 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new LogisticRegression; + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var modelClasses := model.GetClassLabels; + + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + Check(pred.Length = X.RowCount, 'Predict length mismatch'); + Check(labels.Length = X.RowCount, 'PredictLabels length mismatch'); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check(Abs(pred[i] - pi) < 1e-12, $'Predict[{i}] is not an original integer label'); + Check((labels[i] >= 0) and (labels[i] < modelClasses.Length), $'PredictLabels[{i}] out of range'); + Check(modelClasses[labels[i]] = pi.ToString, $'Predict[{i}] and PredictLabels[{i}] are inconsistent'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/004_randomforest_predictlabels_contract.pas b/TestSuite/_MachineLearning/Regression/004_randomforest_predictlabels_contract.pas new file mode 100644 index 000000000..1491cb531 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/004_randomforest_predictlabels_contract.pas @@ -0,0 +1,29 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new RandomForestClassifier(20, maxDepth := 6, seed := 1); + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var modelClasses := model.GetClassLabels; + + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + Check(pred.Length = X.RowCount, 'Predict length mismatch'); + Check(labels.Length = X.RowCount, 'PredictLabels length mismatch'); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check(Abs(pred[i] - pi) < 1e-12, $'Predict[{i}] is not an original integer label'); + Check((labels[i] >= 0) and (labels[i] < modelClasses.Length), $'PredictLabels[{i}] out of range'); + Check(modelClasses[labels[i]] = pi.ToString, $'Predict[{i}] and PredictLabels[{i}] are inconsistent'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/005_gradientboosting_predictlabels_contract.pas b/TestSuite/_MachineLearning/Regression/005_gradientboosting_predictlabels_contract.pas new file mode 100644 index 000000000..9e615a1d8 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/005_gradientboosting_predictlabels_contract.pas @@ -0,0 +1,29 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new GradientBoostingClassifier(20, learningRate := 0.1, maxDepth := 3, seed := 1); + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var modelClasses := model.GetClassLabels; + + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + Check(pred.Length = X.RowCount, 'Predict length mismatch'); + Check(labels.Length = X.RowCount, 'PredictLabels length mismatch'); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check(Abs(pred[i] - pi) < 1e-12, $'Predict[{i}] is not an original integer label'); + Check((labels[i] >= 0) and (labels[i] < modelClasses.Length), $'PredictLabels[{i}] out of range'); + Check(modelClasses[labels[i]] = pi.ToString, $'Predict[{i}] and PredictLabels[{i}] are inconsistent'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/006_knn_predictlabels_contract.pas b/TestSuite/_MachineLearning/Regression/006_knn_predictlabels_contract.pas new file mode 100644 index 000000000..b526b13d6 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/006_knn_predictlabels_contract.pas @@ -0,0 +1,29 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new KNNClassifier(5); + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var modelClasses := model.GetClassLabels; + + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + Check(pred.Length = X.RowCount, 'Predict length mismatch'); + Check(labels.Length = X.RowCount, 'PredictLabels length mismatch'); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check(Abs(pred[i] - pi) < 1e-12, $'Predict[{i}] is not an original integer label'); + Check((labels[i] >= 0) and (labels[i] < modelClasses.Length), $'PredictLabels[{i}] out of range'); + Check(modelClasses[labels[i]] = pi.ToString, $'Predict[{i}] and PredictLabels[{i}] are inconsistent'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/007_minmax_inverse_range.pas b/TestSuite/_MachineLearning/Regression/007_minmax_inverse_range.pas new file mode 100644 index 000000000..68f05d1a7 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/007_minmax_inverse_range.pas @@ -0,0 +1,20 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var X := new Matrix(4, 2); + X[0,0] := 1.0; X[0,1] := 10.0; + X[1,0] := 2.0; X[1,1] := 20.0; + X[2,0] := 5.0; X[2,1] := 40.0; + X[3,0] := 9.0; X[3,1] := 80.0; + + var scaler := new MinMaxScaler(-1.0, 1.0); + scaler.Fit(X); + + var scaled := scaler.Transform(X); + var restored := scaler.InverseTransform(scaled); + + for var i := 0 to X.RowCount - 1 do + for var j := 0 to X.ColCount - 1 do + Check(Abs(restored[i,j] - X[i,j]) < 1e-9, $'InverseTransform mismatch at [{i},{j}]'); +end. diff --git a/TestSuite/_MachineLearning/Regression/008_dataframe_schema_snapshot.pas b/TestSuite/_MachineLearning/Regression/008_dataframe_schema_snapshot.pas new file mode 100644 index 000000000..b16f7ffb9 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/008_dataframe_schema_snapshot.pas @@ -0,0 +1,24 @@ +uses MLABC, DataFrameABCCore; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddStrColumn('City', Arr('Msk', 'Spb')); + df.AddIntColumn('Age', Arr(20, 30)); + df := df.SetCategorical(['City']); + + var s1 := df.Schema; + var names := s1.ColumnNames; + var types := s1.Types; + var cats := s1.CategoricalFlags; + + names[0] := 'Broken'; + types[0] := ColumnType.ctBool; + cats[0] := false; + + var s2 := df.Schema; + + Check(s2.NameAt(0) = 'City', 'Schema name snapshot must not mutate DataFrame'); + Check(s2.ColumnTypeAt(0) = ColumnType.ctStr, 'Schema type snapshot must not mutate DataFrame'); + Check(s2.IsCategoricalAt(0), 'Schema categorical snapshot must not mutate DataFrame'); +end. diff --git a/TestSuite/_MachineLearning/Regression/009_pipeline_rowcount_invariant.pas b/TestSuite/_MachineLearning/Regression/009_pipeline_rowcount_invariant.pas new file mode 100644 index 000000000..94c51dded --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/009_pipeline_rowcount_invariant.pas @@ -0,0 +1,45 @@ +uses MLABC, PreprocessorABC; +uses TestHelpers in '..\TestHelpers.pas'; + +type + BadDropper = class(IPreprocessor) + public + function Fit(df: DataFrame): IPreprocessor; + function Transform(df: DataFrame): DataFrame; + function FitTransform(df: DataFrame): DataFrame; + function Clone: IPreprocessor; + end; + +function BadDropper.Fit(df: DataFrame): IPreprocessor; +begin + Result := Self; +end; + +function BadDropper.Transform(df: DataFrame): DataFrame; +begin + Result := df.Head(df.RowCount - 1); +end; + +function BadDropper.FitTransform(df: DataFrame): DataFrame; +begin + Result := Transform(df); +end; + +function BadDropper.Clone: IPreprocessor; +begin + Result := new BadDropper; +end; + +begin + var ds := Datasets.Iris; + var pipe := DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new BadDropper, + new LogisticRegression + ); + + CheckRaises(procedure -> begin pipe.Fit(ds.Data); end, + 'Pipeline must reject DataFrame preprocessors that change RowCount'); +end. diff --git a/TestSuite/_MachineLearning/Regression/010_decisiontree_predictlabels_contract.pas b/TestSuite/_MachineLearning/Regression/010_decisiontree_predictlabels_contract.pas new file mode 100644 index 000000000..1285fb5a3 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/010_decisiontree_predictlabels_contract.pas @@ -0,0 +1,29 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new DecisionTreeClassifier(maxDepth := 6, seed := 1); + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var modelClasses := model.GetClassLabels; + + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + Check(pred.Length = X.RowCount, 'Predict length mismatch'); + Check(labels.Length = X.RowCount, 'PredictLabels length mismatch'); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check(Abs(pred[i] - pi) < 1e-12, $'Predict[{i}] is not an original integer label'); + Check((labels[i] >= 0) and (labels[i] < modelClasses.Length), $'PredictLabels[{i}] out of range'); + Check(modelClasses[labels[i]] = pi.ToString, $'Predict[{i}] and PredictLabels[{i}] are inconsistent'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/011_decisiontree_getclasslabels_contract.pas b/TestSuite/_MachineLearning/Regression/011_decisiontree_getclasslabels_contract.pas new file mode 100644 index 000000000..25ede7f11 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/011_decisiontree_getclasslabels_contract.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new DecisionTreeClassifier(maxDepth := 6, seed := 1); + model.Fit(X, y); + + var modelClasses := model.GetClassLabels; + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + + for var i := 0 to classes.Length - 1 do + Check(modelClasses[i] = i.ToString, $'DecisionTree GetClassLabels mismatch at {i}'); +end. diff --git a/TestSuite/_MachineLearning/Regression/012_pipeline_getencodedlabels_unknown_class.pas b/TestSuite/_MachineLearning/Regression/012_pipeline_getencodedlabels_unknown_class.pas new file mode 100644 index 000000000..00e841aea --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/012_pipeline_getencodedlabels_unknown_class.pas @@ -0,0 +1,26 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var trainDf := new DataFrame; + trainDf.AddFloatColumn('X', Arr(0.0, 1.0, 0.0, 1.0)); + trainDf.AddStrColumn('Target', Arr('cat', 'dog', 'cat', 'dog')); + trainDf := trainDf.SetCategorical(['Target']); + + var testDf := new DataFrame; + testDf.AddFloatColumn('X', Arr(0.5, 0.7)); + testDf.AddStrColumn('Target', Arr('cat', 'fox')); + testDf := testDf.SetCategorical(['Target']); + + var pipe := DataPipeline.Build( + TaskKind.tkClassification, + 'Target', + Arr($'X'), + new LogisticRegression + ); + + pipe.Fit(trainDf); + + CheckRaises(procedure -> begin var y := pipe.GetEncodedLabels(testDf); end, + 'GetEncodedLabels must reject unseen target classes'); +end. diff --git a/TestSuite/_MachineLearning/Regression/013_upipeline_rowcount_invariant.pas b/TestSuite/_MachineLearning/Regression/013_upipeline_rowcount_invariant.pas new file mode 100644 index 000000000..9567668f8 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/013_upipeline_rowcount_invariant.pas @@ -0,0 +1,43 @@ +uses MLABC, PreprocessorABC; +uses TestHelpers in '..\TestHelpers.pas'; + +type + BadDropper = class(IPreprocessor) + public + function Fit(df: DataFrame): IPreprocessor; + function Transform(df: DataFrame): DataFrame; + function FitTransform(df: DataFrame): DataFrame; + function Clone: IPreprocessor; + end; + +function BadDropper.Fit(df: DataFrame): IPreprocessor; +begin + Result := Self; +end; + +function BadDropper.Transform(df: DataFrame): DataFrame; +begin + Result := df.Head(df.RowCount - 1); +end; + +function BadDropper.FitTransform(df: DataFrame): DataFrame; +begin + Result := Transform(df); +end; + +function BadDropper.Clone: IPreprocessor; +begin + Result := new BadDropper; +end; + +begin + var ds := Datasets.Iris; + var pipe := UDataPipeline.Build( + ds.Features, + new BadDropper, + new KMeans(3, seed := 1) + ); + + CheckRaises(procedure -> begin var labels := pipe.FitPredict(ds.Data); end, + 'UDataPipeline must reject DataFrame preprocessors that change RowCount'); +end. diff --git a/TestSuite/_MachineLearning/Regression/014_logistic_getclasslabels_contract.pas b/TestSuite/_MachineLearning/Regression/014_logistic_getclasslabels_contract.pas new file mode 100644 index 000000000..79fd8e02e --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/014_logistic_getclasslabels_contract.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new LogisticRegression; + model.Fit(X, y); + + var modelClasses := model.GetClassLabels; + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + + for var i := 0 to classes.Length - 1 do + Check(modelClasses[i] = i.ToString, $'LogisticRegression GetClassLabels mismatch at {i}'); +end. diff --git a/TestSuite/_MachineLearning/Regression/015_randomforest_getclasslabels_contract.pas b/TestSuite/_MachineLearning/Regression/015_randomforest_getclasslabels_contract.pas new file mode 100644 index 000000000..602167a0d --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/015_randomforest_getclasslabels_contract.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new RandomForestClassifier(20, maxDepth := 6, seed := 1); + model.Fit(X, y); + + var modelClasses := model.GetClassLabels; + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + + for var i := 0 to classes.Length - 1 do + Check(modelClasses[i] = i.ToString, $'RandomForestClassifier GetClassLabels mismatch at {i}'); +end. diff --git a/TestSuite/_MachineLearning/Regression/016_gradientboosting_getclasslabels_contract.pas b/TestSuite/_MachineLearning/Regression/016_gradientboosting_getclasslabels_contract.pas new file mode 100644 index 000000000..a92b0e180 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/016_gradientboosting_getclasslabels_contract.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new GradientBoostingClassifier(20, learningRate := 0.1, maxDepth := 3, seed := 1); + model.Fit(X, y); + + var modelClasses := model.GetClassLabels; + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + + for var i := 0 to classes.Length - 1 do + Check(modelClasses[i] = i.ToString, $'GradientBoostingClassifier GetClassLabels mismatch at {i}'); +end. diff --git a/TestSuite/_MachineLearning/Regression/017_knn_getclasslabels_contract.pas b/TestSuite/_MachineLearning/Regression/017_knn_getclasslabels_contract.pas new file mode 100644 index 000000000..e64f17a6b --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/017_knn_getclasslabels_contract.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new KNNClassifier(5); + model.Fit(X, y); + + var modelClasses := model.GetClassLabels; + Check(modelClasses.Length = classes.Length, 'class count mismatch'); + + for var i := 0 to classes.Length - 1 do + Check(modelClasses[i] = i.ToString, $'KNNClassifier GetClassLabels mismatch at {i}'); +end. diff --git a/TestSuite/_MachineLearning/Regression/018_pipeline_regression_rejects_string_target.pas b/TestSuite/_MachineLearning/Regression/018_pipeline_regression_rejects_string_target.pas new file mode 100644 index 000000000..f4b46427f --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/018_pipeline_regression_rejects_string_target.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddFloatColumn('X', Arr(1.0, 2.0, 3.0)); + df.AddStrColumn('Target', Arr('low', 'mid', 'high')); + df := df.SetCategorical(['Target']); + + var pipe := DataPipeline.Build( + TaskKind.tkRegression, + 'Target', + Arr($'X'), + new LinearRegression + ); + + CheckRaises(procedure -> begin pipe.Fit(df); end, + 'Regression pipeline must reject non-numeric target'); +end. diff --git a/TestSuite/_MachineLearning/Regression/019_pipeline_classification_requires_categorical_target.pas b/TestSuite/_MachineLearning/Regression/019_pipeline_classification_requires_categorical_target.pas new file mode 100644 index 000000000..2f72ccd48 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/019_pipeline_classification_requires_categorical_target.pas @@ -0,0 +1,18 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddFloatColumn('X', Arr(1.0, 2.0, 3.0, 4.0)); + df.AddStrColumn('Target', Arr('cat', 'dog', 'cat', 'dog')); + + var pipe := DataPipeline.Build( + TaskKind.tkClassification, + 'Target', + Arr($'X'), + new LogisticRegression + ); + + CheckRaises(procedure -> begin pipe.Fit(df); end, + 'Classification pipeline must require categorical target'); +end. diff --git a/TestSuite/_MachineLearning/Regression/020_logistic_predictproba_shape.pas b/TestSuite/_MachineLearning/Regression/020_logistic_predictproba_shape.pas new file mode 100644 index 000000000..5d2050068 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/020_logistic_predictproba_shape.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new LogisticRegression; + model.Fit(X, y); + + var proba := model.PredictProba(X); + + Check(proba.RowCount = X.RowCount, 'Probability row count mismatch'); + Check(proba.ColCount = classes.Length, 'Probability class count mismatch'); + CheckProbabilityRowsSumToOne(proba); +end. diff --git a/TestSuite/_MachineLearning/Regression/021_randomforest_predictproba_shape.pas b/TestSuite/_MachineLearning/Regression/021_randomforest_predictproba_shape.pas new file mode 100644 index 000000000..22554d7c4 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/021_randomforest_predictproba_shape.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new RandomForestClassifier(20, maxDepth := 6, seed := 1); + model.Fit(X, y); + + var proba := model.PredictProba(X); + + Check(proba.RowCount = X.RowCount, 'Probability row count mismatch'); + Check(proba.ColCount = classes.Length, 'Probability class count mismatch'); + CheckProbabilityRowsSumToOne(proba); +end. diff --git a/TestSuite/_MachineLearning/Regression/022_gradientboosting_predictproba_shape.pas b/TestSuite/_MachineLearning/Regression/022_gradientboosting_predictproba_shape.pas new file mode 100644 index 000000000..f7bec5daa --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/022_gradientboosting_predictproba_shape.pas @@ -0,0 +1,19 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var X := ds.Data.ToMatrix(ds.Features); + + var classes: array of string; + var y := ds.Data.EncodeLabels(ds.Target, classes); + + var model := new GradientBoostingClassifier(20, learningRate := 0.1, maxDepth := 3, seed := 1); + model.Fit(X, y); + + var proba := model.PredictProba(X); + + Check(proba.RowCount = X.RowCount, 'Probability row count mismatch'); + Check(proba.ColCount = classes.Length, 'Probability class count mismatch'); + CheckProbabilityRowsSumToOne(proba); +end. diff --git a/TestSuite/_MachineLearning/Regression/023_pipeline_predictproba_shape.pas b/TestSuite/_MachineLearning/Regression/023_pipeline_predictproba_shape.pas new file mode 100644 index 000000000..df92fb040 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/023_pipeline_predictproba_shape.pas @@ -0,0 +1,26 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression + ); + + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 3); + pipe.Fit(trainDf); + + var classes := pipe.GetClassLabels; + var proba := pipe.PredictProba(testDf); + + Check(proba.RowCount = testDf.RowCount, 'Probability row count mismatch'); + Check(proba.ColCount = classes.Length, 'Probability class count mismatch'); + CheckProbabilityRowsSumToOne(proba); +end. diff --git a/TestSuite/_MachineLearning/Regression/024_pipeline_target_in_features_rejected.pas b/TestSuite/_MachineLearning/Regression/024_pipeline_target_in_features_rejected.pas new file mode 100644 index 000000000..10d24472e --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/024_pipeline_target_in_features_rejected.pas @@ -0,0 +1,14 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + CheckRaises(procedure -> begin + var pipe := DataPipeline.Build( + TaskKind.tkClassification, + 'Target', + Arr($'X', $'Target'), + new LogisticRegression + ); + end, + 'DataPipeline.Build must reject target inside features'); +end. diff --git a/TestSuite/_MachineLearning/Regression/025_upipeline_duplicate_features_rejected.pas b/TestSuite/_MachineLearning/Regression/025_upipeline_duplicate_features_rejected.pas new file mode 100644 index 000000000..ba605165d --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/025_upipeline_duplicate_features_rejected.pas @@ -0,0 +1,12 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + CheckRaises(procedure -> begin + var pipe := UDataPipeline.Build( + Arr($'X', $'X'), + new KMeans(3, seed := 1) + ); + end, + 'UDataPipeline.Build must reject duplicate features'); +end. diff --git a/TestSuite/_MachineLearning/Regression/026_pipeline_predictlabels_returns_original_strings.pas b/TestSuite/_MachineLearning/Regression/026_pipeline_predictlabels_returns_original_strings.pas new file mode 100644 index 000000000..b19e7dbf7 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/026_pipeline_predictlabels_returns_original_strings.pas @@ -0,0 +1,27 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 3); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression + ); + + pipe.Fit(trainDf); + + var labels := pipe.PredictLabels(testDf); + var classes := pipe.GetClassLabels; + + Check(labels.Length = testDf.RowCount, 'PredictLabels length mismatch'); + + for var i := 0 to labels.Length - 1 do + Check(labels[i] in classes, $'PredictLabels[{i}] must be one of pipeline class labels'); +end. diff --git a/TestSuite/_MachineLearning/Regression/027_join_float_key_rejected.pas b/TestSuite/_MachineLearning/Regression/027_join_float_key_rejected.pas new file mode 100644 index 000000000..ffd32fc8a --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/027_join_float_key_rejected.pas @@ -0,0 +1,15 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var left := new DataFrame; + left.AddFloatColumn('id', Arr(1.0, 2.0)); + left.AddStrColumn('name', Arr('A', 'B')); + + var right := new DataFrame; + right.AddFloatColumn('id', Arr(1.0, 2.0)); + right.AddFloatColumn('score', Arr(10.0, 20.0)); + + CheckRaises(procedure -> begin var res := left.Join(right, 'id'); end, + 'Join on float key must be rejected'); +end. diff --git a/TestSuite/_MachineLearning/Regression/028_pipeline_target_bound_step_blocked.pas b/TestSuite/_MachineLearning/Regression/028_pipeline_target_bound_step_blocked.pas new file mode 100644 index 000000000..592494065 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/028_pipeline_target_bound_step_blocked.pas @@ -0,0 +1,53 @@ +uses MLABC, PreprocessorABC, MLCoreABC; +uses TestHelpers in '..\TestHelpers.pas'; + +type + BadTargetStep = class(IPreprocessor, IColumnBoundStep) + private + fColumn: string; + public + constructor Create(column: string); + function Fit(df: DataFrame): IPreprocessor; + function Transform(df: DataFrame): DataFrame; + function FitTransform(df: DataFrame): DataFrame; + function Clone: IPreprocessor; + property ColumnName: string read fColumn; + end; + +constructor BadTargetStep.Create(column: string); +begin + fColumn := column; +end; + +function BadTargetStep.Fit(df: DataFrame): IPreprocessor; +begin + Result := Self; +end; + +function BadTargetStep.Transform(df: DataFrame): DataFrame; +begin + Result := df; +end; + +function BadTargetStep.FitTransform(df: DataFrame): DataFrame; +begin + Result := df; +end; + +function BadTargetStep.Clone: IPreprocessor; +begin + Result := new BadTargetStep(fColumn); +end; + +begin + CheckRaises(procedure -> begin + var pipe := DataPipeline.Build( + TaskKind.tkClassification, + 'Target', + Arr($'X'), + new BadTargetStep('Target'), + new LogisticRegression + ); + end, + 'DataPipeline must reject a bound preprocessor that targets the target column'); +end. diff --git a/TestSuite/_MachineLearning/Regression/029_pipeline_target_columns_step_blocked.pas b/TestSuite/_MachineLearning/Regression/029_pipeline_target_columns_step_blocked.pas new file mode 100644 index 000000000..5f027e78d --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/029_pipeline_target_columns_step_blocked.pas @@ -0,0 +1,53 @@ +uses MLABC, PreprocessorABC, MLCoreABC; +uses TestHelpers in '..\TestHelpers.pas'; + +type + BadTargetColumnsStep = class(IPreprocessor, IColumnsBoundStep) + private + fColumns: array of string; + public + constructor Create(columns: array of string); + function Fit(df: DataFrame): IPreprocessor; + function Transform(df: DataFrame): DataFrame; + function FitTransform(df: DataFrame): DataFrame; + function Clone: IPreprocessor; + property Columns: array of string read fColumns; + end; + +constructor BadTargetColumnsStep.Create(columns: array of string); +begin + fColumns := Copy(columns); +end; + +function BadTargetColumnsStep.Fit(df: DataFrame): IPreprocessor; +begin + Result := Self; +end; + +function BadTargetColumnsStep.Transform(df: DataFrame): DataFrame; +begin + Result := df; +end; + +function BadTargetColumnsStep.FitTransform(df: DataFrame): DataFrame; +begin + Result := df; +end; + +function BadTargetColumnsStep.Clone: IPreprocessor; +begin + Result := new BadTargetColumnsStep(fColumns); +end; + +begin + CheckRaises(procedure -> begin + var pipe := DataPipeline.Build( + TaskKind.tkClassification, + 'Target', + Arr($'X'), + new BadTargetColumnsStep(Arr($'X', $'Target')), + new LogisticRegression + ); + end, + 'DataPipeline must reject a multi-column bound preprocessor that includes the target column'); +end. diff --git a/TestSuite/_MachineLearning/Regression/030_pipeline_int_categorical_target_contract.pas b/TestSuite/_MachineLearning/Regression/030_pipeline_int_categorical_target_contract.pas new file mode 100644 index 000000000..1cc1d3851 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/030_pipeline_int_categorical_target_contract.pas @@ -0,0 +1,41 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var df := new DataFrame; + df.AddFloatColumn('X', Arr(0.0, 1.0, 0.0, 1.0)); + df.AddIntColumn('Target', Arr(10, 20, 10, 20)); + df := df.SetCategorical(['Target']); + + var pipe := DataPipeline.Build( + TaskKind.tkClassification, + 'Target', + Arr($'X'), + new LogisticRegression + ); + + pipe.Fit(df); + + var pred := pipe.Predict(df); + var predLabels := pipe.PredictLabels(df); + var enc := pipe.GetEncodedLabels(df); + var classes := pipe.GetClassLabels; + + Check(classes.Length = 2, 'Class count mismatch'); + Check((classes[0] = '10') or (classes[0] = '20'), 'Unexpected first class label'); + Check((classes[1] = '10') or (classes[1] = '20'), 'Unexpected second class label'); + Check(classes[0] <> classes[1], 'Class labels must be distinct'); + + Check(pred.Length = df.RowCount, 'Predict length mismatch'); + Check(predLabels.Length = df.RowCount, 'PredictLabels length mismatch'); + Check(enc.Length = df.RowCount, 'GetEncodedLabels length mismatch'); + + for var i := 0 to df.RowCount - 1 do + begin + var pi := Round(pred[i]); + var ei := Round(enc[i]); + Check((pi >= 0) and (pi < classes.Length), $'Predict[{i}] out of range'); + Check((ei >= 0) and (ei < classes.Length), $'GetEncodedLabels[{i}] out of range'); + Check(predLabels[i] = classes[pi], $'PredictLabels[{i}] mismatch'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/031_logistic_sparse_raw_labels_contract.pas b/TestSuite/_MachineLearning/Regression/031_logistic_sparse_raw_labels_contract.pas new file mode 100644 index 000000000..a3e5d31cb --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/031_logistic_sparse_raw_labels_contract.pas @@ -0,0 +1,38 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var X := new Matrix(6, 1); + X[0,0] := 0.0; X[1,0] := 0.1; + X[2,0] := 1.0; X[3,0] := 1.1; + X[4,0] := 2.0; X[5,0] := 2.1; + + var y := new Vector(Arr(10.0, 10.0, 20.0, 20.0, 30.0, 30.0)); + + var model := new LogisticRegression; + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var classes := model.GetClassLabels; + var proba := model.PredictProba(X); + + Check(classes.Length = 3, 'Class count mismatch'); + Check(classes[0] = '10', 'First class label mismatch'); + Check(classes[1] = '20', 'Second class label mismatch'); + Check(classes[2] = '30', 'Third class label mismatch'); + + Check(pred.Length = X.RowCount, 'Predict length mismatch'); + Check(labels.Length = X.RowCount, 'PredictLabels length mismatch'); + Check(proba.RowCount = X.RowCount, 'PredictProba row count mismatch'); + Check(proba.ColCount = classes.Length, 'PredictProba class count mismatch'); + CheckProbabilityRowsSumToOne(proba); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check((labels[i] >= 0) and (labels[i] < classes.Length), $'PredictLabels[{i}] out of range'); + Check((pi = 10) or (pi = 20) or (pi = 30), $'Predict[{i}] must return original sparse label'); + Check(classes[labels[i]] = pi.ToString, $'Predict and PredictLabels mismatch at {i}'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/032_randomforest_sparse_raw_labels_contract.pas b/TestSuite/_MachineLearning/Regression/032_randomforest_sparse_raw_labels_contract.pas new file mode 100644 index 000000000..425fe8ed5 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/032_randomforest_sparse_raw_labels_contract.pas @@ -0,0 +1,33 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var X := new Matrix(6, 1); + X[0,0] := 0.0; X[1,0] := 0.1; + X[2,0] := 1.0; X[3,0] := 1.1; + X[4,0] := 2.0; X[5,0] := 2.1; + + var y := new Vector(Arr(10.0, 10.0, 20.0, 20.0, 30.0, 30.0)); + + var model := new RandomForestClassifier(20, maxDepth := 6, seed := 1); + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var classes := model.GetClassLabels; + var proba := model.PredictProba(X); + + Check(classes.Length = 3, 'Class count mismatch'); + Check(classes[0] = '10', 'First class label mismatch'); + Check(classes[1] = '20', 'Second class label mismatch'); + Check(classes[2] = '30', 'Third class label mismatch'); + CheckProbabilityRowsSumToOne(proba); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check((labels[i] >= 0) and (labels[i] < classes.Length), $'PredictLabels[{i}] out of range'); + Check((pi = 10) or (pi = 20) or (pi = 30), $'Predict[{i}] must return original sparse label'); + Check(classes[labels[i]] = pi.ToString, $'Predict and PredictLabels mismatch at {i}'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/033_gradientboosting_sparse_raw_labels_contract.pas b/TestSuite/_MachineLearning/Regression/033_gradientboosting_sparse_raw_labels_contract.pas new file mode 100644 index 000000000..6f38b4c11 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/033_gradientboosting_sparse_raw_labels_contract.pas @@ -0,0 +1,33 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var X := new Matrix(6, 1); + X[0,0] := 0.0; X[1,0] := 0.1; + X[2,0] := 1.0; X[3,0] := 1.1; + X[4,0] := 2.0; X[5,0] := 2.1; + + var y := new Vector(Arr(10.0, 10.0, 20.0, 20.0, 30.0, 30.0)); + + var model := new GradientBoostingClassifier(20, learningRate := 0.1, maxDepth := 3, seed := 1); + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var classes := model.GetClassLabels; + var proba := model.PredictProba(X); + + Check(classes.Length = 3, 'Class count mismatch'); + Check(classes[0] = '10', 'First class label mismatch'); + Check(classes[1] = '20', 'Second class label mismatch'); + Check(classes[2] = '30', 'Third class label mismatch'); + CheckProbabilityRowsSumToOne(proba); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check((labels[i] >= 0) and (labels[i] < classes.Length), $'PredictLabels[{i}] out of range'); + Check((pi = 10) or (pi = 20) or (pi = 30), $'Predict[{i}] must return original sparse label'); + Check(classes[labels[i]] = pi.ToString, $'Predict and PredictLabels mismatch at {i}'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/034_decisiontree_sparse_raw_labels_contract.pas b/TestSuite/_MachineLearning/Regression/034_decisiontree_sparse_raw_labels_contract.pas new file mode 100644 index 000000000..90bcaf834 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/034_decisiontree_sparse_raw_labels_contract.pas @@ -0,0 +1,31 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var X := new Matrix(6, 1); + X[0,0] := 0.0; X[1,0] := 0.1; + X[2,0] := 1.0; X[3,0] := 1.1; + X[4,0] := 2.0; X[5,0] := 2.1; + + var y := new Vector(Arr(10.0, 10.0, 20.0, 20.0, 30.0, 30.0)); + + var model := new DecisionTreeClassifier(maxDepth := 6, seed := 1); + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var classes := model.GetClassLabels; + + Check(classes.Length = 3, 'Class count mismatch'); + Check(classes[0] = '10', 'First class label mismatch'); + Check(classes[1] = '20', 'Second class label mismatch'); + Check(classes[2] = '30', 'Third class label mismatch'); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check((labels[i] >= 0) and (labels[i] < classes.Length), $'PredictLabels[{i}] out of range'); + Check((pi = 10) or (pi = 20) or (pi = 30), $'Predict[{i}] must return original sparse label'); + Check(classes[labels[i]] = pi.ToString, $'Predict and PredictLabels mismatch at {i}'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/035_knn_sparse_raw_labels_contract.pas b/TestSuite/_MachineLearning/Regression/035_knn_sparse_raw_labels_contract.pas new file mode 100644 index 000000000..184f119ec --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/035_knn_sparse_raw_labels_contract.pas @@ -0,0 +1,33 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var X := new Matrix(6, 1); + X[0,0] := 0.0; X[1,0] := 0.1; + X[2,0] := 1.0; X[3,0] := 1.1; + X[4,0] := 2.0; X[5,0] := 2.1; + + var y := new Vector(Arr(10.0, 10.0, 20.0, 20.0, 30.0, 30.0)); + + var model := new KNNClassifier(3); + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var classes := model.GetClassLabels; + var proba := model.PredictProba(X); + + Check(classes.Length = 3, 'Class count mismatch'); + Check(classes[0] = '10', 'First class label mismatch'); + Check(classes[1] = '20', 'Second class label mismatch'); + Check(classes[2] = '30', 'Third class label mismatch'); + CheckProbabilityRowsSumToOne(proba); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check((labels[i] >= 0) and (labels[i] < classes.Length), $'PredictLabels[{i}] out of range'); + Check((pi = 10) or (pi = 20) or (pi = 30), $'Predict[{i}] must return original sparse label'); + Check(classes[labels[i]] = pi.ToString, $'Predict and PredictLabels mismatch at {i}'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/036_pipeline_predict_dense_encoded_labels.pas b/TestSuite/_MachineLearning/Regression/036_pipeline_predict_dense_encoded_labels.pas new file mode 100644 index 000000000..1e249fc5e --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/036_pipeline_predict_dense_encoded_labels.pas @@ -0,0 +1,32 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 3); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression + ); + + pipe.Fit(trainDf); + + var pred := pipe.Predict(testDf); + var classes := pipe.GetClassLabels; + + Check(pred.Length = testDf.RowCount, 'Predict length mismatch'); + Check(classes.Length > 0, 'Class labels must not be empty'); + + for var i := 0 to pred.Length - 1 do + begin + var pi := Round(pred[i]); + Check(Abs(pred[i] - pi) < 1e-12, $'Predict[{i}] must be a dense encoded integer'); + Check((pi >= 0) and (pi < classes.Length), $'Predict[{i}] out of dense class range'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/037_pipeline_predict_contract_matches_documented_semantics.pas b/TestSuite/_MachineLearning/Regression/037_pipeline_predict_contract_matches_documented_semantics.pas new file mode 100644 index 000000000..7deba4649 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/037_pipeline_predict_contract_matches_documented_semantics.pas @@ -0,0 +1,35 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var ds := Datasets.Iris; + var df := ds.Data; + var (trainDf, testDf) := df.TrainTestSplit(0.2, seed := 3); + + var pipe := + DataPipeline.Build( + TaskKind.tkClassification, + ds.Target, + ds.Features, + new StandardScaler, + new LogisticRegression + ); + + pipe.Fit(trainDf); + + var pred := pipe.Predict(testDf); + var predLabels := pipe.PredictLabels(testDf); + var classes := pipe.GetClassLabels; + + Check(pred.Length = testDf.RowCount, 'Predict length mismatch'); + Check(predLabels.Length = testDf.RowCount, 'PredictLabels length mismatch'); + Check(classes.Length > 0, 'Class labels must not be empty'); + + for var i := 0 to pred.Length - 1 do + begin + var pi := Round(pred[i]); + Check(Abs(pred[i] - pi) < 1e-12, $'Predict[{i}] must be an internal class index'); + Check((pi >= 0) and (pi < classes.Length), $'Predict[{i}] out of range'); + Check(predLabels[i] = classes[pi], $'PredictLabels[{i}] must decode Predict[{i}]'); + end; +end. diff --git a/TestSuite/_MachineLearning/Regression/038_standalone_classifier_predict_contract_is_explicit.pas b/TestSuite/_MachineLearning/Regression/038_standalone_classifier_predict_contract_is_explicit.pas new file mode 100644 index 000000000..2f7e722e2 --- /dev/null +++ b/TestSuite/_MachineLearning/Regression/038_standalone_classifier_predict_contract_is_explicit.pas @@ -0,0 +1,28 @@ +uses MLABC; +uses TestHelpers in '..\TestHelpers.pas'; + +begin + var X := new Matrix(6, 1); + X[0,0] := 0.0; X[1,0] := 0.1; + X[2,0] := 1.0; X[3,0] := 1.1; + X[4,0] := 2.0; X[5,0] := 2.1; + + var y := new Vector(Arr(10.0, 10.0, 20.0, 20.0, 30.0, 30.0)); + + var model := new LogisticRegression; + model.Fit(X, y); + + var pred := model.Predict(X); + var labels := model.PredictLabels(X); + var classes := model.GetClassLabels; + + Check(classes.Length = 3, 'Class count mismatch'); + + for var i := 0 to X.RowCount - 1 do + begin + var pi := Round(pred[i]); + Check((pi = 10) or (pi = 20) or (pi = 30), $'Predict[{i}] must return original class label'); + Check((labels[i] >= 0) and (labels[i] < classes.Length), $'PredictLabels[{i}] out of range'); + Check(classes[labels[i]] = pi.ToString, $'PredictLabels[{i}] must point to Predict[{i}]'); + end; +end. diff --git a/TestSuite/_MachineLearning/TestHelpers.pas b/TestSuite/_MachineLearning/TestHelpers.pas new file mode 100644 index 000000000..33da14306 --- /dev/null +++ b/TestSuite/_MachineLearning/TestHelpers.pas @@ -0,0 +1,63 @@ +unit TestHelpers; + +interface + +uses MLABC; + +procedure Check(cond: boolean; msg: string); +procedure CheckRaises(action: procedure; msg: string); +procedure CheckSchemaMatchesColumns(df: DataFrame); +procedure CheckSchemaMatchesColumns(df: DataFrame; expectedCats: array of boolean); +procedure CheckProbabilityRowsSumToOne(m: Matrix; eps: real := 1e-9); + +implementation + +procedure Check(cond: boolean; msg: string); +begin + if not cond then + raise new Exception(msg); +end; + +procedure CheckRaises(action: procedure; msg: string); +begin + var raised := false; + try + action(); + except + on e: Exception do + raised := true; + end; + Check(raised, msg); +end; + +procedure CheckSchemaMatchesColumns(df: DataFrame); +begin + Check(df.Schema.ColumnCount = df.ColumnCount, 'Schema/column count mismatch'); + for var i := 0 to df.ColumnCount - 1 do + begin + Check(df.Schema.NameAt(i) = df.GetColumn(i).Info.Name, $'Name mismatch at {i}'); + Check(df.Schema.ColumnTypeAt(i) = df.GetColumn(i).Info.ColType, $'Type mismatch at {i}'); + Check(df.GetColumn(i).RowCount = df.RowCount, $'RowCount mismatch at column {i}'); + end; +end; + +procedure CheckSchemaMatchesColumns(df: DataFrame; expectedCats: array of boolean); +begin + CheckSchemaMatchesColumns(df); + Check(expectedCats.Length = df.ColumnCount, 'Expected categorical flags length mismatch'); + for var i := 0 to df.ColumnCount - 1 do + Check(df.Schema.IsCategoricalAt(i) = expectedCats[i], $'Categorical mismatch at {i}'); +end; + +procedure CheckProbabilityRowsSumToOne(m: Matrix; eps: real); +begin + for var i := 0 to m.RowCount - 1 do + begin + var s := 0.0; + for var j := 0 to m.ColCount - 1 do + s += m[i, j]; + Check(Abs(s - 1.0) < eps, $'Probability row {i} must sum to 1'); + end; +end; + +end. diff --git a/TestSuite/_MachineLearning/clean_ml_tests.cmd b/TestSuite/_MachineLearning/clean_ml_tests.cmd new file mode 100644 index 000000000..ea9b9d6a6 --- /dev/null +++ b/TestSuite/_MachineLearning/clean_ml_tests.cmd @@ -0,0 +1,6 @@ +@echo off +chcp 65001 >nul +for /r "%~dp0" %%F in (*.exe) do del /q "%%F" +for /r "%~dp0" %%F in (*.pdb) do del /q "%%F" +echo Done. +pause diff --git a/TestSuite/_MachineLearning/run_ml_tests.cmd b/TestSuite/_MachineLearning/run_ml_tests.cmd new file mode 100644 index 000000000..89248d468 --- /dev/null +++ b/TestSuite/_MachineLearning/run_ml_tests.cmd @@ -0,0 +1,4 @@ +@echo off +chcp 65001 >nul +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0run_ml_tests.ps1" -Fast +pause diff --git a/TestSuite/_MachineLearning/run_ml_tests.ps1 b/TestSuite/_MachineLearning/run_ml_tests.ps1 new file mode 100644 index 000000000..bf5259c48 --- /dev/null +++ b/TestSuite/_MachineLearning/run_ml_tests.ps1 @@ -0,0 +1,121 @@ +param( + [string]$Root = 'D:\PABC_GIT\TestSuite\_MachineLearning', + [switch]$Fast +) + +[Console]::InputEncoding = [System.Text.Encoding]::UTF8 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$compiler = 'D:\PABC_GIT\bin\pabcnetcclear.exe' +$libRoot = 'D:\PABC_GIT\bin\Lib' +$tests = Get-ChildItem -Path $Root -Recurse -Filter *.pas | Where-Object { $_.Name -ne 'TestHelpers.pas' } | Sort-Object FullName +$libStamp = (Get-ChildItem -Path $libRoot -Recurse -Filter *.pas | Measure-Object LastWriteTime -Maximum).Maximum +$testHelpers = Join-Path $Root 'TestHelpers.pas' +$helperStamp = if (Test-Path $testHelpers) { (Get-Item $testHelpers).LastWriteTime } else { Get-Date '2000-01-01' } + +if ($tests.Count -eq 0) { + Write-Host 'No tests found.' + exit 0 +} + +$failed = @() +$passedCount = 0 +$progressOnLine = 0 + +Write-Host "Found tests: $($tests.Count)" +if ($Fast) { + Write-Host 'Mode: FAST' +} +Write-Host '' + +foreach ($test in $tests) { + $exe = [System.IO.Path]::ChangeExtension($test.FullName, '.exe') + $pdb = [System.IO.Path]::ChangeExtension($test.FullName, '.pdb') + + $needCompile = $true + if ($Fast -and (Test-Path $exe)) { + $exeStamp = (Get-Item $exe).LastWriteTime + if (($exeStamp -ge $test.LastWriteTime) -and ($exeStamp -ge $libStamp) -and ($exeStamp -ge $helperStamp)) { + $needCompile = $false + } + } + + if ($needCompile) { + $compileOut = & $compiler "/SearchDir:$Root" $test.FullName 2>&1 + if ($LASTEXITCODE -ne 0) { + if ($progressOnLine -gt 0) { + Write-Host '' + $progressOnLine = 0 + } + Write-Host "=== $($test.FullName) ===" + if ($compileOut) { $compileOut | ForEach-Object { Write-Host $_ } } + Write-Host 'COMPILE FAIL' + Write-Host '' + $failed += "$($test.FullName) [compile]" + continue + } + } + + if (-not (Test-Path $exe)) { + if ($progressOnLine -gt 0) { + Write-Host '' + $progressOnLine = 0 + } + Write-Host "=== $($test.FullName) ===" + Write-Host 'EXE NOT FOUND' + Write-Host '' + $failed += "$($test.FullName) [no exe]" + continue + } + + $runOut = & $exe 2>&1 + $runCode = $LASTEXITCODE + + if (-not $Fast) { + if (Test-Path $exe) { Remove-Item -LiteralPath $exe -Force } + if (Test-Path $pdb) { Remove-Item -LiteralPath $pdb -Force } + } + + if ($runCode -ne 0) { + if ($progressOnLine -gt 0) { + Write-Host '' + $progressOnLine = 0 + } + Write-Host "=== $($test.FullName) ===" + if ($runOut) { $runOut | ForEach-Object { Write-Host $_ } } + Write-Host 'RUN FAIL' + Write-Host '' + $failed += "$($test.FullName) [run]" + continue + } + + $passedCount += 1 + Write-Host -NoNewline '.' + $progressOnLine += 1 + if ($progressOnLine -ge 10) { + Write-Host '' + $progressOnLine = 0 + } +} + +if ($progressOnLine -gt 0) { + Write-Host '' +} + +Write-Host '' +Write-Host 'SUMMARY' +Write-Host " Total : $($tests.Count)" +Write-Host " Passed: $passedCount" +Write-Host " Failed: $($failed.Count)" + +if ($failed.Count -gt 0) { + Write-Host '' + Write-Host 'FAILED TESTS:' + $failed | ForEach-Object { Write-Host $_ } + exit 1 +} + +Write-Host '' +Write-Host 'ALL TESTS PASSED' +exit 0 diff --git a/bin/Lib/DataAdapters.pas b/bin/Lib/DataAdapters.pas index 36d5e3520..3777fa7ed 100644 --- a/bin/Lib/DataAdapters.pas +++ b/bin/Lib/DataAdapters.pas @@ -71,10 +71,10 @@ begin end; end; -/// Кодирует строковые метки классов в целочисленные индексы. -/// Каждому уникальному значению присваивается номер 0,1,2,... +/// Кодирует строковые метки классов в целочисленные индексы (0..K-1). /// Порядок кодирования соответствует порядку первого появления меток. -/// Используется при обучении моделей и визуализации. +/// Используйте только при ручной подготовке данных (без DataPipeline). +/// Вызывать только ДО разбиения на train/test, чтобы избежать рассинхронизации меток. function EncodeLabels(Self: DataFrame; target: string): array of integer; extensionmethod; begin if Self = nil then diff --git a/bin/Lib/DataFrameABC.pas b/bin/Lib/DataFrameABC.pas index 63e3fc97d..6819e3b64 100644 --- a/bin/Lib/DataFrameABC.pas +++ b/bin/Lib/DataFrameABC.pas @@ -48,6 +48,9 @@ type fschema: DataFrameSchema; procedure RebuildSchema; + procedure CommitAddedColumn(c: Column); + procedure ValidateColumnsAgainstSchema; + procedure ValidateColumnsAgainstSchema(candidate: DataFrameSchema); // Single key методы function JoinInnerSingleKey(other: DataFrame; key: string): DataFrame; @@ -78,7 +81,6 @@ type procedure AssertSchemaConsistent; // Проверка инвариантов в Debug constructor Create(cols: List; schema: DataFrameSchema); - constructor Create(cols: List); function BuildJoinSchema(right: DataFrame; leftKeys, rightKeys: array of integer; rightPrefix: string): DataFrameSchema; @@ -87,14 +89,15 @@ type function CreateEmptyBySchema(schema: DataFrameSchema): DataFrame; function GetColumn(name: string): Column; + function GetSchema: DataFrameSchema; - function CloneWithCopiedColumns: DataFrame; + function CloneWithSharedColumns: DataFrame; public /// Создает пустой DataFrame constructor Create; - /// Схема DataFrame: имена, типы и признаки категориальности - property Schema: DataFrameSchema read fschema; + /// Возвращает копию схемы DataFrame + property Schema: DataFrameSchema read GetSchema; procedure SetSchema(schema: DataFrameSchema); @@ -102,10 +105,16 @@ type function IsCategorical(name: string): boolean; + /// Возвращает внутренний столбец без копирования. + /// Не изменяйте его, иначе DataFrame будет повреждён. property Item[name: string]: Column read GetColumn; default; + /// Возвращает внутренние столбцы без копирования. + /// Не изменяйте их, иначе DataFrame будет повреждён. function GetColumns: sequence of Column; + /// Возвращает внутренний столбец без копирования. + /// Не изменяйте его, иначе DataFrame будет повреждён. function GetColumn(i: integer): Column; /// Добавляет в DataFrame столбец-синоним, @@ -145,29 +154,57 @@ type /// Возвращает кортеж (trainDataFrame, testDataFrame). function TrainTestSplit(testRatio: real := 0.2; shuffle: boolean := true; seed: integer := -1): (DataFrame, DataFrame); - /// Добавляет столбец целых чисел + /// Разбивает таблицу на обучающую и тестовую выборки с сохранением + /// распределения целевой переменной (стратификация). + /// + /// Строки группируются по значениям столбца target, после чего внутри + /// каждой группы случайным образом перемешиваются и делятся на train и test + /// в заданной пропорции. + /// + /// testRatio = 0.2 означает, что примерно 20% строк из каждой группы + /// попадут в тестовую выборку. + /// + /// Используется только для задач классификации (категориальный target). + /// + /// Если seed >= 0, разбиение будет детерминированным. + /// Если seed = -1, используется случайная инициализация генератора. + /// + /// Возвращает кортеж (trainDataFrame, testDataFrame). + function StratifiedTrainTestSplit(target: string; testRatio: real := 0.2; seed: integer := -1): (DataFrame, DataFrame); + + /// Добавляет столбец целых чисел. + /// Переданные массивы сохраняются как есть. + /// Не изменяйте их после передачи в DataFrame. procedure AddIntColumn(name: string; data: array of integer; valid: array of boolean := nil); - /// Добавляет столбец вещественных чисел + /// Добавляет столбец вещественных чисел. + /// Переданные массивы сохраняются как есть. + /// Не изменяйте их после передачи в DataFrame. procedure AddFloatColumn(name: string; data: array of real; valid: array of boolean := nil); - /// Добавляет строковый столбец + /// Добавляет строковый столбец. + /// Переданные массивы сохраняются как есть. + /// Не изменяйте их после передачи в DataFrame. procedure AddStrColumn(name: string; data: array of string; valid: array of boolean := nil); - /// Добавляет строковый столбец + /// Добавляет строковый столбец. + /// Переданные массивы сохраняются как есть. + /// Не изменяйте их после передачи в DataFrame. procedure AddStrColumn(name: string; data: array of char; valid: array of boolean := nil); - /// Добавляет столбец логических значений + /// Добавляет столбец логических значений. + /// Переданные массивы сохраняются как есть. + /// Не изменяйте их после передачи в DataFrame. procedure AddBoolColumn(name: string; data: array of boolean; valid: array of boolean := nil); - /// Возвращает данные целочисленного столбца по имени - /// Не изменяйте их, иначе DataFrame будет повреждён + /// Возвращает внутренний буфер целочисленного столбца без копирования. + /// Не изменяйте его, иначе DataFrame будет повреждён. function GetIntColumn(name: string): array of integer; - /// Возвращает данные вещественного столбца по имени - /// Не изменяйте их, иначе DataFrame будет повреждён + /// Возвращает внутренний буфер вещественного столбца без копирования. + /// Не изменяйте его, иначе DataFrame будет повреждён. function GetFloatColumn(name: string): array of real; - /// Возвращает данные строкового столбца по имени - /// Не изменяйте их, иначе DataFrame будет повреждён + /// Возвращает внутренний буфер строкового столбца без копирования. + /// Не изменяйте его, иначе DataFrame будет повреждён. function GetStrColumn(name: string): array of string; - /// Возвращает данные логического столбца по имени - /// Не изменяйте их, иначе DataFrame будет повреждён + /// Возвращает внутренний буфер логического столбца без копирования. + /// Не изменяйте его, иначе DataFrame будет повреждён. function GetBoolColumn(name: string): array of boolean; /// Вычисляет сумму значений столбца по индексу @@ -243,9 +280,9 @@ type function GroupBy(colNames: array of string): IGroupByContext; /// Возвращает первые n строк - function Head(n: integer): DataFrame; + function Head(n: integer := 10): DataFrame; /// Возвращает последние n строк - function Tail(n: integer): DataFrame; + function Tail(n: integer := 10): DataFrame; /// Фильтрует строки по предикату function Filter(pred: CursorPredicate): DataFrame; @@ -292,17 +329,26 @@ type /// Пропущенные значения (NA) сохраняются function ReplaceColumnInt(colName: string; f: DataFrameCursor -> integer): DataFrame; - /// Преобразует значения целочисленного столбца. + /// Преобразует сырые значения целочисленного столбца. /// Возвращает новый DataFrame. + function MapIntColumnData(name: string; f: integer -> integer): DataFrame; + /// Преобразует сырые значения вещественного столбца. + /// Возвращает новый DataFrame. + function MapFloatColumnData(name: string; f: real -> real): DataFrame; + /// Преобразует сырые значения строкового столбца. + /// Возвращает новый DataFrame. + function MapStrColumnData(name: string; f: string -> string): DataFrame; + /// Преобразует сырые значения логического столбца. + /// Возвращает новый DataFrame. + function MapBoolColumnData(name: string; f: boolean -> boolean): DataFrame; + + /// Устаревшее имя. Используйте MapIntColumnData. function TransformIntColumn(name: string; f: integer -> integer): DataFrame; - /// Преобразует значения вещественного столбца. - /// Возвращает новый DataFrame. + /// Устаревшее имя. Используйте MapFloatColumnData. function TransformFloatColumn(name: string; f: real -> real): DataFrame; - /// Преобразует значения строкового столбца. - /// Возвращает новый DataFrame. + /// Устаревшее имя. Используйте MapStrColumnData. function TransformStrColumn(name: string; f: string -> string): DataFrame; - /// Преобразует значения логического столбца. - /// Возвращает новый DataFrame. + /// Устаревшее имя. Используйте MapBoolColumnData. function TransformBoolColumn(name: string; f: boolean -> boolean): DataFrame; /// Возвращает новый DataFrame со строками с заданными номерами из исходного DataFrame @@ -321,20 +367,12 @@ type /// Соединяет с другим DataFrame по разным именам ключей function Join(other: DataFrame; leftKeys, rightKeys: array of string; kind: JoinKind := jkInner): DataFrame; - /// Выводит DataFrame - procedure Print(decimals: integer := 3); - /// Выводит DataFrame и переходит на новую строку - procedure Println(decimals: integer := 3); /// Выводит DataFrame с настраиваемым числом строк - procedure PrintPreview(maxRows: integer; headRows: integer := -1; decimals: integer := 3); - /// Выводит DataFrame с настраиваемым числом строк и переходит на новую строку - procedure PrintlnPreview(maxRows: integer; headRows: integer := -1; decimals: integer := 3); + procedure Print(maxRows: integer := 10; headRows: integer := -1; decimals: integer := 2); /// Выводит схему датафрейма procedure PrintSchema; /// Выводит размер, схему и количество валидных значений procedure PrintInfo; - /// Выводит размер, схему и количество валидных значений - procedure PrintlnInfo; /// Загружает DataFrame из CSV файла static function FromCsv(filename: string): DataFrame; @@ -505,6 +543,12 @@ const 'schema не может быть nil!!schema cannot be nil'; ER_COLS_SCHEMA_MISMATCH = 'Количество столбцов не совпадает со схемой!!Columns count and schema mismatch'; + ER_SCHEMA_NAME_MISMATCH = + 'Имя столбца #{0} не совпадает со схемой: "{1}" vs "{2}"!!Column name #{0} does not match schema: "{1}" vs "{2}"'; + ER_SCHEMA_TYPE_MISMATCH = + 'Тип столбца "{0}" не совпадает со схемой: {1} vs {2}!!Column type "{0}" does not match schema: {1} vs {2}'; + ER_COLUMN_ROWCOUNT_MISMATCH = + 'Длина столбца "{0}" не совпадает с ожидаемой: {1} vs {2}!!Column "{0}" row count does not match expected: {1} vs {2}'; ER_JOIN_KEY_TYPE_MISMATCH = 'Типы ключей соединения не совпадают!!Join key types mismatch'; ER_JOIN_KEY_NOT_FOUND = @@ -930,7 +974,7 @@ begin // --- right columns (учитывают -1) for var ci := 0 to other.ColumnCount - 1 do if ci <> rightKey then - BuildColumnFromJoin(res, 'right_' + other.columns[ci].Info.Name, other.columns[ci], rightArr); + BuildColumnFromJoin(res, MergedRightColumnName(Self.fSchema, other.fSchema, ci), other.columns[ci], rightArr); var schema := DataFrameSchema.Merge( Self.fSchema, @@ -955,16 +999,6 @@ begin fschema := new DataFrameSchema([], []); end; -constructor DataFrame.Create(cols: List); -begin - if cols = nil then - ArgumentNullError(ER_COLS_NULL); - - self.columns := cols; - - RebuildSchema; -end; - constructor DataFrame.Create(cols: List; schema: DataFrameSchema); begin if cols = nil then @@ -975,9 +1009,13 @@ begin ArgumentError(ER_COLS_SCHEMA_MISMATCH); self.columns := cols; - self.fSchema := schema; - - RebuildSchema; + self.fSchema := new DataFrameSchema( + schema.ColumnNames, + schema.Types, + schema.CategoricalFlags + ); + + ValidateColumnsAgainstSchema; end; function DataFrame.BuildJoinKey(cur: DataFrameCursor; layout: JoinKeyLayout; var hasNA: boolean): JoinKey; @@ -1111,8 +1149,9 @@ begin var k := lcur.Int(leftKey); - if index.ContainsKey(k) then - foreach var r in index[k] do + var rows: List; + if index.TryGetValue(k, rows) then + foreach var r in rows do begin leftIdx.Add(lpos); rightIdx.Add(r); @@ -1164,8 +1203,9 @@ begin var k := lcur.Str(leftKey); - if index.ContainsKey(k) then - foreach var r in index[k] do + var rows: List; + if index.TryGetValue(k, rows) then + foreach var r in rows do begin leftIdx.Add(lpos); rightIdx.Add(r); @@ -1217,8 +1257,9 @@ begin var k := lcur.Bool(leftKey); - if index.ContainsKey(k) then - foreach var r in index[k] do + var rows: List; + if index.TryGetValue(k, rows) then + foreach var r in rows do begin leftIdx.Add(lpos); rightIdx.Add(r); @@ -1281,8 +1322,9 @@ begin var hasNA := false; var key := BuildJoinKey(lcur, leftLayout, hasNA); - if (not hasNA) and hash.ContainsKey(key) then - foreach var rpos in hash[key] do + var rows: List; + if (not hasNA) and hash.TryGetValue(key, rows) then + foreach var rpos in rows do begin leftIdx.Add(lpos); rightIdx.Add(rpos); @@ -1306,7 +1348,7 @@ begin // --- right columns (exclude keys) for var ci := 0 to other.ColumnCount - 1 do if not rightKeyIdx.Contains(ci) then - BuildColumnFromJoin(res, 'right_' + other.columns[ci].Info.Name, other.columns[ci], rightArr); + BuildColumnFromJoin(res, MergedRightColumnName(fSchema, other.fSchema, ci), other.columns[ci], rightArr); // --- schema res.SetSchema(schema); @@ -1466,7 +1508,7 @@ begin // --- 9. right колонки (без ключа) for var ci := 0 to other.ColumnCount - 1 do if ci <> ri then - BuildColumnFromJoin(res, 'right_' + other.columns[ci].Info.Name, other.columns[ci], rightArr); + BuildColumnFromJoin(res, MergedRightColumnName(fSchema, other.fSchema, ci), other.columns[ci], rightArr); // --- 10. схема res.SetSchema(schema); @@ -1600,7 +1642,7 @@ begin // --- 9. right колонки (без ключей) for var ci := 0 to other.ColumnCount - 1 do if not rightKeyIdx.Contains(ci) then - BuildColumnFromJoin(res, 'right_' + other.columns[ci].Info.Name, other.columns[ci], rightArr); + BuildColumnFromJoin(res, MergedRightColumnName(fSchema, other.fSchema, ci), other.columns[ci], rightArr); // --- 10. схема res.SetSchema(schema); @@ -1650,7 +1692,7 @@ begin // --- right columns (exclude key) for var ci := 0 to other.ColumnCount - 1 do if ci <> rightKey then - BuildColumnFromJoin(res, 'right_' + other.columns[ci].Info.Name, other.columns[ci], rightArr); + BuildColumnFromJoin(res, MergedRightColumnName(Self.fSchema, other.fSchema, ci), other.columns[ci], rightArr); var schema := DataFrameSchema.Merge( Self.fSchema, @@ -1850,7 +1892,7 @@ begin // --- right columns (exclude keys) for var ci := 0 to other.ColumnCount - 1 do if not rightKeyIdx.Contains(ci) then - BuildColumnFromJoin(res, 'right_' + other.columns[ci].Info.Name, other.columns[ci], rightArr); + BuildColumnFromJoin(res, MergedRightColumnName(fSchema, other.fSchema, ci), other.columns[ci], rightArr); // --- schema var schema := DataFrameSchema.Merge( @@ -1872,7 +1914,7 @@ begin for var i := 0 to keyIndices.Length - 1 do begin - var t := columns[keyIndices[i]].Info.ColType; + var t := fSchema.ColumnTypeAt(keyIndices[i]); if t = ctFloat then Error(ER_JOIN_FLOAT_KEY_NOT_SUPPORTED); @@ -1922,6 +1964,197 @@ begin if leftKeys.Length <> rightKeys.Length then ArgumentError(ER_JOIN_KEYS_LENGTH_MISMATCH); + if leftKeys.Length = 1 then + begin + var leftKey := fSchema.IndexOf(leftKeys[0]); + var rightKey := other.fSchema.IndexOf(rightKeys[0]); + var lt := fSchema.ColumnTypeAt(leftKey); + var rt := other.fSchema.ColumnTypeAt(rightKey); + + if lt <> rt then + Error(ER_JOIN_KEY_TYPE_MISMATCH); + + if lt = ctFloat then + Error(ER_JOIN_FLOAT_KEY_NOT_SUPPORTED); + + case kind of + jkInner: + case lt of + ctInt: exit(JoinInnerSingleKeyInt(other, leftKey, rightKey)); + ctStr: exit(JoinInnerSingleKeyStr(other, leftKey, rightKey)); + ctBool: exit(JoinInnerSingleKeyBool(other, leftKey, rightKey)); + else + Error(ER_UNSUPPORTED_COLUMN_TYPE, lt); + end; + + jkLeft: + case lt of + ctInt: exit(LeftJoinSingleKeyInt(other, leftKey, rightKey)); + ctStr: exit(LeftJoinSingleKeyStr(other, leftKey, rightKey)); + ctBool: exit(LeftJoinSingleKeyBool(other, leftKey, rightKey)); + else + Error(ER_UNSUPPORTED_COLUMN_TYPE, lt); + end; + + jkRight: + begin + case lt of + ctInt: + begin + var index := new Dictionary>; + var lcur := GetCursor; + while lcur.MoveNext do + if lcur.IsValid(leftKey) then + begin + var k := lcur.Int(leftKey); + var lst: List; + if not index.TryGetValue(k, lst) then + begin + lst := new List; + index[k] := lst; + end; + lst.Add(lcur.Position); + end; + + var leftIdx := new List; + var rightIdx := new List; + var rcur := other.GetCursor; + while rcur.MoveNext do + begin + var rpos := rcur.Position; + if not rcur.IsValid(rightKey) then + begin + leftIdx.Add(-1); + rightIdx.Add(rpos); + continue; + end; + + var k := rcur.Int(rightKey); + var rows: List; + if index.TryGetValue(k, rows) then + foreach var lpos in rows do + begin + leftIdx.Add(lpos); + rightIdx.Add(rpos); + end + else + begin + leftIdx.Add(-1); + rightIdx.Add(rpos); + end; + end; + exit(BuildLeftJoinResult(Self, other, leftIdx, rightIdx, leftKey, rightKey)); + end; + + ctStr: + begin + var index := new Dictionary>; + var lcur := GetCursor; + while lcur.MoveNext do + if lcur.IsValid(leftKey) then + begin + var k := lcur.Str(leftKey); + var lst: List; + if not index.TryGetValue(k, lst) then + begin + lst := new List; + index[k] := lst; + end; + lst.Add(lcur.Position); + end; + + var leftIdx := new List; + var rightIdx := new List; + var rcur := other.GetCursor; + while rcur.MoveNext do + begin + var rpos := rcur.Position; + if not rcur.IsValid(rightKey) then + begin + leftIdx.Add(-1); + rightIdx.Add(rpos); + continue; + end; + + var k := rcur.Str(rightKey); + var rows: List; + if index.TryGetValue(k, rows) then + foreach var lpos in rows do + begin + leftIdx.Add(lpos); + rightIdx.Add(rpos); + end + else + begin + leftIdx.Add(-1); + rightIdx.Add(rpos); + end; + end; + exit(BuildLeftJoinResult(Self, other, leftIdx, rightIdx, leftKey, rightKey)); + end; + + ctBool: + begin + var index := new Dictionary>; + var lcur := GetCursor; + while lcur.MoveNext do + if lcur.IsValid(leftKey) then + begin + var k := lcur.Bool(leftKey); + var lst: List; + if not index.TryGetValue(k, lst) then + begin + lst := new List; + index[k] := lst; + end; + lst.Add(lcur.Position); + end; + + var leftIdx := new List; + var rightIdx := new List; + var rcur := other.GetCursor; + while rcur.MoveNext do + begin + var rpos := rcur.Position; + if not rcur.IsValid(rightKey) then + begin + leftIdx.Add(-1); + rightIdx.Add(rpos); + continue; + end; + + var k := rcur.Bool(rightKey); + var rows: List; + if index.TryGetValue(k, rows) then + foreach var lpos in rows do + begin + leftIdx.Add(lpos); + rightIdx.Add(rpos); + end + else + begin + leftIdx.Add(-1); + rightIdx.Add(rpos); + end; + end; + exit(BuildLeftJoinResult(Self, other, leftIdx, rightIdx, leftKey, rightKey)); + end; + + else + Error(ER_UNSUPPORTED_COLUMN_TYPE, lt); + end; + end; + + jkFull: + begin + var tmp := other; + if leftKeys[0] <> rightKeys[0] then + tmp := other.Rename([(rightKeys[0], leftKeys[0])]); + exit(FullJoinSingleKey(tmp, leftKeys[0])); + end; + end; + end; + // временно переименовываем столбцы справа var tmp := other; var renames := new List<(string,string)>; @@ -1960,9 +2193,71 @@ begin fSchema := new DataFrameSchema(names, types, cats); end; +procedure DataFrame.CommitAddedColumn(c: Column); +begin + var n := columns.Count; + var names := new string[n + 1]; + var types := new ColumnType[n + 1]; + var cats := new boolean[n + 1]; + + for var i := 0 to n - 1 do + begin + var info := columns[i].Info; + names[i] := info.Name; + types[i] := info.ColType; + if fSchema <> nil then + cats[i] := fSchema.CategoricalFlags[i] + else + cats[i] := false; + end; + + names[n] := c.Info.Name; + types[n] := c.Info.ColType; + cats[n] := false; + + var newSchema := new DataFrameSchema(names, types, cats); + columns.Add(c); + fSchema := newSchema; +end; + +procedure DataFrame.ValidateColumnsAgainstSchema; +begin + ValidateColumnsAgainstSchema(fSchema); +end; + +procedure DataFrame.ValidateColumnsAgainstSchema(candidate: DataFrameSchema); +begin + if columns = nil then + ArgumentNullError(ER_COLS_NULL); + + if candidate = nil then + ArgumentNullError(ER_SCHEMA_NULL); + + if columns.Count <> candidate.ColumnCount then + ArgumentError(ER_COLS_SCHEMA_MISMATCH); + + var expectedRowCount := if columns.Count = 0 then 0 else columns[0].RowCount; + + for var i := 0 to columns.Count - 1 do + begin + var info := columns[i].Info; + var schemaName := candidate.NameAt(i); + var schemaType := candidate.ColumnTypeAt(i); + + if columns[i].RowCount <> expectedRowCount then + ArgumentError(ER_COLUMN_ROWCOUNT_MISMATCH, info.Name, columns[i].RowCount, expectedRowCount); + + if info.Name <> schemaName then + ArgumentError(ER_SCHEMA_NAME_MISMATCH, i, info.Name, schemaName); + + if info.ColType <> schemaType then + ArgumentError(ER_SCHEMA_TYPE_MISMATCH, info.Name, info.ColType, schemaType); + end; +end; + function DataFrame.GetColumnType(colIndex: integer): ColumnType; begin - Result := columns[colIndex].Info.ColType + Result := fSchema.ColumnTypeAt(colIndex) end; function DataFrame.RowCount: integer; @@ -1987,6 +2282,15 @@ begin Result := fSchema.HasColumn(name); end; +function DataFrame.GetSchema: DataFrameSchema; +begin + Result := new DataFrameSchema( + fSchema.ColumnNames, + fSchema.Types, + fSchema.CategoricalFlags + ); +end; + function DataFrame.GetCursor: DataFrameCursor := new DataFrameCursor(columns.ToArray,fSchema); @@ -2018,7 +2322,6 @@ begin Result := c.Data; end; - function DataFrame.TrainTestSplit(testRatio: real; shuffle: boolean; seed: integer): (DataFrame, DataFrame); begin if Self = nil then @@ -2073,37 +2376,140 @@ begin Result := (trainDf, testDf); end; +function DataFrame.StratifiedTrainTestSplit( + target: string; + testRatio: real; + seed: integer +): (DataFrame, DataFrame); +begin + if Self = nil then + ArgumentNullError(ER_ARG_NULL, 'DataFrame'); + + if target = nil then + ArgumentNullError(ER_ARG_NULL, 'target'); + + if not HasColumn(target) then + ArgumentError(ER_COLUMN_NOT_FOUND, target); + + if (testRatio <= 0.0) or (testRatio >= 1.0) then + ArgumentError(ER_TEST_RATIO_INVALID, testRatio); + + var n := RowCount; + if n < 2 then + ArgumentError(ER_EMPTY_DATA, 'StratifiedTrainTestSplit'); + + var actualSeed := if seed >= 0 then seed else System.Environment.TickCount and integer.MaxValue; + var rnd := new System.Random(actualSeed); + + var ci := ColumnIndex(target); + var col := GetColumn(ci); + + var groups := new Dictionary>; + var keyOrder := new List; + + case col.Info.ColType of + + ctInt: + begin + var data := IntColumn(col).Data; + for var i := 0 to n - 1 do + begin + var key: object := data[i]; + + var lst: List; + if not groups.TryGetValue(key, lst) then + begin + lst := new List; + groups[key] := lst; + keyOrder.Add(key); + end; + + lst.Add(i); + end; + end; + + ctStr: + begin + var data := StrColumn(col).Data; + for var i := 0 to n - 1 do + begin + var key: object := data[i]; + + var lst: List; + if not groups.TryGetValue(key, lst) then + begin + lst := new List; + groups[key] := lst; + keyOrder.Add(key); + end; + + lst.Add(i); + end; + end; + + else + Error(ER_GROUPBY_UNSUPPORTED_KEY_TYPE, col.Info.ColType); + end; + + var trainIdx := new List; + var testIdx := new List; + + foreach var key in keyOrder do + begin + var arr := groups[key].ToArray; + arr.Shuffle(rnd); + + var m := arr.Length; + var rawSize := Round(m * testRatio); + var testSize := rawSize.Clamp(1, m - 1); + + for var i := 0 to testSize - 1 do + testIdx.Add(arr[i]); + + for var i := testSize to m - 1 do + trainIdx.Add(arr[i]); + end; + + var trainDf := TakeRows(trainIdx.ToArray); + var testDf := TakeRows(testIdx.ToArray); + + Result := (trainDf, testDf); +end; + procedure DataFrame.AddIntColumn(name: string; data: array of integer; valid: array of boolean); begin + if HasColumn(name) then + ArgumentError(ER_COLUMN_ALREADY_EXISTS, name); + if (columns.Count > 0) and (Length(data) <> RowCount) then DimensionError(ER_ADD_COLUMN_ROW_MISMATCH); var c := new IntColumn(name, data, valid); - - columns.Add(c); - RebuildSchema; + CommitAddedColumn(c); end; procedure DataFrame.AddFloatColumn(name: string; data: array of real; valid: array of boolean); begin + if HasColumn(name) then + ArgumentError(ER_COLUMN_ALREADY_EXISTS, name); + if (columns.Count > 0) and (Length(data) <> RowCount) then DimensionError(ER_ADD_COLUMN_ROW_MISMATCH); var c := new FloatColumn(name, data, valid); - - columns.Add(c); - RebuildSchema; + CommitAddedColumn(c); end; procedure DataFrame.AddStrColumn(name: string; data: array of string; valid: array of boolean); begin + if HasColumn(name) then + ArgumentError(ER_COLUMN_ALREADY_EXISTS, name); + if (columns.Count > 0) and (Length(data) <> RowCount) then DimensionError(ER_ADD_COLUMN_ROW_MISMATCH); var c := new StrColumn(name, data, valid); - - columns.Add(c); - RebuildSchema; + CommitAddedColumn(c); end; procedure DataFrame.AddStrColumn(name: string; data: array of char; valid: array of boolean); @@ -2114,13 +2520,14 @@ end; procedure DataFrame.AddBoolColumn(name: string; data: array of boolean; valid: array of boolean); begin + if HasColumn(name) then + ArgumentError(ER_COLUMN_ALREADY_EXISTS, name); + if (columns.Count > 0) and (Length(data) <> RowCount) then DimensionError(ER_ADD_COLUMN_ROW_MISMATCH); var c := new BoolColumn(name, data, valid); - - columns.Add(c); - RebuildSchema; + CommitAddedColumn(c); end; procedure DataFrame.CheckColumnIndex(colIndex: integer); @@ -2531,7 +2938,7 @@ end;} function DataFrame.Head(n: integer): DataFrame; begin if n <= 0 then - exit(new DataFrame); + exit(TakeRows([])); var k := PABCSystem.Min(n, RowCount); @@ -2541,7 +2948,7 @@ end; function DataFrame.Tail(n: integer): DataFrame; begin if n <= 0 then - exit(new DataFrame); + exit(TakeRows([])); var total := RowCount; var k := PABCSystem.Min(n, total); @@ -2591,7 +2998,7 @@ begin if not k.Valid[i] then continue; - case columns[c].Info.ColType of + case fSchema.ColumnTypeAt(c) of ctInt: k.IntVals[i] := cur.Int(c); ctFloat: k.FloatVals[i] := cur.Float(c); ctStr: k.StrVals[i] := cur.Str(c); @@ -2616,7 +3023,7 @@ begin continue; var cmp: integer; - case columns[colIndices[i]].Info.ColType of + case fSchema.ColumnTypeAt(colIndices[i]) of ctInt: cmp := a.IntVals[i].CompareTo(b.IntVals[i]); ctFloat: cmp := a.FloatVals[i].CompareTo(b.FloatVals[i]); ctStr: cmp := a.StrVals[i].CompareTo(b.StrVals[i]); @@ -2661,10 +3068,14 @@ begin if schema = nil then ArgumentNullError(ER_SCHEMA_NULL); - if schema.ColumnCount <> ColumnCount then - ArgumentError(ER_COLS_SCHEMA_MISMATCH); - - fSchema := schema; + var candidate := new DataFrameSchema( + schema.ColumnNames, + schema.Types, + schema.CategoricalFlags + ); + + ValidateColumnsAgainstSchema(candidate); + fSchema := candidate; end; function DataFrame.SetCategorical(names: array of string): DataFrame; @@ -2688,8 +3099,8 @@ begin cats ); - // создаём новый DataFrame (columns не копируем!) - Result := new DataFrame(columns, newSchema); + // создаём новый DataFrame с отдельным списком колонок + Result := new DataFrame(columns.ToList, newSchema); end; function DataFrame.IsCategorical(name: string): boolean; @@ -2841,8 +3252,7 @@ begin if oldName = newName then exit(Self); - var newSchema := fSchema.Rename(oldName, newName); - Result := new DataFrame(columns, newSchema); + Result := Rename([(oldName, newName)]); end; @@ -2866,7 +3276,7 @@ begin var oldName := col.Info.Name; var newName := if map.ContainsKey(oldName) then map[oldName] else oldName; - case col.Info.ColType of + case fSchema.ColumnTypeAt(i) of ctInt: begin var c := IntColumn(col); @@ -2996,7 +3406,7 @@ begin Result := columns[idx]; end; -function DataFrame.CloneWithCopiedColumns: DataFrame; +function DataFrame.CloneWithSharedColumns: DataFrame; begin Result := new DataFrame; @@ -3004,7 +3414,7 @@ begin begin var col := columns[i]; - case col.Info.ColType of + case fSchema.ColumnTypeAt(i) of ctInt: begin var c := IntColumn(col); @@ -3037,7 +3447,7 @@ begin if fSchema.HasColumn(name) then ArgumentError(ER_COLUMN_ALREADY_EXISTS, name); - var res := CloneWithCopiedColumns; + var res := CloneWithSharedColumns; var data := new integer[RowCount]; var valid := new boolean[RowCount]; @@ -3064,7 +3474,7 @@ begin if fSchema.HasColumn(name) then ArgumentError(ER_COLUMN_ALREADY_EXISTS, name); - var res := CloneWithCopiedColumns; + var res := CloneWithSharedColumns; var data := new real[RowCount]; var valid := new boolean[RowCount]; @@ -3091,7 +3501,7 @@ begin if fSchema.HasColumn(name) then ArgumentError(ER_COLUMN_ALREADY_EXISTS, name); - var res := CloneWithCopiedColumns; + var res := CloneWithSharedColumns; var data := new string[RowCount]; var valid := new boolean[RowCount]; @@ -3118,7 +3528,7 @@ begin if fSchema.HasColumn(name) then ArgumentError(ER_COLUMN_ALREADY_EXISTS, name); - var res := CloneWithCopiedColumns; + var res := CloneWithSharedColumns; var data := new boolean[RowCount]; var valid := new boolean[RowCount]; @@ -3306,12 +3716,13 @@ begin res.AddColumnAlias(columns[i]); res.AddIntColumn(name, data, valid); + res.SetSchema(ExtendSchema(name, ctInt, false)); Result := res; Result.AssertSchemaConsistent; end; -function DataFrame.TransformIntColumn(name: string; f: integer -> integer): DataFrame; +function DataFrame.MapIntColumnData(name: string; f: integer -> integer): DataFrame; begin var res := new DataFrame; @@ -3358,12 +3769,21 @@ begin end; end; + res.SetSchema(new DataFrameSchema( + fSchema.ColumnNames, + fSchema.Types, + fSchema.CategoricalFlags + )); + Result := res; Result.AssertSchemaConsistent; end; -function DataFrame.TransformFloatColumn(name: string; f: real -> real): DataFrame; +function DataFrame.TransformIntColumn(name: string; f: integer -> integer): DataFrame := + MapIntColumnData(name, f); + +function DataFrame.MapFloatColumnData(name: string; f: real -> real): DataFrame; begin var res := new DataFrame; @@ -3410,12 +3830,21 @@ begin end; end; + res.SetSchema(new DataFrameSchema( + fSchema.ColumnNames, + fSchema.Types, + fSchema.CategoricalFlags + )); + Result := res; Result.AssertSchemaConsistent; end; -function DataFrame.TransformStrColumn(name: string; f: string -> string): DataFrame; +function DataFrame.TransformFloatColumn(name: string; f: real -> real): DataFrame := + MapFloatColumnData(name, f); + +function DataFrame.MapStrColumnData(name: string; f: string -> string): DataFrame; begin var res := new DataFrame; @@ -3462,12 +3891,21 @@ begin end; end; + res.SetSchema(new DataFrameSchema( + fSchema.ColumnNames, + fSchema.Types, + fSchema.CategoricalFlags + )); + Result := res; Result.AssertSchemaConsistent; end; -function DataFrame.TransformBoolColumn(name: string; f: boolean -> boolean): DataFrame; +function DataFrame.TransformStrColumn(name: string; f: string -> string): DataFrame := + MapStrColumnData(name, f); + +function DataFrame.MapBoolColumnData(name: string; f: boolean -> boolean): DataFrame; begin var res := new DataFrame; @@ -3514,11 +3952,20 @@ begin end; end; + res.SetSchema(new DataFrameSchema( + fSchema.ColumnNames, + fSchema.Types, + fSchema.CategoricalFlags + )); + Result := res; Result.AssertSchemaConsistent; end; +function DataFrame.TransformBoolColumn(name: string; f: boolean -> boolean): DataFrame := + MapBoolColumnData(name, f); + function DataFrame.TakeRows(indices: array of integer): DataFrame; begin if indices = nil then @@ -3527,6 +3974,14 @@ begin var k := indices.Length; var res := new DataFrame; + for var j := 0 to k - 1 do + begin + var i := indices[j]; + + if (i < 0) or (i >= RowCount) then + ArgumentError(ER_ROW_INDEX_OUT_OF_RANGE, i); + end; + var names := new List; var types := new List; var cats := new List; @@ -3545,16 +4000,13 @@ begin var data := new integer[k]; var validDst := new boolean[k]; - for var j := 0 to k - 1 do - begin - var i := indices[j]; + for var j := 0 to k - 1 do + begin + var i := indices[j]; - if (i < 0) or (i >= RowCount) then - ArgumentError(ER_ROW_INDEX_OUT_OF_RANGE, i); - - data[j] := src.Data[i]; - validDst[j] := src.IsValid[i]; - end; + data[j] := src.Data[i]; + validDst[j] := src.IsValid[i]; + end; res.AddIntColumn(name, data, validDst); end; @@ -3565,16 +4017,13 @@ begin var data := new real[k]; var validDst := new boolean[k]; - for var j := 0 to k - 1 do - begin - var i := indices[j]; + for var j := 0 to k - 1 do + begin + var i := indices[j]; - if (i < 0) or (i >= RowCount) then - ArgumentError(ER_ROW_INDEX_OUT_OF_RANGE, i); - - data[j] := src.Data[i]; - validDst[j] := src.IsValid[i]; - end; + data[j] := src.Data[i]; + validDst[j] := src.IsValid[i]; + end; res.AddFloatColumn(name, data, validDst); end; @@ -3585,16 +4034,13 @@ begin var data := new string[k]; var validDst := new boolean[k]; - for var j := 0 to k - 1 do - begin - var i := indices[j]; + for var j := 0 to k - 1 do + begin + var i := indices[j]; - if (i < 0) or (i >= RowCount) then - ArgumentError(ER_ROW_INDEX_OUT_OF_RANGE, i); - - data[j] := src.Data[i]; - validDst[j] := src.IsValid[i]; - end; + data[j] := src.Data[i]; + validDst[j] := src.IsValid[i]; + end; res.AddStrColumn(name, data, validDst); end; @@ -3605,16 +4051,13 @@ begin var data := new boolean[k]; var validDst := new boolean[k]; - for var j := 0 to k - 1 do - begin - var i := indices[j]; + for var j := 0 to k - 1 do + begin + var i := indices[j]; - if (i < 0) or (i >= RowCount) then - ArgumentError(ER_ROW_INDEX_OUT_OF_RANGE, i); - - data[j] := src.Data[i]; - validDst[j] := src.IsValid[i]; - end; + data[j] := src.Data[i]; + validDst[j] := src.IsValid[i]; + end; res.AddBoolColumn(name, data, validDst); end; @@ -3637,7 +4080,7 @@ begin Result := res; end; -procedure DataFrame.PrintPreview(maxRows: integer; headRows: integer; decimals: integer); +procedure DataFrame.Print(maxRows: integer; headRows: integer; decimals: integer); begin var colCount := columns.Count; if colCount = 0 then exit; @@ -3810,23 +4253,6 @@ begin end; end; -procedure DataFrame.PrintlnPreview(maxRows: integer; headRows: integer; decimals: integer); -begin - PrintPreview(maxRows, headRows, decimals); - PABCSystem.Println; -end; - -procedure DataFrame.Print(decimals: integer); -begin - PrintPreview(10, 5, decimals); -end; - -procedure DataFrame.Println(decimals: integer); -begin - Print(decimals); - PABCSystem.Println; -end; - procedure DataFrame.PrintSchema; begin var nameWidth := fSchema.ColumnNames.Max(s -> s.Length); @@ -3887,12 +4313,6 @@ begin end; end; -procedure DataFrame.PrintlnInfo; -begin - PrintInfo; - PABCSystem.Println -end; - procedure DataFrame.AssertSchemaConsistent; begin {$IFNDEF Test} @@ -3913,6 +4333,14 @@ begin for var i := 0 to columns.Count - 1 do begin var name := columns[i].Info.Name; + var schemaName := fSchema.NameAt(i); + var schemaType := fSchema.ColumnTypeAt(i); + + if name <> schemaName then + Error(ER_SCHEMA_NAME_MISMATCH, i, name, schemaName); + + if columns[i].Info.ColType <> schemaType then + Error(ER_SCHEMA_TYPE_MISMATCH, name, columns[i].Info.ColType, schemaType); if not fSchema.HasColumn(name) then Error(ER_SCHEMA_COLUMN_MISSING, name); @@ -4459,7 +4887,7 @@ begin var col := source.columns[ci]; var colName := col.Info.Name; - case col.Info.ColType of + case source.fSchema.ColumnTypeAt(ci) of ctInt: begin res.AddIntColumn(colName, keys.Select(key -> integer(key.Values[k])).ToArray, nil); @@ -4612,9 +5040,9 @@ begin // собираем все числовые колонки for var i := 0 to source.ColumnCount - 1 do - case source.columns[i].Info.ColType of + case source.fSchema.ColumnTypeAt(i) of ctInt, ctFloat: - cols.Add(source.columns[i].Info.Name); + cols.Add(source.fSchema.NameAt(i)); end; if cols.Count = 0 then @@ -4857,13 +5285,19 @@ begin var col := source.columns[keyColumn]; var keyName := col.Info.Name; - if col.Info.ColType = ctInt then - res.AddIntColumn(keyName, keys1.Select(k -> integer(k)).ToArray, nil) + case source.fSchema.ColumnTypeAt(keyColumn) of + ctInt: + res.AddIntColumn(keyName, keys1.Select(k -> integer(k)).ToArray, nil); + ctStr: + res.AddStrColumn(keyName, keys1.Select(k -> string(k)).ToArray, nil); + ctBool: + res.AddBoolColumn(keyName, keys1.Select(k -> boolean(k)).ToArray, nil); else - res.AddStrColumn(keyName, keys1.Select(k -> string(k)).ToArray, nil); + Error(ER_GROUPBY_UNSUPPORTED_KEY_TYPE, source.fSchema.ColumnTypeAt(keyColumn)); + end; names.Add(keyName); - types.Add(col.Info.ColType); + types.Add(source.fSchema.ColumnTypeAt(keyColumn)); cats.Add(true); end else @@ -4874,13 +5308,19 @@ begin var col := source.columns[ci]; var keyName := col.Info.Name; - if col.Info.ColType = ctInt then - res.AddIntColumn(keyName, keysN.Select(key -> integer(key.Values[k])).ToArray, nil) + case source.fSchema.ColumnTypeAt(ci) of + ctInt: + res.AddIntColumn(keyName, keysN.Select(key -> integer(key.Values[k])).ToArray, nil); + ctStr: + res.AddStrColumn(keyName, keysN.Select(key -> string(key.Values[k])).ToArray, nil); + ctBool: + res.AddBoolColumn(keyName, keysN.Select(key -> boolean(key.Values[k])).ToArray, nil); else - res.AddStrColumn(keyName, keysN.Select(key -> string(key.Values[k])).ToArray, nil); + Error(ER_GROUPBY_UNSUPPORTED_KEY_TYPE, source.fSchema.ColumnTypeAt(ci)); + end; names.Add(keyName); - types.Add(col.Info.ColType); + types.Add(source.fSchema.ColumnTypeAt(ci)); cats.Add(true); end; end; @@ -5222,9 +5662,10 @@ begin var names := oldSchema.ColumnNames; var cats := oldSchema.CategoricalFlags; - var types := new ColumnType[names.Length]; + var types := Copy(oldSchema.Types); for var i := 0 to names.Length - 1 do - types[i] := ctFloat; + if isNumeric[i] then + types[i] := ctFloat; res.SetSchema(new DataFrameSchema(names, types, cats)); @@ -5308,9 +5749,10 @@ begin var names := oldSchema.ColumnNames; var cats := oldSchema.CategoricalFlags; - var types := new ColumnType[names.Length]; + var types := Copy(oldSchema.Types); for var i := 0 to names.Length - 1 do - types[i] := ctFloat; + if isNumeric[i] then + types[i] := ctFloat; res.SetSchema(new DataFrameSchema(names, types, cats)); @@ -6333,4 +6775,4 @@ begin end; end; -end. \ No newline at end of file +end. diff --git a/bin/Lib/DataFrameABCCore.pas b/bin/Lib/DataFrameABCCore.pas index d7f51203f..8e3337acf 100644 --- a/bin/Lib/DataFrameABCCore.pas +++ b/bin/Lib/DataFrameABCCore.pas @@ -35,11 +35,14 @@ type fIndexByName: Dictionary; class function BuildIndex(names: array of string): Dictionary; + function GetColumnNames: array of string; + function GetTypes: array of ColumnType; + function GetCategoricalFlags: array of boolean; public property ColumnCount: integer read fNames.Length; - property ColumnNames: array of string read fNames; - property Types: array of ColumnType read fTypes; - property CategoricalFlags: array of boolean read fCategoricalFlags; + property ColumnNames: array of string read GetColumnNames; + property Types: array of ColumnType read GetTypes; + property CategoricalFlags: array of boolean read GetCategoricalFlags; function IndexOf(name: string): integer; function HasColumn(name: string): boolean; @@ -79,9 +82,14 @@ type procedure AssertConsistent; end; - ColumnInfo = auto class - Name: string; - ColType: ColumnType; + ColumnInfo = sealed class + private + fName: string; + fColType: ColumnType; + public + property Name: string read fName; + property ColType: ColumnType read fColType; + constructor Create(name: string; colType: ColumnType); //IsCategorical - только в Schema! end; @@ -178,7 +186,7 @@ type function Equals(oth: object): boolean; override; function GetHashCode: integer; override; end; - + /// Курсор для итерации по строкам DataFrame DataFrameCursor = class private @@ -243,6 +251,8 @@ type /// Максимальное значение Max: real; end; + +function MergedRightColumnName(leftSchema, rightSchema: DataFrameSchema; rightIndex: integer): string; implementation @@ -331,6 +341,21 @@ begin end; end; +function DataFrameSchema.GetColumnNames: array of string; +begin + Result := Copy(fNames); +end; + +function DataFrameSchema.GetTypes: array of ColumnType; +begin + Result := Copy(fTypes); +end; + +function DataFrameSchema.GetCategoricalFlags: array of boolean; +begin + Result := Copy(fCategoricalFlags); +end; + constructor DataFrameSchema.Create(names: array of string; types: array of ColumnType; isCategorical: array of boolean); begin @@ -357,6 +382,12 @@ begin AssertConsistent; end; +constructor ColumnInfo.Create(name: string; colType: ColumnType); +begin + fName := name; + fColType := colType; +end; + procedure DataFrameSchema.Print; begin if fNames.Length = 0 then @@ -548,6 +579,13 @@ begin Result := new DataFrameSchema(names.ToArray, types.ToArray, cats.ToArray); end; +function MergedRightColumnName(leftSchema, rightSchema: DataFrameSchema; rightIndex: integer): string; +begin + Result := rightSchema.NameAt(rightIndex); + if leftSchema.HasColumn(Result) then + Result := 'right_' + Result; +end; + procedure DataFrameSchema.AssertConsistent; begin Assert(fNames.Length = fTypes.Length); diff --git a/bin/Lib/LinearAlgebraML.pas b/bin/Lib/LinearAlgebraML.pas index 008f26d11..5846a1490 100644 --- a/bin/Lib/LinearAlgebraML.pas +++ b/bin/Lib/LinearAlgebraML.pas @@ -32,20 +32,31 @@ type static procedure CheckNonEmpty(const v: Vector); procedure SetData(i: integer; value: real) := fdata[i] := value; public + /// Массив элементов вектора. property Data: array of real read fdata; + /// Длина вектора. property Length: integer read fdata.Length; + /// Доступ к элементу вектора по индексу. property Item[i: integer]: real read fdata[i] write SetData; default; + /// Создаёт вектор заданной длины, заполненный нулями. constructor Create(n: integer); + /// Создаёт вектор из массива вещественных значений. constructor Create(values: array of real); + /// Создаёт вектор из массива целых значений. constructor Create(values: array of integer); + /// Возвращает копию вектора. function Clone: Vector; + /// Нормирует текущий вектор по евклидовой норме и возвращает его. function Normalize: Vector; + /// Возвращает нормированную копию вектора. function Normalized: Vector; + /// Возвращает копию данных в виде массива. function ToArray: array of real; + /// Преобразует элементы вектора в массив целых чисел. function ToIntArray: array of integer; static function operator +(a, b: Vector): Vector; @@ -68,30 +79,44 @@ type // ---------- Векторные функции ---------- /// Применить функцию ко всем элементам вектора function Apply(f: real -> real): Vector; + /// Возвращает вектор из квадратных корней элементов. function Sqrt: Vector; + /// Возвращает вектор из экспонент элементов. function Exp: Vector := Apply(PABCSystem.Exp); + /// Возвращает вектор из натуральных логарифмов элементов. function Ln: Vector := Apply(PABCSystem.Ln); + /// Возвращает вектор из модулей элементов. function Abs: Vector := Apply(PABCSystem.Abs); // ---------- Основные методы ---------- + /// Возвращает сумму элементов вектора. function Sum: real; /// Среднее. Синоним Mean function Average: real; /// Среднее function Mean: real; + /// Возвращает квадрат евклидовой нормы вектора. function Norm2: real; + /// Возвращает евклидову норму вектора. function Norm: real; + /// Возвращает максимальный элемент вектора. function Max: real; + /// Возвращает минимальный элемент вектора. function Min: real; /// Скалярное произведение function Dot(b: Vector): real; // ---------- Сервисные методы ---------- + /// Возвращает строковое представление вектора. function ToString: string; override := $'{fdata.Select(x -> x.ToString(''G3''))}'; + /// Возвращает строковое представление вектора с заданной точностью. function ToString(digits: integer): string := $'{fdata.Select(x -> x.ToString(''G''+digits))}'; + /// Печатает вектор без перевода строки. procedure Print := fdata.Print; + /// Печатает вектор с переводом строки. procedure Println := fdata.Println; + /// Возвращает подмножество элементов по заданным индексам. function SubvectorBy(indices: array of integer): Vector; end; @@ -105,56 +130,97 @@ type procedure SetData(i, j: integer; value: real) := fdata[i, j] := value; public + /// Число строк матрицы. property RowCount: integer read fdata.RowCount; + /// Число столбцов матрицы. property ColCount: integer read fdata.ColCount; + /// Данные матрицы в виде двумерного массива. property Data: array[,] of real read fdata; + /// Доступ к элементу матрицы по индексам строки и столбца. property Item[i, j: integer]: real read fdata[i, j] write SetData; default; + /// Создаёт матрицу заданного размера, заполненную нулями. constructor Create(r, c: integer); + /// Создаёт матрицу из двумерного массива вещественных значений. constructor Create(values: array[,] of real); + /// Возвращает копию данных в виде двумерного массива. function ToArray2D: array[,] of real; + /// Возвращает строку матрицы в виде массива. function RowToArray(r: integer): array of real; + /// Возвращает копию матрицы. function Clone: Matrix; + /// Возвращает строку матрицы в виде массива. function Row(i: integer): array of real := fdata.Row(i); + /// Возвращает столбец матрицы в виде массива. function Col(j: integer): array of real := fdata.Col(j); + /// Возвращает суммы по всем столбцам. function ColumnSums: Vector; + /// Возвращает суммы по всем строкам. function RowSums: Vector; + /// Возвращает средние значения по всем столбцам. function ColumnMeans: Vector; + /// Возвращает средние значения по всем строкам. function RowMeans: Vector; + /// Возвращает дисперсии по всем столбцам. function ColumnVariances: Vector; + /// Возвращает дисперсии по всем строкам. function RowVariances: Vector; + /// Возвращает стандартные отклонения по всем столбцам. function ColumnStd: Vector; + /// Возвращает стандартные отклонения по всем строкам. function RowStd: Vector; + /// Возвращает минимумы по всем столбцам. function ColumnMins: Vector; + /// Возвращает максимумы по всем столбцам. function ColumnMaxs: Vector; + /// Возвращает минимумы по всем строкам. function RowMins: Vector; + /// Возвращает максимумы по всем строкам. function RowMaxs: Vector; + /// Возвращает сумму элементов строки. function RowSum(i: integer): real; + /// Возвращает среднее значение элементов строки. function RowMean(i: integer): real; + /// Возвращает дисперсию элементов строки. function RowVariance(i: integer): real; + /// Возвращает стандартное отклонение элементов строки. function RowStd(i: integer): real; + /// Возвращает минимальный элемент строки. function RowMin(i: integer): real; + /// Возвращает максимальный элемент строки. function RowMax(i: integer): real; + /// Возвращает индекс минимального элемента строки. function RowArgMin(i: integer): integer; + /// Возвращает индекс максимального элемента строки. function RowArgMax(i: integer): integer; + /// Возвращает сумму элементов столбца. function ColumnSum(j: integer): real; + /// Возвращает среднее значение элементов столбца. function ColumnMean(j: integer): real; + /// Возвращает дисперсию элементов столбца. function ColumnVariance(j: integer): real; + /// Возвращает стандартное отклонение элементов столбца. function ColumnStd(j: integer): real; + /// Возвращает минимальный элемент столбца. function ColumnMin(j: integer): real; + /// Возвращает максимальный элемент столбца. function ColumnMax(j: integer): real; + /// Возвращает индекс минимального элемента столбца. function ColumnArgMin(j: integer): integer; + /// Возвращает индекс максимального элемента столбца. function ColumnArgMax(j: integer): integer; + /// Возвращает норму Фробениуса матрицы. function FrobeniusNorm: real; + /// Прибавляет lambda к диагональным элементам матрицы. procedure AddScaledIdentity(lambda: real); @@ -206,13 +272,19 @@ type function PCA(k: integer): (Matrix, Vector); // ---------- Сервисные методы ---------- + /// Возвращает строковое представление матрицы. function ToString: string; override := $'{fdata}'; + /// Печатает матрицу без перевода строки. procedure Print := fdata.Print; + /// Печатает матрицу с переводом строки. procedure Println := fdata.Println; + /// Возвращает строку матрицы в виде вектора. function GetRow(i: integer): Vector; + /// Возвращает столбец матрицы в виде вектора. function GetCol(j: integer): Vector; + /// Возвращает матрицу, составленную из строк с заданными индексами. function TakeRows(indices: array of integer): Matrix; // ---------- Статические методы ---------- diff --git a/bin/Lib/MLABC.pas b/bin/Lib/MLABC.pas index 8d9bde51d..60e254b3c 100644 --- a/bin/Lib/MLABC.pas +++ b/bin/Lib/MLABC.pas @@ -58,6 +58,7 @@ type Matrix = LinearAlgebraML.Matrix; Validation = ValidationML.Validation; + GridSearch = ValidationML.GridSearch; Metrics = MetricsABC.Metrics; ClassificationMetrics = MetricsABC.ClassificationMetrics; @@ -69,6 +70,7 @@ type DataFrame = DataFrameABC.DataFrame; DataFrameCursor = DataFrameABCCore.DataFrameCursor; + ColumnType = DataFrameABCCore.ColumnType; Statistics = DataFrameABC.Statistics; CsvLoader = DataFrameABC.CsvLoader; @@ -130,7 +132,7 @@ type ISupervisedModel = MLCoreABC.ISupervisedModel; IUnsupervisedModel = MLCoreABC.IUnsupervisedModel; - UPipeline = MLModelsABC.UMatrixPipeline; + UMatrixPipeline = MLModelsABC.UMatrixPipeline; UDataPipeline = MLPipelineABC.UDataPipeline; TaskKind = MLPipelineABC.TaskKind; diff --git a/bin/Lib/MLCoreABC.pas b/bin/Lib/MLCoreABC.pas index 82c44b0b3..5096d1b9c 100644 --- a/bin/Lib/MLCoreABC.pas +++ b/bin/Lib/MLCoreABC.pas @@ -108,14 +108,17 @@ type /// Интерфейс классификатора. /// Наследуется от IModel. - /// Предназначен для моделей, выполняющих классификацию (предсказание меток классов). - IClassifier = interface(ISupervisedModel) - /// Возвращает индексы классов (0,1,2,...) - function PredictLabels(X: Matrix): array of integer; - - /// Возвращает метки классов в порядке кодирования - function GetClassLabels: array of string; - + /// Предназначен для моделей, выполняющих классификацию (предсказание меток классов). + IClassifier = interface(ISupervisedModel) + /// Возвращает внутренние индексы классов (0,1,2,...). + /// Индекс i соответствует метке GetClassLabels[i]. + function PredictLabels(X: Matrix): array of integer; + + /// Возвращает исходные метки классов в порядке внутреннего кодирования. + function GetClassLabels: array of string; + end; + + IClassifierInternal = interface procedure SetClassLabels(classes: array of string); end; @@ -172,4 +175,4 @@ type implementation -end. \ No newline at end of file +end. diff --git a/bin/Lib/MLDatasets.pas b/bin/Lib/MLDatasets.pas index 0732901b8..52518e8f8 100644 --- a/bin/Lib/MLDatasets.pas +++ b/bin/Lib/MLDatasets.pas @@ -51,7 +51,7 @@ type function StratifiedTrainTestSplit(testRatio: real := 0.2; seed: integer := -1): (Dataset, Dataset); /// Возвращает первые n строк таблицы данных. - function Head(n: integer := 5): DataFrame; + function Head(n: integer := 10): DataFrame; /// Возвращает краткое описание датасета (метаданные). function Describe: DataFrame; @@ -260,6 +260,9 @@ type /// Датасет российских городов (задача кластеризации) static function RussianCities: Dataset; + + /// Датасет пассажиров Титаника (задача классификации) + static function TitanicRu: Dataset; {/// Датасет результатов экзамена студентов (классификация) static function StudentExam: Dataset; @@ -411,6 +414,9 @@ end; function Dataset.TrainTestSplit(testRatio: real; shuffle: boolean; seed: integer): (Dataset, Dataset); begin + if Data = nil then + ArgumentNullError(ER_ARG_NULL, 'Data'); + var (trainDf, testDf) := Data.TrainTestSplit(testRatio, shuffle, seed); var trainDs := CloneMeta(trainDf); @@ -423,96 +429,15 @@ function Dataset.StratifiedTrainTestSplit(testRatio: real; seed: integer): (Data begin if Data = nil then ArgumentNullError(ER_ARG_NULL, 'Data'); - - if (testRatio <= 0.0) or (testRatio >= 1.0) then - ArgumentError(ER_TEST_RATIO_INVALID, testRatio); - + if Task <> Classification then Error(ER_STRATIFIED_ONLY_FOR_CLASSIFICATION); - var n := Data.RowCount; - if n < 2 then - ArgumentError(ER_EMPTY_DATA, 'StratifiedTrainTestSplit'); - - var actualSeed := if seed >= 0 then seed else System.Environment.TickCount and integer.MaxValue; - var rnd := new System.Random(actualSeed); - - // --- target column - var ci := Data.ColumnIndex(Target); - var col := Data.GetColumn(ci); - - var groups := new Dictionary>; - - // --- группировка по target - case col.Info.ColType of - - ctInt: - begin - var data := IntColumn(col).Data; - - for var i := 0 to n - 1 do - begin - var key: object := data[i]; - - var lst: List; - if not groups.TryGetValue(key, lst) then - begin - lst := new List; - groups[key] := lst; - end; - - lst.Add(i); - end; - end; - - ctStr: - begin - var data := StrColumn(col).Data; - - for var i := 0 to n - 1 do - begin - var key: object := data[i]; - - var lst: List; - if not groups.TryGetValue(key, lst) then - begin - lst := new List; - groups[key] := lst; - end; - - lst.Add(i); - end; - end; - - else - Error(ER_GROUPBY_UNSUPPORTED_KEY_TYPE, col.Info.ColType); - end; - - var trainIdx := new List; - var testIdx := new List; - - // --- split внутри каждой группы - foreach var kvp in groups do - begin - var arr := kvp.Value.ToArray; - arr.Shuffle(rnd); - - var m := arr.Length; - var rawSize := Round(m * testRatio); - var testSize := rawSize.Clamp(1, m - 1); - - for var i := 0 to testSize - 1 do - testIdx.Add(arr[i]); - - for var i := testSize to m - 1 do - trainIdx.Add(arr[i]); - end; - - var trainDf := Data.TakeRows(trainIdx.ToArray); - var testDf := Data.TakeRows(testIdx.ToArray); + var (trainDf, testDf) := + Data.StratifiedTrainTestSplit(Target, testRatio, seed); var trainDs := CloneMeta(trainDf); - var testDs := CloneMeta(testDf); + var testDs := CloneMeta(testDf); Result := (trainDs, testDs); end; @@ -1361,6 +1286,20 @@ begin Result := ds; end; +static function Datasets.TitanicRu: Dataset; +begin + var ds := Load('titanic_ru'); + + ds.Data := ds.Data.SetCategorical([ + 'Выжил', + 'Класс', + 'Пол', + 'ПортПосадки' + ]); + + Result := ds; +end; + {static function Datasets.StudentExam: Dataset; begin NotImplementedError(ER_NOT_IMPLEMENTED, 'Datasets.StudentExam'); @@ -1437,4 +1376,4 @@ begin .ToArray; end; -end. \ No newline at end of file +end. diff --git a/bin/Lib/MLModelsABC.pas b/bin/Lib/MLModelsABC.pas index e778324d2..1e67ab066 100644 --- a/bin/Lib/MLModelsABC.pas +++ b/bin/Lib/MLModelsABC.pas @@ -304,7 +304,7 @@ type /// для двух классов — частный случай softmax. /// Оптимизация выполняется по кросс-энтропийной функции потерь /// с поддержкой L2-регуляризации - LogisticRegression = class(IProbabilisticClassifier) + LogisticRegression = class(IProbabilisticClassifier, IClassifierInternal) private fW: Matrix; // p x k fIntercept: Vector; // k @@ -427,6 +427,21 @@ type Result.Threshold := threshold; end; end; + + RegSplitResult = record + Found: boolean; + Feature: integer; + Threshold: real; + WeightedScore: real; + + static function Invalid: RegSplitResult; + begin + Result.Found := false; + Result.Feature := -1; + Result.Threshold := 0.0; + Result.WeightedScore := real.PositiveInfinity; + end; + end; /// Интерфейс критерия разбиения узла дерева. /// Определяет функцию нечистоты (impurity), которая используется для оценки качества разбиения @@ -523,7 +538,7 @@ type function BuildNode(X: Matrix; y: Vector; indices: array of integer; depth: integer): DecisionTreeNode; - function FindBestSplit(X: Matrix; y: Vector; indices: array of integer; + function FindBestSplitCore(X: Matrix; y: Vector; indices: array of integer; var bestF: integer; var bestT: real): boolean; function MajorityClass(y: Vector; indices: array of integer): integer; @@ -534,84 +549,6 @@ type function Clone: DecisionTreeCore; end; -//============================ -// DecisionTreeBase -//============================ -/// Базовый абстрактный класс дерева решений. -/// Используется только DecisionTreeRegressor -/// Classifier использует DecisionTreeCore -/// Реализует общую логику построения структуры дерева: -/// рекурсивное разбиение, контроль глубины, -/// минимального числа объектов и расчет важности признаков. -/// Конкретная логика вычисления значения листа и критерия разбиения задается в наследнике - DecisionTreeRegressorBase = abstract class(ITreeModel) - protected - fRoot: DecisionTreeNode; - fMaxDepth: integer; - fMinSamplesSplit: integer; - fMinSamplesLeaf: integer; - fFitted: boolean; - fCriterion: ISplitCriterion; - fFeatureImportances: Vector; - fRandomSeed: integer; - fMaxFeatures: integer := 0; - fRowIndices: array of integer := nil; - - fRng: System.Random; - fUserProvidedSeed: boolean; - - function BuildTree(X: Matrix; y: Vector; indices: array of integer; depth: integer): DecisionTreeNode; - - //function FindBestSplit0(X: Matrix; y: Vector; indices: array of integer): SplitResult; - - function FindBestSplit(X: Matrix; y: Vector; indices: array of integer): SplitResult; virtual; abstract; - function IsPure(y: Vector; indices: array of integer): boolean; virtual; -// -------------------------- - - function LeafValue(y: Vector; indices: array of integer): real; virtual; abstract; - function LeafNode(value: real): DecisionTreeNode; - procedure CopyBaseState(dest: DecisionTreeRegressorBase); - function GetFeatureSubset(nFeatures: integer): array of integer; virtual; - - procedure SetRowIndices(rows: array of integer); - public -/// Создает дерево решений: -/// • maxDepth — максимальная глубина дерева. -/// • minSamplesSplit — минимальное число объектов для разбиения узла. -/// • minSamplesLeaf — минимальное число объектов в листе - constructor Create(maxDepth: integer; minSamplesSplit: integer; minSamplesLeaf: integer; - criterion: ISplitCriterion; seed: integer); - -/// Возвращает вектор важности признаков. -/// Важность вычисляется как суммарное уменьшение -/// нечистоты (impurity reduction) по всем разбиениям. -/// Значения нормированы так, что сумма равна 1. - function FeatureImportances: Vector; - -/// Обучает дерево решений для задачи регрессии. -/// X — матрица m × n (m объектов, n признаков). -/// y — вектор длины m с непрерывными значениями. -/// -/// Примечание: -/// • обученное состояние дерева НЕ копируется методом Clone - function Fit(X: Matrix; y: Vector): ISupervisedModel; virtual; abstract; - -/// Выполняет предсказание для матрицы X. -/// Возвращает вектор прогнозов. -/// Для регрессии — вещественные значения. -/// Для классификации — метки классов. - function Predict(X: Matrix): Vector; virtual; abstract; - -/// Копирует только конфигурацию модели (без обученного состояния). -/// Используется для создания независимых экземпляров модели. - function Clone: IModel; virtual; abstract; - -/// Возвращает true, если дерево обучено. -/// Если false — Predict вызовет ошибку. - property IsFitted: boolean read fFitted; - - function Name: string := Self.GetType.Name; - end; //============================ // DecisionTreeClassifier @@ -619,7 +556,7 @@ type /// Дерево решений для задачи классификации. /// Использует критерий нечистоты (обычно Gini) для выбора оптимальных разбиений. /// В листьях хранится наиболее частый класс - DecisionTreeClassifier = class(IClassifier) + DecisionTreeClassifier = class(IClassifier, IClassifierInternal) private fMaxDepth: integer; fMinSamplesSplit: integer; @@ -687,6 +624,78 @@ type function Clone: IModel; end; +//============================ +// DecisionTreeRegressorBase +//============================ +/// Базовый абстрактный класс дерева решений. +/// Используется только DecisionTreeRegressor +/// Classifier использует DecisionTreeCore +/// Реализует общую логику построения структуры дерева: +/// рекурсивное разбиение, контроль глубины, +/// минимального числа объектов и расчет важности признаков. +/// Конкретная логика вычисления значения листа и критерия разбиения задается в наследнике + DecisionTreeRegressorBase = abstract class(ITreeModel) + protected + fRoot: DecisionTreeNode; + fMaxDepth: integer; + fMinSamplesSplit: integer; + fMinSamplesLeaf: integer; + fFitted: boolean; + fCriterion: ISplitCriterion; + fFeatureImportances: Vector; + fRandomSeed: integer; + fMaxFeatures: integer := 0; + fRowIndices: array of integer := nil; + + fRng: System.Random; + fUserProvidedSeed: boolean; + + function IsPure(y: Vector; indices: array of integer): boolean; virtual; +// -------------------------- + + function LeafValue(y: Vector; indices: array of integer): real; virtual; abstract; + function LeafNode(value: real): DecisionTreeNode; + + procedure SetRowIndices(rows: array of integer); + public +/// Создает дерево решений: +/// • maxDepth — максимальная глубина дерева. +/// • minSamplesSplit — минимальное число объектов для разбиения узла. +/// • minSamplesLeaf — минимальное число объектов в листе + constructor Create(maxDepth: integer; minSamplesSplit: integer; minSamplesLeaf: integer; + criterion: ISplitCriterion; seed: integer); + +/// Возвращает вектор важности признаков. +/// Важность вычисляется как суммарное уменьшение +/// нечистоты (impurity reduction) по всем разбиениям. +/// Значения нормированы так, что сумма равна 1. + function FeatureImportances: Vector; + +/// Обучает дерево решений для задачи регрессии. +/// X — матрица m × n (m объектов, n признаков). +/// y — вектор длины m с непрерывными значениями. +/// +/// Примечание: +/// • обученное состояние дерева НЕ копируется методом Clone + function Fit(X: Matrix; y: Vector): ISupervisedModel; virtual; abstract; + +/// Выполняет предсказание для матрицы X. +/// Возвращает вектор прогнозов. +/// Для регрессии — вещественные значения. +/// Для классификации — метки классов. + function Predict(X: Matrix): Vector; virtual; abstract; + +/// Копирует только конфигурацию модели (без обученного состояния). +/// Используется для создания независимых экземпляров модели. + function Clone: IModel; virtual; abstract; + +/// Возвращает true, если дерево обучено. +/// Если false — Predict вызовет ошибку. + property IsFitted: boolean read fFitted; + + function Name: string := Self.GetType.Name; + end; + //============================ // DecisionTreeRegressor //============================ @@ -698,6 +707,11 @@ type DecisionTreeRegressor = class(DecisionTreeRegressorBase, IRegressor) private fLeafL2: real; + fSortedOrders: array of array of integer; + fSortedValues: array of array of real; + + fVisitMarks: array of integer; + fVisitId: integer; function PredictOne(X: Matrix; rowIndex: integer): real; @@ -706,12 +720,19 @@ type /// В регрессии это среднее целевой переменной /// с учетом L2-регуляризации (если leafL2 > 0). function LeafValue(y: Vector; indices: array of integer): real; override; + function BuildTreeNew(X: Matrix; y: Vector; indices: array of integer; depth: integer): DecisionTreeNode; + function BuildTreeNode(X: Matrix; y: Vector; nodeOrders: array of array of integer; depth: integer): DecisionTreeNode; - function FindBestSplitReg(X: Matrix; y: Vector; indices: array of integer): SplitResult; + function FindBestSplitReg(X: Matrix; y: Vector; nodeOrders: array of array of integer): RegSplitResult; + procedure BuildSortedOrders(X: Matrix; indices: array of integer); + function BuildInitialNodeOrders(indices: array of integer): array of array of integer; + procedure SplitNodeOrders(X: Matrix; nodeOrders: array of array of integer; feature: integer; threshold: real; + var leftOrders, rightOrders: array of array of integer); + function BuildMembershipMask(rowCount: integer; indices: array of integer): array of boolean; + procedure ComputeNodeStats(yData: array of real; indices: array of integer; var sumAll, sumSqAll: real); + function WeightedVariance(n, leftCount: integer; leftSum, leftSumSq, sumAll, sumSqAll: real): real; + function GetFeatureSubset(p: integer): array of integer; -/// Ищет лучшее разбиение узла по всем признакам и возможным порогам. -/// Критерий — максимальное уменьшение дисперсии. - function FindBestSplit(X: Matrix; y: Vector; indices: array of integer): SplitResult; override; /// Проверяет, является ли узел "чистым". /// Для регрессии это означает, что все значения y одинаковы /// или разбиение больше не имеет смысла. @@ -909,7 +930,7 @@ type /// Строит ансамбль классификационных деревьев, обученных на /// bootstrap-подвыборках объектов и случайных подмножествах признаков. /// Итоговое предсказание формируется голосованием деревьев или агрегацией вероятностей классов - RandomForestClassifier = class(RandomForestBase, IProbabilisticClassifier) + RandomForestClassifier = class(RandomForestBase, IProbabilisticClassifier, IClassifierInternal) private fTrees: array of DecisionTreeCore; fIndexToClass: array of integer; @@ -1178,7 +1199,7 @@ type /// • validation early stopping /// • OOB early stopping /// • staged prediction - GradientBoostingClassifier = class(IProbabilisticClassifier) + GradientBoostingClassifier = class(IProbabilisticClassifier, IClassifierInternal) private // hyperparams fNEstimators: integer; @@ -1398,7 +1419,7 @@ type /// Реализует вероятностные предсказания через PredictProba /// ВАЖНО: KNN чувствителен к масштабу признаков. /// Всегда используйте StandardScaler или MinMaxScaler в Pipeline перед KNN. - KNNClassifier = class(KNNBase, IProbabilisticClassifier) + KNNClassifier = class(KNNBase, IProbabilisticClassifier, IClassifierInternal) private // ==== classification state ==== fYEnc: array of integer; @@ -2334,8 +2355,8 @@ const 'leafL2 должно быть >= 0 ({0}).!!' + 'leafL2 must be >= 0 ({0}).'; ER_MIN_LEAF_GT_SPLIT = - 'minSamplesSplit ({1}) должен быть > 2 * minSamplesLeaf ({0}).!!' + - 'minSamplesSplit ({1}) must be > 2 * minSamplesLeaf ({0}).'; + 'minSamplesSplit ({1}) должен быть >= 2 * minSamplesLeaf ({0}).!!' + + 'minSamplesSplit ({1}) must be >= 2 * minSamplesLeaf ({0}).'; ER_OOB_NOT_ENABLED = 'OOB score не включен для этой модели. Установите computeOOB = true в конструкторе.!!' + 'OOB score is not enabled for this model. Set computeOOB = true in the constructor.'; @@ -2960,6 +2981,10 @@ begin fClassToIndex[unique[i]] := i; fIndexToClass[i] := unique[i]; end; + + SetLength(fClassLabels, fIndexToClass.Length); + for var i := 0 to fIndexToClass.Length - 1 do + fClassLabels[i] := fIndexToClass[i].ToString; // --- init fW := new Matrix(p, fClassCount); @@ -3356,26 +3381,22 @@ begin if not fFitted then NotFittedError(ER_FIT_NOT_CALLED); - var P := PredictProba(X); - - var m := P.RowCount; - Result := new Vector(m); - - for var i := 0 to m - 1 do - begin - var internalIdx := P.RowArgMax(i); - Result[i] := fIndexToClass[internalIdx]; - end; + var labels := PredictLabels(X); + + Result := new Vector(labels.Length); + + for var i := 0 to labels.Length - 1 do + Result[i] := fIndexToClass[labels[i]]; end; function LogisticRegression.PredictLabels(X: Matrix): array of integer; begin - var v := Predict(X); + var P := PredictProba(X); - Result := new integer[v.Length]; + SetLength(Result, P.RowCount); - for var i := 0 to v.Length - 1 do - Result[i] := Round(v[i]); + for var i := 0 to P.RowCount - 1 do + Result[i] := P.RowArgMax(i); end; function LogisticRegression.ToString: string; @@ -3707,7 +3728,7 @@ begin var bestFeature: integer; var bestThreshold: real; - if not FindBestSplit(X, y, indices, bestFeature, bestThreshold) then + if not FindBestSplitCore(X, y, indices, bestFeature, bestThreshold) then exit(CreateLeaf(y, indices)); // 4. split → только индексы @@ -3757,7 +3778,7 @@ begin end; // Новая реализация (O(n log n · p)) -function DecisionTreeCore.FindBestSplit( +function DecisionTreeCore.FindBestSplitCore( X: Matrix; y: Vector; indices: array of integer; @@ -3963,62 +3984,6 @@ begin fRng := new System.Random(fRandomSeed); end; -procedure DecisionTreeRegressorBase.CopyBaseState(dest: DecisionTreeRegressorBase); -begin - dest.fMaxDepth := fMaxDepth; - dest.fMinSamplesSplit := fMinSamplesSplit; - dest.fMinSamplesLeaf := fMinSamplesLeaf; - dest.fFitted := fFitted; - dest.fRandomSeed := fRandomSeed; - dest.fMaxFeatures := fMaxFeatures; - - dest.fUserProvidedSeed := fUserProvidedSeed; - - if fUserProvidedSeed then - dest.fRng := new System.Random(fRandomSeed) - else - dest.fRng := new System.Random; - - // MUST be stateless - if fCriterion <> nil then - dest.fCriterion := fCriterion; - - if fFeatureImportances <> nil then - dest.fFeatureImportances := fFeatureImportances.Clone; - - if fRoot <> nil then - dest.fRoot := fRoot.Clone; - - if fRowIndices <> nil then - dest.fRowIndices := Copy(fRowIndices); -end; - -function DecisionTreeRegressorBase.GetFeatureSubset(nFeatures: integer): array of integer; -begin - if (fMaxFeatures = 0) or (fMaxFeatures >= nFeatures) then - begin - Result := new integer[nFeatures]; - for var i := 0 to nFeatures-1 do - Result[i] := i; - exit; - end; - - var all := new List; - for var i := 0 to nFeatures-1 do - all.Add(i); - - var subset := new integer[fMaxFeatures]; - - for var k := 0 to fMaxFeatures-1 do - begin - var idx := fRng.Next(all.Count); - subset[k] := all[idx]; - all.RemoveAt(idx); - end; - - Result := subset; -end; - procedure DecisionTreeRegressorBase.SetRowIndices(rows: array of integer); begin if Length(rows) = 0 then @@ -4046,54 +4011,52 @@ begin Result := n; end; -function DecisionTreeRegressorBase.BuildTree(X: Matrix; y: Vector; +function DecisionTreeRegressor.BuildTreeNew(X: Matrix; y: Vector; indices: array of integer; depth: integer): DecisionTreeNode; begin + var nodeOrders := BuildInitialNodeOrders(indices); + Result := BuildTreeNode(X, y, nodeOrders, depth); +end; + +function DecisionTreeRegressor.BuildTreeNode(X: Matrix; y: Vector; + nodeOrders: array of array of integer; depth: integer): DecisionTreeNode; +begin + var indices := nodeOrders[0]; + var n := indices.Length; + if (fMaxDepth >= 0) and (depth >= fMaxDepth) then exit(LeafNode(LeafValue(y, indices))); - if indices.Length < fMinSamplesSplit then + if n < fMinSamplesSplit then exit(LeafNode(LeafValue(y, indices))); - if IsPure(y, indices) then + var yData := y.Data; + var sumAll, sumSqAll: real; + ComputeNodeStats(yData, indices, sumAll, sumSqAll); + + var meanAll := sumAll / n; + var parentVar := (sumSqAll / n) - meanAll * meanAll; + if parentVar < 0 then + parentVar := 0.0; + + if parentVar < 1e-12 then exit(LeafNode(LeafValue(y, indices))); - var parentImp := fCriterion.Impurity(y, indices); - - if double.IsNaN(parentImp) or double.IsInfinity(parentImp) then - exit(LeafNode(LeafValue(y, indices))); - - var split := FindBestSplit(X, y, indices); + var split := FindBestSplitReg(X, y, nodeOrders); if not split.Found then exit(LeafNode(LeafValue(y, indices))); - var left := new List; - var right := new List; + var leftOrders, rightOrders: array of array of integer; + SplitNodeOrders(X, nodeOrders, split.Feature, split.Threshold, leftOrders, rightOrders); + var leftArr := leftOrders[0]; + var rightArr := rightOrders[0]; - foreach var i in indices do - if X[i, split.Feature] <= split.Threshold then - left.Add(i) - else - right.Add(i); - - if (left.Count < fMinSamplesLeaf) or - (right.Count < fMinSamplesLeaf) then + if (leftArr.Length < fMinSamplesLeaf) or + (rightArr.Length < fMinSamplesLeaf) then exit(LeafNode(LeafValue(y, indices))); - var leftArr := left.ToArray; - var rightArr := right.ToArray; - - var leftImp := fCriterion.Impurity(y, leftArr); - var rightImp := fCriterion.Impurity(y, rightArr); - - var n := indices.Length; - - var weighted := - (real(leftArr.Length) / n) * leftImp + - (real(rightArr.Length) / n) * rightImp; - - var delta := parentImp - weighted; + var delta := parentVar - split.WeightedScore; if double.IsNaN(delta) or double.IsInfinity(delta) then exit(LeafNode(LeafValue(y, indices))); @@ -4101,14 +4064,13 @@ begin if delta < 0 then delta := 0.0; - // КЛЮЧЕВОЙ PRODUCTION-ФИЛЬТР if delta <= 0 then exit(LeafNode(LeafValue(y, indices))); fFeatureImportances[split.Feature] += delta; - var leftNode := BuildTree(X, y, leftArr, depth + 1); - var rightNode := BuildTree(X, y, rightArr, depth + 1); + var leftNode := BuildTreeNode(X, y, leftOrders, depth + 1); + var rightNode := BuildTreeNode(X, y, rightOrders, depth + 1); var node := new DecisionTreeNode; node.IsLeaf := false; @@ -4127,123 +4089,295 @@ begin Result := fCriterion.Impurity(y, indices) < EPS; end; -function DecisionTreeRegressor.FindBestSplitReg(X: Matrix; y: Vector; indices: array of integer): SplitResult; +function DecisionTreeRegressor.FindBestSplitReg(X: Matrix; y: Vector; + nodeOrders: array of array of integer): RegSplitResult; begin + Result := RegSplitResult.Invalid; + + var indices := nodeOrders[0]; + var n := indices.Length; + if n < 2 then + exit; + + var xData := X.Data; + var yData := y.Data; + + var sumAll, sumSqAll: real; + ComputeNodeStats(yData, indices, sumAll, sumSqAll); + var bestScore := real.PositiveInfinity; var bestFeature := -1; var bestThreshold := 0.0; - var n := indices.Length; - if n < 2 then + var features := GetFeatureSubset(X.ColCount); + + for var fj := 0 to features.Length - 1 do begin - Result.Found := false; - exit; - end; - - // --- parent sums - var sumAll := 0.0; - var sumSqAll := 0.0; - - for var i := 0 to n-1 do - begin - var v := y[indices[i]]; - sumAll += v; - sumSqAll += v*v; - end; - - var ProcessFeature: integer -> () := j -> - begin - var pairs: array of (real, integer); - SetLength(pairs, n); - - for var i := 0 to n-1 do - begin - var idx := indices[i]; - pairs[i] := (X[idx,j], idx); - end; - - pairs.Sort(p -> p.Item1); + var j := features[fj]; + var order := nodeOrders[j]; + var orderLen := order.Length; var leftCount := 0; var leftSum := 0.0; var leftSumSq := 0.0; - for var i := 1 to n-1 do + var prevValue := 0.0; + var firstIncluded := true; + + for var i := 0 to orderLen - 1 do begin - var v := y[pairs[i-1].Item2]; + var idx := order[i]; + var xCur := xData[idx, j]; + var yCur := yData[idx]; + + if not firstIncluded then + begin + var rightCount := n - leftCount; + + if (leftCount >= fMinSamplesLeaf) and + (rightCount >= fMinSamplesLeaf) and + (prevValue <> xCur) then + begin + var rightCountReal := rightCount; + var leftMean := leftSum / leftCount; + var leftVar := leftSumSq / leftCount - leftMean * leftMean; + + var rightSum := sumAll - leftSum; + var rightSumSq := sumSqAll - leftSumSq; + var rightMean := rightSum / rightCount; + var rightVar := rightSumSq / rightCount - rightMean * rightMean; + + if leftVar < 0 then + leftVar := 0.0; + if rightVar < 0 then + rightVar := 0.0; + + var weighted := (leftCount * leftVar + rightCountReal * rightVar) / n; + + if weighted < bestScore then + begin + bestScore := weighted; + bestFeature := j; + bestThreshold := (prevValue + xCur) * 0.5; + end; + end; + end; leftCount += 1; - leftSum += v; - leftSumSq += v*v; - - var rightCount := n - leftCount; - - if (leftCount < fMinSamplesLeaf) or - (rightCount < fMinSamplesLeaf) then - continue; - - var x1 := pairs[i-1].Item1; - var x2 := pairs[i].Item1; - - if x1 = x2 then - continue; - - var rightSum := sumAll - leftSum; - var rightSumSq := sumSqAll - leftSumSq; - - var leftMean := leftSum / leftCount; - var rightMean := rightSum / rightCount; - - var leftVar := (leftSumSq / leftCount) - leftMean*leftMean; - var rightVar := (rightSumSq / rightCount) - rightMean*rightMean; - - if leftVar < 0 then leftVar := 0.0; - if rightVar < 0 then rightVar := 0.0; - - var weighted := - (real(leftCount) / n) * leftVar + - (real(rightCount) / n) * rightVar; - - if double.IsNaN(weighted) or double.IsInfinity(weighted) then - continue; - - if weighted < bestScore then - begin - bestScore := weighted; - bestFeature := j; - bestThreshold := (x1 + x2) * 0.5; - end; + leftSum += yCur; + leftSumSq += yCur * yCur; + prevValue := xCur; + firstIncluded := false; end; end; - var p := X.ColCount; - - if (fMaxFeatures <= 0) or (fMaxFeatures >= p) then - begin - for var j := 0 to p-1 do - ProcessFeature(j); - end - else - begin - var feat := new integer[p]; - for var i := 0 to p-1 do - feat[i] := i; - - for var i := 0 to fMaxFeatures-1 do - begin - var r := i + fRng.Next(p - i); - var tmp := feat[i]; - feat[i] := feat[r]; - feat[r] := tmp; - end; - - for var k := 0 to fMaxFeatures-1 do - ProcessFeature(feat[k]); - end; - Result.Found := bestFeature <> -1; Result.Feature := bestFeature; Result.Threshold := bestThreshold; + Result.WeightedScore := bestScore; +end; + +type + SortPair = record + Value: real; + Index: integer; + end; + SortPairComparer = class(IComparer) + public + function Compare(a, b: SortPair): integer; + begin + Result := a.Value.CompareTo(b.Value); + end; + end; + +procedure DecisionTreeRegressor.BuildSortedOrders(X: Matrix; indices: array of integer); +begin + var p := X.ColCount; + var n := indices.Length; + var xData := X.Data; + + SetLength(fSortedOrders, p); + SetLength(fSortedValues, p); + + for var j := 0 to p - 1 do + begin + var pairs: array of SortPair; + SetLength(pairs, n); + + for var i := 0 to n - 1 do + begin + var idx := indices[i]; + pairs[i].Value := xData[idx, j]; + pairs[i].Index := idx; + end; + + System.Array.Sort(pairs,new SortPairComparer); + + fSortedOrders[j] := new integer[n]; + fSortedValues[j] := new real[n]; + + for var i := 0 to n - 1 do + begin + fSortedValues[j][i] := pairs[i].Value; + fSortedOrders[j][i] := pairs[i].Index; + end; + end; +end; + +function DecisionTreeRegressor.BuildInitialNodeOrders(indices: array of integer): array of array of integer; +begin + var maxIdx := -1; + for var i := 0 to indices.Length - 1 do + if indices[i] > maxIdx then + maxIdx := indices[i]; + + var inNode := BuildMembershipMask(maxIdx + 1, indices); + SetLength(Result, Length(fSortedOrders)); + + for var j := 0 to Length(fSortedOrders) - 1 do + begin + var cnt := 0; + foreach var idx in fSortedOrders[j] do + if inNode[idx] then + cnt += 1; + + Result[j] := new integer[cnt]; + var k := 0; + foreach var idx in fSortedOrders[j] do + if inNode[idx] then + begin + Result[j][k] := idx; + k += 1; + end; + end; +end; + +procedure DecisionTreeRegressor.SplitNodeOrders(X: Matrix; + nodeOrders: array of array of integer; + feature: integer; + threshold: real; + var leftOrders, rightOrders: array of array of integer); +begin + var xData := X.Data; + var p := Length(nodeOrders); + + fVisitId += 1; + var mark := fVisitId; + + var splitArr := nodeOrders[feature]; + var splitLen := splitArr.Length; + + var leftCount := 0; + + for var i := 0 to splitLen - 1 do + begin + var idx := splitArr[i]; + + if xData[idx, feature] <= threshold then + begin + fVisitMarks[idx] := mark; + leftCount += 1; + end; + end; + + var rightCount := splitLen - leftCount; + + SetLength(leftOrders, p); + SetLength(rightOrders, p); + + for var j := 0 to p - 1 do + begin + var src := nodeOrders[j]; + var n := src.Length; + + var left := new integer[leftCount]; + var right := new integer[rightCount]; + + var li := 0; + var ri := 0; + + for var k := 0 to n - 1 do + begin + var idx := src[k]; + + if fVisitMarks[idx] = mark then + begin + left[li] := idx; + li += 1; + end + else + begin + right[ri] := idx; + ri += 1; + end; + end; + + leftOrders[j] := left; + rightOrders[j] := right; + end; +end; + +function DecisionTreeRegressor.BuildMembershipMask(rowCount: integer; indices: array of integer): array of boolean; +begin + Result := new boolean[rowCount]; + for var i := 0 to indices.Length - 1 do + Result[indices[i]] := true; +end; + +procedure DecisionTreeRegressor.ComputeNodeStats(yData: array of real; indices: array of integer; var sumAll, sumSqAll: real); +begin + sumAll := 0.0; + sumSqAll := 0.0; + + for var i := 0 to indices.Length - 1 do + begin + var v := yData[indices[i]]; + sumAll += v; + sumSqAll += v * v; + end; +end; + +function DecisionTreeRegressor.WeightedVariance(n, leftCount: integer; leftSum, leftSumSq, sumAll, sumSqAll: real): real; +begin + var rightCount := n - leftCount; + var rightSum := sumAll - leftSum; + var rightSumSq := sumSqAll - leftSumSq; + + var leftMean := leftSum / leftCount; + var rightMean := rightSum / rightCount; + + var leftVar := (leftSumSq / leftCount) - leftMean * leftMean; + var rightVar := (rightSumSq / rightCount) - rightMean * rightMean; + + if leftVar < 0 then leftVar := 0.0; + if rightVar < 0 then rightVar := 0.0; + + Result := + (real(leftCount) / n) * leftVar + + (real(rightCount) / n) * rightVar; +end; + +function DecisionTreeRegressor.GetFeatureSubset(p: integer): array of integer; +begin + if (fMaxFeatures <= 0) or (fMaxFeatures >= p) then + begin + Result := Arr(0..p - 1); + exit; + end; + + Result := new integer[fMaxFeatures]; + var feat := new integer[p]; + for var i := 0 to p - 1 do + feat[i] := i; + + for var i := 0 to fMaxFeatures - 1 do + begin + var r := i + fRng.Next(p - i); + var tmp := feat[i]; + feat[i] := feat[r]; + feat[r] := tmp; + Result[i] := feat[i]; + end; end; //============================== @@ -4320,6 +4454,9 @@ begin fCriterion := new GiniCriterion(classes.Length); fIndexToClass := classes; + SetLength(fClassLabels, fIndexToClass.Length); + for var i := 0 to fIndexToClass.Length - 1 do + fClassLabels[i] := fIndexToClass[i].ToString; // --- encoded vector var yEncoded := new Vector(yEncArr); @@ -4330,6 +4467,7 @@ begin fMinSamplesSplit, fMinSamplesLeaf, fCriterion, + classes.Length, fMaxFeatures, fRandomSeed ); @@ -4341,6 +4479,16 @@ begin end; function DecisionTreeClassifier.Predict(X: Matrix): Vector; +begin + var labels := PredictLabels(X); + + Result := new Vector(labels.Length); + + for var i := 0 to labels.Length - 1 do + Result[i] := fIndexToClass[labels[i]]; +end; + +function DecisionTreeClassifier.PredictLabels(X: Matrix): array of integer; begin if not fFitted then NotFittedError(ER_FIT_NOT_CALLED); @@ -4353,27 +4501,13 @@ begin if X.ColCount <> fFeatureImportances.Length then DimensionError(ER_FEATURE_COUNT_MISMATCH, X.ColCount, fFeatureImportances.Length); - + var predIdx := fCore.Predict(X); - - var n := predIdx.Length; - Result := new Vector(n); - - for var i := 0 to n - 1 do - begin - var k := Round(predIdx[i]); - Result[i] := fIndexToClass[k]; - end; -end; - -function DecisionTreeClassifier.PredictLabels(X: Matrix): array of integer; -begin - var v := Predict(X); - Result := new integer[v.Length]; + Result := new integer[predIdx.Length]; - for var i := 0 to v.Length - 1 do - Result[i] := Round(v[i]); + for var i := 0 to predIdx.Length - 1 do + Result[i] := Round(predIdx[i]); end; function DecisionTreeClassifier.Clone: IModel; @@ -4465,11 +4599,6 @@ begin Result := value; end; -function DecisionTreeRegressor.FindBestSplit(X: Matrix; y: Vector; indices: array of integer): SplitResult; -begin - Result := FindBestSplitReg(X, y, indices); -end; - function DecisionTreeRegressor.IsPure(y: Vector; indices: array of integer): boolean; begin var first := y[indices[0]]; @@ -4508,7 +4637,12 @@ begin if indices = nil then indices := Arr(0..X.RowCount - 1); - fRoot := BuildTree(X, y, indices, 0); + BuildSortedOrders(X, indices); + + SetLength(fVisitMarks, X.RowCount); + fVisitId := 0; + + fRoot := BuildTreeNew(X, y, indices, 0); var s := fFeatureImportances.Sum; if s > 0 then @@ -4518,6 +4652,10 @@ begin fFitted := true; fRowIndices := nil; + fSortedOrders := nil; + fSortedValues := nil; + + fVisitMarks := nil; Result := Self; end; @@ -4914,6 +5052,10 @@ begin var yEncArr := EncodeLabelsInt(yInt, fIndexToClass); fClassCount := fIndexToClass.Length; + + SetLength(fClassLabels, fIndexToClass.Length); + for var i := 0 to fIndexToClass.Length - 1 do + fClassLabels[i] := fIndexToClass[i].ToString; if fClassCount < 2 then ArgumentError(ER_NEED_AT_LEAST_TWO_CLASSES); @@ -5016,6 +5158,16 @@ begin end; function RandomForestClassifier.Predict(X: Matrix): Vector; +begin + var labels := PredictLabels(X); + + Result := new Vector(labels.Length); + + for var i := 0 to labels.Length - 1 do + Result[i] := fIndexToClass[labels[i]]; +end; + +function RandomForestClassifier.PredictLabels(X: Matrix): array of integer; begin if not fFitted then NotFittedError(ER_FIT_NOT_CALLED); @@ -5028,7 +5180,7 @@ begin if X.ColCount <> fFeatureCount then DimensionError(ER_FEATURE_COUNT_MISMATCH, X.ColCount, fFeatureCount); - + var n := X.RowCount; var treeCount := fTrees.Length; @@ -5038,19 +5190,17 @@ begin if fClassCount <= 0 then Error(ER_MODEL_NOT_INITIALIZED); - var resultVec := new Vector(n); + Result := new integer[n]; var counts := new integer[fClassCount]; - + for var i := 0 to n - 1 do begin - // обнуление счётчиков for var c := 0 to fClassCount - 1 do counts[c] := 0; - // голосование деревьев for var t := 0 to treeCount - 1 do begin - var cls := fTrees[t].PredictRow(X, i); // <-- КЛЮЧЕВОЕ изменение + var cls := fTrees[t].PredictRow(X, i); if (cls < 0) or (cls >= fClassCount) then ArgumentError(ER_LABEL_INDEX_INVALID); @@ -5058,7 +5208,6 @@ begin counts[cls] += 1; end; - // выбор класса var bestClass := 0; var bestCount := counts[0]; @@ -5069,21 +5218,8 @@ begin bestClass := c; end; - // декодирование - resultVec[i] := fIndexToClass[bestClass]; + Result[i] := bestClass; end; - - Result := resultVec; -end; - -function RandomForestClassifier.PredictLabels(X: Matrix): array of integer; -begin - var v := Predict(X); - - Result := new integer[v.Length]; - - for var i := 0 to v.Length - 1 do - Result[i] := Round(v[i]); end; function RandomForestClassifier.PredictProba(X: Matrix): Matrix; @@ -6049,6 +6185,10 @@ begin var yEncoded := EncodeLabelsInt(yTrainInt, fClasses); fClassCount := fClasses.Length; + + SetLength(fClassLabels, fClasses.Length); + for var i := 0 to fClasses.Length - 1 do + fClassLabels[i] := fClasses[i].ToString; if fClassCount < 2 then ArgumentError(ER_NEED_AT_LEAST_TWO_CLASSES); @@ -6793,38 +6933,22 @@ begin if not fFitted then NotFittedError(ER_FIT_NOT_CALLED); - var probs := PredictProba(X); - - var nSamples := X.RowCount; - var classCount := fClassCount; - - Result := new Vector(nSamples); - - for var i := 0 to nSamples - 1 do - begin - var bestClassIndex := 0; - var bestValue := probs[i, 0]; - - for var cls := 1 to classCount - 1 do - if probs[i, cls] > bestValue then - begin - bestValue := probs[i, cls]; - bestClassIndex := cls; - end; - - // возвращаем оригинальную метку - Result[i] := fClasses[bestClassIndex]; - end; + var labels := PredictLabels(X); + + Result := new Vector(labels.Length); + + for var i := 0 to labels.Length - 1 do + Result[i] := fClasses[labels[i]]; end; function GradientBoostingClassifier.PredictLabels(X: Matrix): array of integer; begin - var v := Predict(X); + var probs := PredictProba(X); - Result := new integer[v.Length]; + SetLength(Result, probs.RowCount); - for var i := 0 to v.Length - 1 do - Result[i] := Round(v[i]); + for var i := 0 to probs.RowCount - 1 do + Result[i] := probs.RowArgMax(i); end; procedure GradientBoostingClassifier.SetClassLabels(classes: array of string); @@ -7088,6 +7212,10 @@ begin SetLength(fClasses, fClassCount); for var i := 0 to fClassCount - 1 do fClasses[i] := classesInt[i]; + + SetLength(fClassLabels, fClassCount); + for var i := 0 to fClassCount - 1 do + fClassLabels[i] := fClasses[i].ToString; // сохранить encoded y SetLength(fYEnc, n); @@ -7121,6 +7249,16 @@ begin end; function KNNClassifier.Predict(X: Matrix): Vector; +begin + var labels := PredictLabels(X); + + Result := new Vector(labels.Length); + + for var i := 0 to labels.Length - 1 do + Result[i] := fClasses[labels[i]]; +end; + +function KNNClassifier.PredictLabels(X: Matrix): array of integer; begin if not fFitted then NotFittedError(ER_FIT_NOT_CALLED); @@ -7137,17 +7275,10 @@ begin var trainRows := fXTrain.Data.Rows; var testRows := X.Data.Rows; - Result := new Vector(m); + Result := new integer[m]; for var i := 0 to m - 1 do begin - // заполнить расстояния - // for var t := 0 to n - 1 do - // begin - // fNeighbors[t].dist := SquaredL2(t, X, i); - // fNeighbors[t].idx := t; - // end; - var rowTest := testRows[i]; for var t := 0 to n - 1 do begin @@ -7165,10 +7296,8 @@ begin fNeighbors[t].idx := t; end; - // выбрать k ближайших QuickSelect(fK - 1); - // exact match: если среди k ближайших есть dist=0, возвращаем его класс var exactCls := -1; for var t := 0 to fK - 1 do if fNeighbors[t].dist < KNN_EPS then @@ -7179,11 +7308,10 @@ begin if exactCls <> -1 then begin - Result[i] := fClasses[exactCls]; + Result[i] := exactCls; continue; end; - // voting (stamping) fEpoch += 1; var touchCount := 0; @@ -7207,7 +7335,6 @@ begin end else begin - // weighted: веса 1 / dist (dist = squared distance) for var t := 0 to fK - 1 do begin var trainIdx := fNeighbors[t].idx; @@ -7224,7 +7351,6 @@ begin if dist < KNN_EPS then begin - // считаем как exact match exactCls := cls; break; end @@ -7238,11 +7364,10 @@ begin if exactCls <> -1 then begin - Result[i] := fClasses[exactCls]; + Result[i] := exactCls; continue; end; - // argmax только по touched var bestCls := fTouched[0]; var bestVotes := fVotes[bestCls]; @@ -7258,20 +7383,10 @@ begin end; end; - Result[i] := fClasses[bestCls]; + Result[i] := bestCls; end; end; -function KNNClassifier.PredictLabels(X: Matrix): array of integer; -begin - var v := Predict(X); - - Result := new integer[v.Length]; - - for var i := 0 to v.Length - 1 do - Result[i] := Round(v[i]); -end; - function KNNClassifier.PredictProba(X: Matrix): Matrix; begin if not fFitted then @@ -7281,15 +7396,29 @@ begin var m := X.RowCount; var n := fXTrain.RowCount; + var p := fXTrain.ColCount; + + var trainRows := fXTrain.Data.Rows; + var testRows := X.Data.Rows; Result := new Matrix(m, fClassCount); // предполагаем нулевую инициализацию for var i := 0 to m - 1 do begin // заполнить расстояния + var rowTest := testRows[i]; for var t := 0 to n - 1 do begin - fNeighbors[t].dist := SquaredL2(t, X, i); + var rowTrain := trainRows[t]; + var sum := 0.0; + + for var j := 0 to p - 1 do + begin + var diff := rowTrain[j] - rowTest[j]; + sum += diff * diff; + end; + + fNeighbors[t].dist := sum; fNeighbors[t].idx := t; end; diff --git a/bin/Lib/MLPipelineABC.pas b/bin/Lib/MLPipelineABC.pas index 543448ea5..cc7c68435 100644 --- a/bin/Lib/MLPipelineABC.pas +++ b/bin/Lib/MLPipelineABC.pas @@ -115,18 +115,32 @@ type function Transform(df: DataFrame): DataFrame; /// Делает предсказание модели для объектов из DataFrame. + /// Для задач классификации возвращает внутренние индексы классов (0..K-1). + /// В отличие от обычных моделей-классификаторов, здесь Predict + /// возвращает не исходные метки классов, а их внутренние индексы. /// Доступен после обучения конвейера (Fit). function Predict(df: DataFrame): Vector; + /// Возвращает исходные строковые метки классов для объектов из DataFrame. + /// Это удобная расшифровка результата Predict через GetClassLabels. + /// Доступен только для задач классификации после Fit. + function PredictLabels(df: DataFrame): array of string; + /// Возвращает матрицу вероятностей (nSamples × nClasses). /// Доступен только если конечная модель поддерживает IProbabilisticClassifier. function PredictProba(df: DataFrame): Matrix; - /// Возвращает метки классов в порядке кодирования (0,1,2,...), - /// используемом при EncodeLabels. - /// Доступен только для задач классификации после Fit. + /// Возвращает исходные метки классов в порядке внутреннего кодирования (0..K-1), + /// соответствующем индексам, возвращаемым Predict. + /// Доступен только для задач классификации. function GetClassLabels: array of string; + /// Возвращает внутренние индексы истинных меток классов для DataFrame + /// в соответствии с кодированием, полученным при Fit. + /// Используется для вычисления метрик после Predict. + /// Доступен только для задач классификации после Fit. + function GetEncodedLabels(df: DataFrame): Vector; + function ToString: string; override; function Name: string := Self.GetType.Name; @@ -221,6 +235,7 @@ implementation uses MLExceptions; uses DataAdapters; +uses MLUtilsABC; const ER_PIPELINE_MODIFY_AFTER_FIT = @@ -282,12 +297,16 @@ const 'Операция доступна только для задач классификации!!Operation is only available for classification tasks'; ER_CLASSES_NOT_AVAILABLE = 'Метки классов недоступны. Убедитесь, что конвейер обучен и задача — классификация!!Class labels are not available. Ensure the pipeline is fitted and the task is classification'; + ER_LABEL_INDEX_OUT_OF_RANGE = + 'Индекс метки {0} вне диапазона [0, {1})!!Label index {0} is out of range [0, {1})'; ER_LABELENCODER_TARGET_NOT_ALLOWED = 'LabelEncoder нельзя применять к целевой переменной — кодирование выполняется внутри модели!!LabelEncoder cannot be applied to target — encoding is handled internally by the model'; ER_ENCODELABELS_NOT_CATEGORICAL = 'Целевой столбец должен быть категориальным для задач классификации!!Target column must be categorical for classification tasks'; ER_REGRESSION_TARGET_MUST_BE_NUMERIC = 'Целевой столбец "{0}" должен быть числовым для задач регрессии!!Target column "{0}" must be numeric for regression tasks'; + ER_PREPROCESSOR_ROWCOUNT_CHANGED = + 'DataFrame-преобразователь не должен изменять число строк!!DataFrame preprocessor must preserve RowCount'; ER_PIPELINE_TARGET_TRANSFORM_NOT_ALLOWED = 'Преобразование целевой переменной "{0}" запрещено в DataPipeline!!' + 'Transformation of target variable "{0}" is not allowed in DataPipeline'; @@ -403,8 +422,11 @@ begin // --- DataFrame steps for var i := 0 to fDataSteps.Count - 1 do begin + var prevRows := current.RowCount; fDataSteps[i] := fDataSteps[i].Fit(current); current := fDataSteps[i].Transform(current); + if current.RowCount <> prevRows then + Error(ER_PREPROCESSOR_ROWCOUNT_CHANGED); end; fFinalFeatures := ResolveFinalFeatures(current); @@ -421,7 +443,12 @@ begin var current := df; foreach var s in fDataSteps do + begin + var prevRows := current.RowCount; current := s.Transform(current); + if current.RowCount <> prevRows then + Error(ER_PREPROCESSOR_ROWCOUNT_CHANGED); + end; Result := current; end; @@ -631,7 +658,7 @@ begin if fTask = tkClassification then begin - if fModel is IClassifier(var cls) then + if fModel is IClassifierInternal(var cls) then cls.SetClassLabels(classes) else Error(ER_MODEL_NOT_CLASSIFIER); @@ -643,6 +670,7 @@ end; function DataPipeline.Transform(df: DataFrame): DataFrame := TransformDataFrame(df); + function DataPipeline.Predict(df: DataFrame): Vector; begin if df = nil then @@ -664,6 +692,28 @@ begin Result := (fModel as IPredictiveModel).Predict(X); end; +function DataPipeline.PredictLabels(df: DataFrame): array of string; +begin + if not fFitted then + NotFittedError(ER_FIT_NOT_CALLED); + + if fTask <> tkClassification then + ArgumentError(ER_NOT_CLASSIFICATION); + + var encoded := LabelsToInts(Predict(df)); + var classes := GetClassLabels; + Result := new string[encoded.Length]; + + for var i := 0 to encoded.Length - 1 do + begin + var idx := encoded[i]; + if (idx < 0) or (idx >= classes.Length) then + Error(ER_LABEL_INDEX_OUT_OF_RANGE, idx, classes.Length); + + Result[i] := classes[idx]; + end; +end; + function DataPipeline.PredictProba(df: DataFrame): Matrix; begin if df = nil then @@ -685,6 +735,25 @@ begin Result := (fModel as IProbabilisticClassifier).PredictProba(X); end; +function DataPipeline.GetEncodedLabels(df: DataFrame): Vector; +begin + if not fFitted then + NotFittedError(ER_FIT_NOT_CALLED); + + if fTask <> tkClassification then + ArgumentError(ER_NOT_CLASSIFICATION); + + if df = nil then + ArgumentNullError(ER_ARG_NULL, 'df'); + + if not df.HasColumn(fTarget) then + ArgumentError(ER_COLUMN_NOT_FOUND, fTarget); + + var classes := GetClassLabels; + var labels := df.TransformLabels(fTarget, classes); + Result := new Vector(labels); +end; + function DataPipeline.GetClassLabels: array of string; begin if not fFitted then @@ -929,8 +998,11 @@ begin // --- 1) DataFrame шаги for var i := 0 to fDataSteps.Count - 1 do begin + var prevRows := current.RowCount; fDataSteps[i] := fDataSteps[i].Fit(current); current := fDataSteps[i].Transform(current); + if current.RowCount <> prevRows then + Error(ER_PREPROCESSOR_ROWCOUNT_CHANGED); end; // --- 2) вычислить финальные признаки diff --git a/bin/Lib/MetricsABC.pas b/bin/Lib/MetricsABC.pas index dff74ceb6..ce124e679 100644 --- a/bin/Lib/MetricsABC.pas +++ b/bin/Lib/MetricsABC.pas @@ -1028,8 +1028,21 @@ begin var correct := 0; for var i := 0 to n - 1 do - if Round(yTrue[i]) = Round(yPred[i]) then + begin + var yt := yTrue[i]; + var yp := yPred[i]; + var ytInt := Round(yt); + var ypInt := Round(yp); + + if Abs(yt - ytInt) > 1e-12 then + ArgumentError(ER_INVALID_CLASS_LABEL, yt); + + if Abs(yp - ypInt) > 1e-12 then + ArgumentError(ER_INVALID_CLASS_LABEL, yp); + + if ytInt = ypInt then correct += 1; + end; Result := correct / n; end; @@ -1078,12 +1091,13 @@ begin for var i := 0 to n - 1 do begin var yt := data[i]; + var ytInt := Round(yt); // проверка: метки должны быть целыми - if Abs(yt - integer(yt)) > 1e-12 then + if Abs(yt - ytInt) > 1e-12 then ArgumentError(ER_INVALID_CLASS_LABEL, yt); - if integer(yt) = yPred[i] then + if ytInt = yPred[i] then correct += 1; end; @@ -1944,8 +1958,8 @@ begin if double.IsNaN(yp) or double.IsInfinity(yp) then ArgumentError(ER_INVALID_VALUE, 'yPred', i); - var ytInt := integer(yt); - var ypInt := integer(yp); + var ytInt := Round(yt); + var ypInt := Round(yp); if Abs(yt - ytInt) > 1e-12 then ArgumentError(ER_INVALID_CLASS_LABEL, yt); diff --git a/bin/Lib/PlotML.pas b/bin/Lib/PlotML.pas index 967ed2fa2..efc741ba0 100644 --- a/bin/Lib/PlotML.pas +++ b/bin/Lib/PlotML.pas @@ -20,6 +20,7 @@ uses System, System.Windows.Shapes, System.Threading, System.Windows.Threading, + System.Text, InteractiveDataDisplay.WPF, LinearAlgebraML; @@ -110,6 +111,7 @@ type procedure Surface(x1, x2: array of real; nx, ny: integer; f: Matrix -> array of integer; pal: PlotML.Palette := nil); procedure Heatmap(m: Matrix); + procedure HeatCell(value, minValue, maxValue: real; text: string := nil); procedure Text(s: string; x: real := 0.5; y: real := 0.5); @@ -151,11 +153,14 @@ type static procedure DrawPoints(chart: ChartWPF; x, y: array of real; color: ColorWPF; size: real; marker: MarkerType; legend: string); - static procedure DrawHeatmap(chart: ChartWPF; m: array[,] of real); + static procedure DrawHeatmap(chart: ChartWPF; m: array[,] of real; names: array of string := nil); static procedure DrawHist(chart: ChartWPF; x: array of real; bins: integer; color: ColorWPF; alpha: real; legend: string); + static procedure DrawHistMany(chart: ChartWPF; arrays: array of array of real; + bins: integer; colors: array of ColorWPF; alpha: real; legends: array of string); + static procedure DrawSurface(chart: ChartWPF; labels: array of integer; nx, ny: integer; xmin, xmax, ymin, ymax: real; pal: Palette); @@ -177,9 +182,13 @@ type static procedure Hist(x: array of real; bins: integer := 0; color: ColorWPF := DefaultColor; alpha: real := 0.7; legend: string := nil); + static procedure HistMany(arrays: array of array of real; bins: integer := 0; + colors: array of ColorWPF := nil; alpha: real := 0.7; legend: array of string := nil); + static procedure PairPlot(X: array[,] of real; labels: array of integer; names: array of string); static procedure Heatmap(m: array[,] of real); + static procedure Heatmap(m: array[,] of real; names: array of string); static procedure Surface(labels: array of integer; nx, ny: integer; xmin, xmax, ymin, ymax: real; pal: PlotML.Palette := nil); @@ -208,6 +217,7 @@ type := PairPlot(X.Data, labels, names); static procedure Heatmap(m: Matrix) := Heatmap(m.Data); + static procedure Heatmap(m: Matrix; names: array of string) := Heatmap(m.Data, names); static function Grid(rows,cols: integer): Figure; @@ -226,6 +236,8 @@ type static procedure Clear; static procedure Save(filename: string); + + static function DebugVisualTree: string; static property Title: string write SetTitle; end; @@ -238,6 +250,8 @@ type fBinsCount: integer; fDescription: string; fMaxCount: integer; + MinValue: real := real.NaN; + MaxValue: real := real.NaN; public constructor Create; @@ -250,6 +264,24 @@ type property Description: string read fDescription write fDescription; property MaxCount: integer read fMaxCount; end; + + HeatmapPlot = class(PlotWPF) + private + fCells: List := new List; + fMinValue: real; + fMaxValue: real; + + function LerpColor(c1, c2: ColorWPF; t: real): ColorWPF; + function Clamp01(x: real): real; + function ColorForValue(v: real): ColorWPF; + public + constructor Create; + + procedure SetData(m: array[,] of real); + + property MinValue: real read fMinValue; + property MaxValue: real read fMaxValue; + end; SurfacePlot = class(PlotWPF) private @@ -377,6 +409,69 @@ begin Result := c; end; +function Clamp01(x: real): real; +begin + if x < 0 then + Result := 0 + else if x > 1 then + Result := 1 + else + Result := x; +end; + +function LerpColor(c1, c2: ColorWPF; t: real): ColorWPF; +begin + t := Clamp01(t); + + Result := Color.FromRgb( + byte(Round(c1.R + (c2.R - c1.R) * t)), + byte(Round(c1.G + (c2.G - c1.G) * t)), + byte(Round(c1.B + (c2.B - c1.B) * t)) + ); +end; + +function HeatmapColor(v, minValue, maxValue: real): ColorWPF; +begin + if real.IsNaN(v) or real.IsInfinity(v) then + exit(Color.FromRgb(180, 180, 180)); + + if minValue = maxValue then + exit(Color.FromRgb(255, 255, 255)); + + var blue := Color.FromRgb(49, 130, 189); + var white := Color.FromRgb(255, 255, 255); + var red := Color.FromRgb(222, 45, 38); + + if (minValue < 0) and (maxValue > 0) then + begin + if v < 0 then + Result := LerpColor(white, blue, Abs(v / minValue)) + else + Result := LerpColor(white, red, v / maxValue); + exit; + end; + + Result := LerpColor(blue, red, (v - minValue) / (maxValue - minValue)); +end; + +procedure DumpVisualNode(sb: StringBuilder; obj: DependencyObject; level: integer); +begin + if obj = nil then + exit; + + var fe := obj as FrameworkElement; + var name := if (fe <> nil) and (fe.Name <> nil) and (fe.Name <> '') then fe.Name else '-'; + + sb.Append(''.PadLeft(level * 2)); + sb.Append(obj.GetType.FullName); + sb.Append(' Name='); + sb.AppendLine(name); + + var cnt := VisualTreeHelper.GetChildrenCount(obj); + for var i := 0 to cnt - 1 do + DumpVisualNode(sb, VisualTreeHelper.GetChild(obj, i), level + 1); +end; + function MakeHistogram(data: array of real; bins: integer): (array of real, array of real); begin var xmin := data.Min; @@ -631,6 +726,35 @@ begin end); end; +procedure Cell.HeatCell(value, minValue, maxValue: real; text: string); +begin + Plot.RunUI(() -> + begin + EnsureChart; + + var container := chart.Content as GridWPF; + if container = nil then + begin + container := new GridWPF; + chart.Content := container; + end; + + container.Children.Clear; + container.Background := new SolidColorBrush(HeatmapColor(value, minValue, maxValue)); + + if text <> nil then + begin + var tb := new System.Windows.Controls.TextBlock; + tb.Text := text; + tb.FontSize := 14; + tb.FontWeight := System.Windows.FontWeights.Bold; + tb.HorizontalAlignment := System.Windows.HorizontalAlignment.Center; + tb.VerticalAlignment := System.Windows.VerticalAlignment.Center; + container.Children.Add(tb); + end; + end); +end; + procedure Cell.Hist(x: array of real; bins: integer; color: ColorWPF; alpha: real; legend: string); begin Plot.RunUI(() -> @@ -811,6 +935,25 @@ begin end); end; +static function Plot.DebugVisualTree: string; +begin + var sb := new StringBuilder; + + RunUI(() -> + begin + if win = nil then + begin + sb.AppendLine('win = nil'); + exit; + end; + + sb.AppendLine('Window content tree:'); + DumpVisualNode(sb, win.Content as DependencyObject, 0); + end); + + Result := sb.ToString; +end; + static function Plot.CreateLineSeries(x,y: array of real; c: Color): LineGraphWPF; begin var g := new LineGraphWPF; @@ -870,7 +1013,13 @@ begin tb.HorizontalAlignment := System.Windows.HorizontalAlignment.Center; tb.VerticalAlignment := System.Windows.VerticalAlignment.Center; - var grid := chart.Parent as System.Windows.Controls.Grid; + var grid := chart.Content as GridWPF; + if grid = nil then + begin + grid := new GridWPF; + chart.Content := grid; + end; + if grid <> nil then begin tb.HorizontalAlignment := System.Windows.HorizontalAlignment.Center; @@ -893,19 +1042,85 @@ begin AddSeries(chart, g); end; -static procedure Plot.DrawHeatmap(chart: ChartWPF; m: array[,] of real); +static procedure Plot.DrawHeatmap(chart: ChartWPF; m: array[,] of real; names: array of string); begin var rows := m.GetLength(0); var cols := m.GetLength(1); - var x := ArrGen(cols, i -> i); - var y := ArrGen(rows, i -> i); + var container := new GridWPF; + chart.Content := container; - var g := new HeatmapGraph; - - g.Plot(m, x, y); + var topOffset := if names <> nil then 1 else 0; + var leftOffset := if names <> nil then 1 else 0; - AddSeries(chart, g); + for var i := 0 to rows + topOffset - 1 do + container.RowDefinitions.Add(new RowDefinition); + + for var j := 0 to cols + leftOffset - 1 do + container.ColumnDefinitions.Add(new ColumnDefinition); + + if names <> nil then + begin + for var j := 0 to cols - 1 do + begin + var tb := new System.Windows.Controls.TextBlock; + tb.Text := names[j]; + tb.FontSize := 13; + tb.FontWeight := System.Windows.FontWeights.Normal; + tb.TextAlignment := TextAlignment.Center; + tb.TextWrapping := TextWrapping.Wrap; + tb.Margin := new Thickness(6); + tb.HorizontalAlignment := HorizontalAlignment.Center; + tb.VerticalAlignment := VerticalAlignment.Center; + GridWPF.SetRow(tb, 0); + GridWPF.SetColumn(tb, j + 1); + container.Children.Add(tb); + end; + + for var i := 0 to rows - 1 do + begin + var tb := new System.Windows.Controls.TextBlock; + tb.Text := names[i]; + tb.FontSize := 13; + tb.FontWeight := System.Windows.FontWeights.Normal; + tb.TextAlignment := TextAlignment.Center; + tb.TextWrapping := TextWrapping.Wrap; + tb.Margin := new Thickness(6); + tb.HorizontalAlignment := HorizontalAlignment.Center; + tb.VerticalAlignment := VerticalAlignment.Center; + GridWPF.SetRow(tb, i + 1); + GridWPF.SetColumn(tb, 0); + container.Children.Add(tb); + end; + end; + + var valuesOnly := new HeatmapPlot; + valuesOnly.SetData(m); + var minValue := valuesOnly.MinValue; + var maxValue := valuesOnly.MaxValue; + + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + begin + var border := new Border; + border.BorderBrush := Brushes.White; + border.BorderThickness := new Thickness(0.25); + border.Background := new SolidColorBrush(HeatmapColor(m[i, j], minValue, maxValue)); + + var tb := new System.Windows.Controls.TextBlock; + tb.Text := $'{m[i, j]:F2}'; + tb.FontSize := 15; + tb.FontWeight := System.Windows.FontWeights.SemiBold; + tb.TextAlignment := TextAlignment.Center; + tb.HorizontalAlignment := HorizontalAlignment.Center; + tb.VerticalAlignment := VerticalAlignment.Center; + + border.Child := tb; + + GridWPF.SetRow(border, i + topOffset); + GridWPF.SetColumn(border, j + leftOffset); + container.Children.Add(border); + end; end; class procedure Plot.AddSeries(chart: ChartWPF; series: UIElement); @@ -977,7 +1192,15 @@ static procedure Plot.Heatmap(m: array[,] of real); begin RunUI(() -> begin - DrawHeatmap(rootChart, m); + DrawHeatmap(rootChart, m, nil); + end); +end; + +static procedure Plot.Heatmap(m: array[,] of real; names: array of string); +begin + RunUI(() -> + begin + DrawHeatmap(rootChart, m, names); end); end; @@ -1183,6 +1406,94 @@ begin chart.PlotHeight := hist.MaxCount * 1.1; end; +static procedure Plot.HistMany(arrays: array of array of real; bins: integer; + colors: array of ColorWPF; alpha: real; legend: array of string); +begin + RunUI(() -> + begin + Plot.DrawHistMany(rootChart, arrays, bins, colors, alpha, legend); + end); +end; + +static procedure Plot.DrawHistMany(chart: ChartWPF; arrays: array of array of real; + bins: integer; colors: array of ColorWPF; alpha: real; legends: array of string); +begin + EnsureAxes(chart); + + var hasLegend := (legends <> nil) and (legends.Length > 0); + + if hasLegend then + chart.LegendVisibility := Visibility.Visible; + + if (arrays = nil) or (arrays.Length = 0) then exit; + + // --- общий диапазон --- + var globalMin := real.MaxValue; + var globalMax := real.MinValue; + + foreach var arr in arrays do + begin + if (arr = nil) or (arr.Length = 0) then continue; + globalMin := Min(globalMin, arr.Min); + globalMax := Max(globalMax, arr.Max); + end; + + if globalMin = real.MaxValue then + exit; + + if globalMax <= globalMin then + exit; + + if bins = 0 then + begin + var maxLen := 0; + + foreach var arr in arrays do + if (arr <> nil) and (arr.Length > maxLen) then + maxLen := arr.Length; + + if maxLen = 0 then + exit; + + bins := Round(Sqrt(maxLen)); + end; + + var maxCount := 0; + + // --- рисуем --- + for var k := 0 to arrays.Length - 1 do + begin + var x := arrays[k]; + if (x = nil) or (x.Length = 0) then continue; + + var hist := new HistogramPlot; + + hist.BinsCount := bins; + hist.Color := if (colors<>nil) and (k < colors.Length) then colors[k] else NextRootColor; + hist.Alpha := alpha; + + // фиксируем диапазон + hist.MinValue := globalMin; + hist.MaxValue := globalMax; + + hist.SetData(x); + + if hist.MaxCount > maxCount then + maxCount := hist.MaxCount; + + if hasLegend and (k < legends.Length) then + hist.Description := legends[k]; + + AddSeries(chart, hist); + end; + + // --- оси --- + chart.PlotOriginX := Floor(globalMin); + chart.PlotWidth := Ceil(globalMax) - Floor(globalMin); + chart.PlotOriginY := 0; + chart.PlotHeight := maxCount * 1.1; +end; + constructor HistogramPlot.Create; begin fBins := new List; @@ -1200,8 +1511,8 @@ begin if x=nil then exit; if x.Length=0 then exit; - var xmin := Floor(x.Min); - var xmax := Ceil(x.Max); + var xmin := if not real.IsNaN(MinValue) then Floor(MinValue) else Floor(x.Min); + var xmax := if not real.IsNaN(MaxValue) then Ceil(MaxValue) else Ceil(x.Max); if xmax=xmin then exit; @@ -1245,10 +1556,118 @@ begin fBins.Add(poly); Children.Add(poly); end; - + fMaxCount := counts.Max; end; +constructor HeatmapPlot.Create; +begin + IsAutoFitEnabled := false; + fMinValue := 0; + fMaxValue := 0; +end; + +function HeatmapPlot.Clamp01(x: real): real; +begin + if x < 0 then + Result := 0 + else if x > 1 then + Result := 1 + else + Result := x; +end; + +function HeatmapPlot.LerpColor(c1, c2: ColorWPF; t: real): ColorWPF; +begin + t := Clamp01(t); + + Result := Color.FromRgb( + byte(Round(c1.R + (c2.R - c1.R) * t)), + byte(Round(c1.G + (c2.G - c1.G) * t)), + byte(Round(c1.B + (c2.B - c1.B) * t)) + ); +end; + +function HeatmapPlot.ColorForValue(v: real): ColorWPF; +begin + Result := HeatmapColor(v, fMinValue, fMaxValue); +end; + +procedure HeatmapPlot.SetData(m: array[,] of real); +begin + Children.Clear; + fCells.Clear; + + if m = nil then + exit; + + var rows := m.GetLength(0); + var cols := m.GetLength(1); + + if (rows = 0) or (cols = 0) then + exit; + + var foundFinite := false; + fMinValue := 0; + fMaxValue := 0; + + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + begin + var v := m[i, j]; + if real.IsNaN(v) or real.IsInfinity(v) then + continue; + + if not foundFinite then + begin + fMinValue := v; + fMaxValue := v; + foundFinite := true; + end + else + begin + if v < fMinValue then + fMinValue := v; + if v > fMaxValue then + fMaxValue := v; + end; + end; + + if not foundFinite then + exit; + + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + begin + var v := m[i, j]; + var brush := new SolidColorBrush(ColorForValue(v)); + + var x0 := j; + var x1 := j + 1; + + // Делаем нулевую строку верхней строкой матрицы. + var y0 := rows - i - 1; + var y1 := rows - i; + + var poly := new Polygon; + var pts := new PointCollection; + + pts.Add(new Point(x0, y0)); + pts.Add(new Point(x0, y1)); + pts.Add(new Point(x1, y1)); + pts.Add(new Point(x1, y0)); + + PlotWPF.SetPoints(poly, pts); + + poly.Fill := brush; + poly.Stroke := Brushes.LightGray; + poly.StrokeThickness := 0.5; + + fCells.Add(poly); + Children.Add(poly); + end; +end; + procedure SurfacePlot.SetData(labels: array of integer; nx, ny: integer; xmin, xmax, ymin, ymax: real; pal: Palette); begin @@ -1374,4 +1793,4 @@ end; initialization InitUI; -end. \ No newline at end of file +end. diff --git a/bin/Lib/PreprocessorABC.pas b/bin/Lib/PreprocessorABC.pas index be15d6f60..b9e624cc1 100644 --- a/bin/Lib/PreprocessorABC.pas +++ b/bin/Lib/PreprocessorABC.pas @@ -21,6 +21,7 @@ unit PreprocessorABC; interface uses DataFrameABC; +uses DataFrameABCCore; uses System; uses MLCoreABC; @@ -122,9 +123,9 @@ type ImputeStrategy = (isMean, isConstant, isMedian); -/// Заполняет пропущенные значения (NA) в числовых столбцах -/// Поддерживает стратегии isMean и isConstant -/// Работает только с Int и Float столбцами +/// Заполняет пропущенные значения (NA) в столбцах DataFrame +/// Стратегии isMean и isMedian работают только с числовыми столбцами +/// Стратегия isConstant работает с любыми поддерживаемыми типами Imputer = class(IPreprocessor, IColumnsBoundStep) private cols: array of string; @@ -133,13 +134,14 @@ type means: array of real; medians: array of real; fitted: boolean; + function BuildImputedColumn(df: DataFrame; idx, imputerIndex: integer): Column; public /// Создаёт Imputer с заполнением средним значением - constructor Create(params columns: array of string); + constructor Create(columns: array of string); /// Создаёт Imputer с заданной стратегией заполнения - constructor Create(strategy: ImputeStrategy; params columns: array of string); + constructor Create(strategy: ImputeStrategy; columns: array of string); /// Создаёт Imputer с константной стратегией заполнения - constructor Create(value: object; params columns: array of string); + constructor Create(value: object; columns: array of string); /// Вычисляет значения для заполнения пропусков. /// df — таблица данных. @@ -216,6 +218,8 @@ const 'Стратегия импутации {0} не поддерживается!!Imputation strategy {0} is not supported'; ER_UNSUPPORTED_IMPUTE_STRATEGY = 'Неподдерживаемая стратегия заполнения: {0}!!Unsupported impute strategy: {0}'; + ER_UNSUPPORTED_COLUMN_TYPE = + 'Неподдерживаемый тип столбца!!Unsupported column type'; ER_ONEHOT_NAME_EQUALS_SOURCE = 'Сгенерированная колонка совпадает с исходной: {0}!!Generated column equals source column: {0}'; ER_ONEHOT_COLUMN_COLLISION = @@ -313,7 +317,15 @@ begin else res.AddIntColumn(col, data, valid); - Result := res.SetCategorical([col]); + var catCols := new List; + + foreach var name in df.Schema.ColumnNames do + if df.IsCategorical(name) and (name <> col) then + catCols.Add(name); + + catCols.Add(col); // encoded колонка тоже categorical + + Result := res.SetCategorical(catCols.ToArray); end; function LabelEncoder.FitTransform(df: DataFrame): DataFrame; @@ -331,6 +343,24 @@ function LabelEncoder.Clone: IPreprocessor; begin Result := new LabelEncoder(col); end; + +procedure AppendAllColumnsExcept( + res, src: DataFrame; + skipIndex: integer; + names: List; + types: List; + cats: List +); +begin + for var i := 0 to src.ColumnCount - 1 do + if i <> skipIndex then + begin + res.AddColumnAlias(src.GetColumn(i)); + names.Add(src.Schema.NameAt(i)); + types.Add(src.Schema.ColumnTypeAt(i)); + cats.Add(src.Schema.IsCategoricalAt(i)); + end; +end; //----------------------------- // OneHotEncoder //----------------------------- @@ -413,42 +443,56 @@ begin if srcIdx < 0 then ArgumentError(ER_COLUMN_NOT_FOUND, col); + var rowCount := df.RowCount; var catCount := categories.Length; - - var res := df; - - // === Генерация one-hot столбцов === + var res := new DataFrame; + + var names := new List; + var types := new List; + var cats := new List; + + AppendAllColumnsExcept(res, df, srcIdx, names, types, cats); + + var srcCol := StrColumn(df.GetColumn(srcIdx)); + var srcData := srcCol.Data; + var srcValid := srcCol.IsValid; + + var dataCols := new List; + var validCols := new List; + for var j := 0 to catCount - 1 do begin - var catIdx := j; - var newName := col + '_' + categories[j]; - - var Encode: DataFrameCursor -> integer := c -> - begin - if not c.IsValid(col) then - begin - Result := 0; - exit; - end; - - var s := c.Str(col); - - var idx: integer; - if not indexByValue.TryGetValue(s, idx) then - begin - Result := 0; - exit; - end; - - Result := Ord(idx = catIdx); - end; - - res := res.AddDerivedIntColumn(newName, Encode); + dataCols.Add(new integer[rowCount]); + validCols.Add([True] * rowCount); end; - - // === удаление исходного столбца === - res := res.Drop([srcIdx]); - + + for var row := 0 to rowCount - 1 do + begin + if (srcValid <> nil) and not srcValid[row] then + continue; + + var s := srcData[row]; + + var idx: integer; + if indexByValue.TryGetValue(s, idx) then + dataCols[idx][row] := 1; + end; + + for var j := 0 to catCount - 1 do + begin + var newName := col + '_' + categories[j]; + res.AddIntColumn(newName, dataCols[j], validCols[j]); + names.Add(newName); + types.Add(ColumnType.ctInt); + cats.Add(False); + end; + + res.SetSchema(new DataFrameSchema( + names.ToArray, + types.ToArray, + cats.ToArray + )); + Result := res; end; @@ -487,7 +531,7 @@ end; // Imputer //----------------------------- -constructor Imputer.Create(strategy: ImputeStrategy; params columns: array of string); +constructor Imputer.Create(strategy: ImputeStrategy; columns: array of string); begin if (columns = nil) or (columns.Length = 0) then ArgumentError(ER_IMPUTER_NO_COLUMNS); @@ -498,12 +542,12 @@ begin fitted := false; end; -constructor Imputer.Create(params columns: array of string); +constructor Imputer.Create(columns: array of string); begin Create(ImputeStrategy.isMean, columns); end; -constructor Imputer.Create(value: object; params columns: array of string); +constructor Imputer.Create(value: object; columns: array of string); begin if (columns = nil) or (columns.Length = 0) then ArgumentError(ER_IMPUTER_NO_COLUMNS); @@ -600,6 +644,179 @@ begin Result := Self; end; +function Imputer.BuildImputedColumn(df: DataFrame; idx, imputerIndex: integer): Column; +begin + var name := cols[imputerIndex]; + var ct := df.Schema.ColumnTypeAt(idx); + var capturedIdx := idx; + + case strategy of + isMean: + begin + if not (ct in [ColumnType.ctInt, ColumnType.ctFloat]) then + Error(ER_IMPUTER_COLUMN_NOT_NUMERIC, name); + + var m := means[imputerIndex]; + var rowCount := df.RowCount; + var data := new real[rowCount]; + var valid := new boolean[rowCount]; + + var cur := df.GetCursor; + var row := 0; + while cur.MoveNext do + begin + data[row] := if cur.IsValid(capturedIdx) then cur.Float(capturedIdx) else m; + valid[row] := True; + row += 1; + end; + + Result := new FloatColumn(name, data, valid); + end; + + isConstant: + begin + var v := constants[imputerIndex]; + if v = nil then + Error(ER_IMPUTER_CONSTANT_VALUE_NULL, name); + + if ct = ColumnType.ctInt then + begin + var k: integer; + + if v is integer then + k := integer(v) + else if v is real then + begin + var r := real(v); + var ir := Round(r); + if Abs(r - ir) > 1e-9 then + Error(ER_IMPUTER_CONSTANT_TYPE_MISMATCH, name); + k := ir; + end + else + Error(ER_IMPUTER_CONSTANT_TYPE_MISMATCH, name); + + var rowCount := df.RowCount; + var data := new integer[rowCount]; + var valid := new boolean[rowCount]; + + var cur := df.GetCursor; + var row := 0; + while cur.MoveNext do + begin + data[row] := if cur.IsValid(capturedIdx) then cur.Int(capturedIdx) else k; + valid[row] := True; + row += 1; + end; + + Result := new IntColumn(name, data, valid); + end + else if ct = ColumnType.ctFloat then + begin + var r: real; + try + r := real(v); + except + on e: Exception do + Error(ER_IMPUTER_CONSTANT_TYPE_MISMATCH, name); + end; + + var rowCount := df.RowCount; + var data := new real[rowCount]; + var valid := new boolean[rowCount]; + + var cur := df.GetCursor; + var row := 0; + while cur.MoveNext do + begin + data[row] := if cur.IsValid(capturedIdx) then cur.Float(capturedIdx) else r; + valid[row] := True; + row += 1; + end; + + Result := new FloatColumn(name, data, valid); + end + else if ct = ColumnType.ctStr then + begin + var s: string; + try + s := string(v); + except + on e: Exception do + Error(ER_IMPUTER_CONSTANT_TYPE_MISMATCH, name); + end; + + var rowCount := df.RowCount; + var data := new string[rowCount]; + var valid := new boolean[rowCount]; + + var cur := df.GetCursor; + var row := 0; + while cur.MoveNext do + begin + data[row] := if cur.IsValid(capturedIdx) then cur.Str(capturedIdx) else s; + valid[row] := True; + row += 1; + end; + + Result := new StrColumn(name, data, valid); + end + else if ct = ColumnType.ctBool then + begin + var b: boolean; + try + b := boolean(v); + except + on e: Exception do + Error(ER_IMPUTER_CONSTANT_TYPE_MISMATCH, name); + end; + + var rowCount := df.RowCount; + var data := new boolean[rowCount]; + var valid := new boolean[rowCount]; + + var cur := df.GetCursor; + var row := 0; + while cur.MoveNext do + begin + data[row] := if cur.IsValid(capturedIdx) then cur.Bool(capturedIdx) else b; + valid[row] := True; + row += 1; + end; + + Result := new BoolColumn(name, data, valid); + end + else + Error(ER_UNSUPPORTED_COLUMN_TYPE, ct); + end; + + isMedian: + begin + if not (ct in [ColumnType.ctInt, ColumnType.ctFloat]) then + Error(ER_IMPUTER_COLUMN_NOT_NUMERIC, name); + + var m := medians[imputerIndex]; + var rowCount := df.RowCount; + var data := new real[rowCount]; + var valid := new boolean[rowCount]; + + var cur := df.GetCursor; + var row := 0; + while cur.MoveNext do + begin + data[row] := if cur.IsValid(capturedIdx) then cur.Float(capturedIdx) else m; + valid[row] := True; + row += 1; + end; + + Result := new FloatColumn(name, data, valid); + end; + + else + Error(ER_IMPUTER_STRATEGY_NOT_SUPPORTED, strategy); + end; +end; + function Imputer.Transform(df: DataFrame): DataFrame; begin if not fitted then @@ -610,90 +827,39 @@ begin ((constants = nil) or (constants.Length <> cols.Length)) then Error(ER_IMPUTER_CONSTANTS_INVALID); - var res := df; - - for var i := 0 to cols.Length - 1 do + var imputeCols := new HashSet(cols); + var res := new DataFrame; + var names := new List; + var types := new List; + var cats := new List; + + for var i := 0 to df.ColumnCount - 1 do begin - var name := cols[i]; - - // --- ВАЖНО: используем актуальную схему - var idx := res.Schema.IndexOf(name); - var ct := res.Schema.ColumnTypeAt(idx); - - if not (ct in [ColumnType.ctInt, ColumnType.ctFloat]) then - Error(ER_IMPUTER_COLUMN_NOT_NUMERIC, name); - - var capturedIdx := idx; + var name := df.Schema.NameAt(i); + var imputerIndex := cols.IndexOf(name); - case strategy of - isMean: - begin - var m := means[i]; - res := res.ReplaceColumnFloat( - name, - c -> (if c.IsValid(capturedIdx) then c.Float(capturedIdx) else m) - ); - end; - - isConstant: - begin - var v := constants[i]; - if v = nil then - Error(ER_IMPUTER_CONSTANT_VALUE_NULL, name); - - if ct = ColumnType.ctInt then - begin - var k: integer; - - if v is integer then - k := integer(v) - else if v is real then - begin - var r := real(v); - var ir := Round(r); - if Abs(r - ir) > 1e-9 then - Error(ER_IMPUTER_CONSTANT_TYPE_MISMATCH, name); - k := ir; - end - else - Error(ER_IMPUTER_CONSTANT_TYPE_MISMATCH, name); - - res := res.ReplaceColumnInt( - name, - c -> (if c.IsValid(capturedIdx) then c.Int(capturedIdx) else k) - ); - end - else // ctFloat - begin - var r: real; - try - r := real(v); - except - on e: Exception do - Error(ER_IMPUTER_CONSTANT_TYPE_MISMATCH, name); - end; - - res := res.ReplaceColumnFloat( - name, - c -> (if c.IsValid(capturedIdx) then c.Float(capturedIdx) else r) - ); - end; - end; - - isMedian: - begin - var m := medians[i]; - res := res.ReplaceColumnFloat( - name, - c -> (if c.IsValid(capturedIdx) then c.Float(capturedIdx) else m) - ); - end; - - else - Error(ER_IMPUTER_STRATEGY_NOT_SUPPORTED, strategy); + if (name in imputeCols) and (imputerIndex >= 0) then + begin + var col := BuildImputedColumn(df, i, imputerIndex); + res.AddColumnAlias(col); + names.Add(name); + types.Add(col.Info.ColType); + cats.Add(df.Schema.IsCategoricalAt(i)); + end + else + begin + res.AddColumnAlias(df.GetColumn(i)); + names.Add(name); + types.Add(df.Schema.ColumnTypeAt(i)); + cats.Add(df.Schema.IsCategoricalAt(i)); end; end; - + + res.SetSchema(new DataFrameSchema( + names.ToArray, + types.ToArray, + cats.ToArray + )); Result := res; end;