diff --git a/Configuration/GlobalAssemblyInfo.cs b/Configuration/GlobalAssemblyInfo.cs index 3ccd6832c..8962ff0cd 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 = "3821"; + public const string Revision = "3823"; 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 333f34bd5..a448bea00 100644 --- a/Configuration/Version.defs +++ b/Configuration/Version.defs @@ -1,4 +1,4 @@ %MINOR%=11 -%REVISION%=3821 +%REVISION%=3823 %COREVERSION%=1 %MAJOR%=3 diff --git a/Release/pabcversion.txt b/Release/pabcversion.txt index c14d5d752..c9a4b9a81 100644 --- a/Release/pabcversion.txt +++ b/Release/pabcversion.txt @@ -1 +1 @@ -3.11.1.3821 +3.11.1.3823 diff --git a/ReleaseGenerators/Files/Datasets/titanic_ru.meta b/ReleaseGenerators/Files/Datasets/titanic_ru.meta index 479e9606b..a14784a13 100644 --- a/ReleaseGenerators/Files/Datasets/titanic_ru.meta +++ b/ReleaseGenerators/Files/Datasets/titanic_ru.meta @@ -1,4 +1,4 @@ -name = TitanicRu +name = TitanicRu task = classification @@ -56,9 +56,14 @@ value.Класс.1.en = first class value.Класс.2.en = second class value.Класс.3.en = third class +value.Пол.муж.ru = муж +value.Пол.жен.ru = жен value.Пол.муж.en = male value.Пол.жен.en = female +value.ПортПосадки.Саутгемптон.ru = Саутгемптон +value.ПортПосадки.Шербур.ru = Шербур +value.ПортПосадки.Квинстаун.ru = Квинстаун value.ПортПосадки.Саутгемптон.en = Southampton value.ПортПосадки.Шербур.en = Cherbourg value.ПортПосадки.Квинстаун.en = Queenstown diff --git a/ReleaseGenerators/PascalABCNET_version.nsh b/ReleaseGenerators/PascalABCNET_version.nsh index d981f2abb..aee79c88d 100644 --- a/ReleaseGenerators/PascalABCNET_version.nsh +++ b/ReleaseGenerators/PascalABCNET_version.nsh @@ -1 +1 @@ -!define VERSION '3.11.1.3821' +!define VERSION '3.11.1.3823' diff --git a/bin/Lib/DataAdapters.pas b/bin/Lib/DataAdapters.pas index 3777fa7ed..4a5068a6c 100644 --- a/bin/Lib/DataAdapters.pas +++ b/bin/Lib/DataAdapters.pas @@ -39,15 +39,39 @@ begin for var j := 0 to p - 1 do begin var col := df[colNames[j]]; - - for var i := 0 to n - 1 do - begin - var value: real; - - if not col.TryGetNumericValue(i, value) then + case col.Info.ColType of + ColumnType.ctInt: + begin + var c := IntColumn(col); + for var i := 0 to n - 1 do + begin + if not c.IsValid[i] then + ArgumentError(ER_TO_MATRIX_NON_NUMERIC, colNames[j]); + Result[i,j] := c.Data[i]; + end; + end; + ColumnType.ctFloat: + begin + var c := FloatColumn(col); + for var i := 0 to n - 1 do + begin + if not c.IsValid[i] then + ArgumentError(ER_TO_MATRIX_NON_NUMERIC, colNames[j]); + Result[i,j] := c.Data[i]; + end; + end; + ColumnType.ctBool: + begin + var c := BoolColumn(col); + for var i := 0 to n - 1 do + begin + if not c.IsValid[i] then + ArgumentError(ER_TO_MATRIX_NON_NUMERIC, colNames[j]); + Result[i,j] := if c.Data[i] then 1.0 else 0.0; + end; + end; + else ArgumentError(ER_TO_MATRIX_NON_NUMERIC, colNames[j]); - - Result[i,j] := value; end; end; end; @@ -59,15 +83,39 @@ begin Result := new Vector(n); var col := df[colName]; - - for var i := 0 to n - 1 do - begin - var value: real; - - if not col.TryGetNumericValue(i, value) then + case col.Info.ColType of + ColumnType.ctInt: + begin + var c := IntColumn(col); + for var i := 0 to n - 1 do + begin + if not c.IsValid[i] then + ArgumentError(ER_TO_VECTOR_NON_NUMERIC, colName); + Result[i] := c.Data[i]; + end; + end; + ColumnType.ctFloat: + begin + var c := FloatColumn(col); + for var i := 0 to n - 1 do + begin + if not c.IsValid[i] then + ArgumentError(ER_TO_VECTOR_NON_NUMERIC, colName); + Result[i] := c.Data[i]; + end; + end; + ColumnType.ctBool: + begin + var c := BoolColumn(col); + for var i := 0 to n - 1 do + begin + if not c.IsValid[i] then + ArgumentError(ER_TO_VECTOR_NON_NUMERIC, colName); + Result[i] := if c.Data[i] then 1.0 else 0.0; + end; + end; + else ArgumentError(ER_TO_VECTOR_NON_NUMERIC, colName); - - Result[i] := value; end; end; diff --git a/bin/Lib/DataFrameABC.pas b/bin/Lib/DataFrameABC.pas index 6819e3b64..28bb07542 100644 --- a/bin/Lib/DataFrameABC.pas +++ b/bin/Lib/DataFrameABC.pas @@ -47,7 +47,6 @@ type columns: List; fschema: DataFrameSchema; - procedure RebuildSchema; procedure CommitAddedColumn(c: Column); procedure ValidateColumnsAgainstSchema; procedure ValidateColumnsAgainstSchema(candidate: DataFrameSchema); @@ -194,19 +193,24 @@ type /// Не изменяйте их после передачи в DataFrame. procedure AddBoolColumn(name: string; data: array of boolean; valid: array of boolean := nil); - /// Возвращает внутренний буфер целочисленного столбца без копирования. - /// Не изменяйте его, иначе DataFrame будет повреждён. + /// Возвращает целочисленный массив значений столбца с данным именем. function GetIntColumn(name: string): array of integer; - /// Возвращает внутренний буфер вещественного столбца без копирования. - /// Не изменяйте его, иначе DataFrame будет повреждён. + /// Возвращает вещественный массив значений столбца с данным именем. function GetFloatColumn(name: string): array of real; - /// Возвращает внутренний буфер строкового столбца без копирования. - /// Не изменяйте его, иначе DataFrame будет повреждён. + /// Возвращает строковый массив значений столбца с данным именем. function GetStrColumn(name: string): array of string; - /// Возвращает внутренний буфер логического столбца без копирования. - /// Не изменяйте его, иначе DataFrame будет повреждён. + /// Возвращает логический массив значений столбца с данным именем. function GetBoolColumn(name: string): array of boolean; + /// Возвращает целочисленный массив значений столбца с данным именем. + function Int(name: string): array of integer; + /// Возвращает вещественный массив значений столбца с данным именем. + function Float(name: string): array of real; + /// Возвращает строковый массив значений столбца с данным именем. + function Str(name: string): array of string; + /// Возвращает логический массив значений столбца с данным именем. + function Bool(name: string): array of boolean; + /// Вычисляет сумму значений столбца по индексу function Sum(colIndex: integer): real; /// Вычисляет сумму значений столбца по имени @@ -2170,29 +2174,6 @@ begin Result.AssertSchemaConsistent; end; -procedure DataFrame.RebuildSchema; -begin - var n := columns.Count; - var names := new string[n]; - var types := new ColumnType[n]; - var cats := new boolean[n]; - - for var i := 0 to n - 1 do - begin - var info := columns[i].Info; - names[i] := info.Name; - types[i] := info.ColType; - - // берем из старой schema - if (fSchema <> nil) and (i < fSchema.ColumnCount) then - cats[i] := fSchema.CategoricalFlags[i] - else - cats[i] := false; - end; - - fSchema := new DataFrameSchema(names, types, cats); -end; - procedure DataFrame.CommitAddedColumn(c: Column); begin var n := columns.Count; @@ -2206,7 +2187,7 @@ begin names[i] := info.Name; types[i] := info.ColType; if fSchema <> nil then - cats[i] := fSchema.CategoricalFlags[i] + cats[i] := fSchema.IsCategoricalAt(i) else cats[i] := false; end; @@ -2322,6 +2303,26 @@ begin Result := c.Data; end; +function DataFrame.Int(name: string): array of integer; +begin + Result := GetIntColumn(name); +end; + +function DataFrame.Float(name: string): array of real; +begin + Result := GetFloatColumn(name); +end; + +function DataFrame.Str(name: string): array of string; +begin + Result := GetStrColumn(name); +end; + +function DataFrame.Bool(name: string): array of boolean; +begin + Result := GetBoolColumn(name); +end; + function DataFrame.TrainTestSplit(testRatio: real; shuffle: boolean; seed: integer): (DataFrame, DataFrame); begin if Self = nil then @@ -2595,7 +2596,7 @@ function DataFrame.Mean(colName: string): real function DataFrame.Median(colIndex: integer): real; begin CheckColumnIndex(colIndex); - Result := Statistics.Median(Self, fSchema.ColumnNames[colIndex]); + Result := Statistics.Median(Self, fSchema.NameAt(colIndex)); end; function DataFrame.Median(colName: string): real; @@ -3086,7 +3087,7 @@ begin // копируем старые значения for var i := 0 to n - 1 do - cats[i] := fSchema.CategoricalFlags[i]; + cats[i] := fSchema.IsCategoricalAt(i); // применяем новые foreach var name in names do @@ -3207,7 +3208,7 @@ begin // КЛЮЧЕВОЕ: перенос schema var cats := new boolean[ColumnCount]; for var i := 0 to ColumnCount - 1 do - cats[i] := fSchema.CategoricalFlags[i]; + cats[i] := fSchema.IsCategoricalAt(i); res.SetSchema(new DataFrameSchema( fSchema.ColumnNames, @@ -3311,12 +3312,12 @@ begin for var i := 0 to n - 1 do begin - var oldName := fSchema.ColumnNames[i]; + var oldName := fSchema.NameAt(i); var newName := if map.ContainsKey(oldName) then map[oldName] else oldName; names[i] := newName; - types[i] := fSchema.Types[i]; - cats[i] := fSchema.CategoricalFlags[i]; + types[i] := fSchema.ColumnTypeAt(i); + cats[i] := fSchema.IsCategoricalAt(i); end; res.SetSchema(new DataFrameSchema(names, types, cats)); @@ -3565,9 +3566,9 @@ begin for var j := 0 to oldN - 1 do begin - names[j] := fSchema.ColumnNames[j]; - types[j] := fSchema.Types[j]; - cats[j] := fSchema.CategoricalFlags[j]; + names[j] := fSchema.NameAt(j); + types[j] := fSchema.ColumnTypeAt(j); + cats[j] := fSchema.IsCategoricalAt(j); end; names[oldN] := name; @@ -4260,7 +4261,7 @@ begin for var i := 0 to ColumnCount - 1 do begin - var name := fSchema.ColumnNames[i].PadRight(nameWidth); + var name := fSchema.NameAt(i).PadRight(nameWidth); var typ := GetColumnType(i).ToString.Replace('ct','').PadRight(typeWidth); PABCSystem.Println($'{name} : {typ}'); end; @@ -4290,7 +4291,7 @@ begin for var i := 0 to ColumnCount - 1 do begin var t := ColumnTypeToString(GetColumnType(i)); - if fSchema.CategoricalFlags[i] then + if fSchema.IsCategoricalAt(i) then t += ' (categorical)'; types[i] := t; @@ -4305,7 +4306,7 @@ begin for var i := 0 to ColumnCount - 1 do begin - var name := fSchema.ColumnNames[i].PadRight(nameWidth); + var name := fSchema.NameAt(i).PadRight(nameWidth); var typ := types[i].PadRight(maxTypeWidth); var cnt := Count(i); @@ -4428,15 +4429,15 @@ begin for var i := 0 to n - 1 do begin - var name := fSchema.ColumnNames[i]; + var name := fSchema.NameAt(i); namesArr[i] := name; - cats[i] := fSchema.CategoricalFlags[i]; + cats[i] := fSchema.IsCategoricalAt(i); if toCast.Contains(name) then types[i] := ctInt else - types[i] := fSchema.Types[i]; + types[i] := fSchema.ColumnTypeAt(i); end; res.SetSchema(new DataFrameSchema(namesArr, types, cats)); @@ -5538,7 +5539,7 @@ begin // числовые столбцы for var i := 0 to df.ColumnCount - 1 do if df.GetColumnType(i) in [ColumnType.ctInt, ColumnType.ctFloat] then - names.Add(df.fSchema.ColumnNames[i]); + names.Add(df.fSchema.NameAt(i)); var n := names.Count; if n = 0 then @@ -5626,7 +5627,7 @@ begin means[i] := mean; stds[i] := Sqrt(variance); if stds[i] = 0 then - Error(ER_ZERO_STD_COLUMN, df.fSchema.ColumnNames[i]); + Error(ER_ZERO_STD_COLUMN, df.fSchema.NameAt(i)); isNumeric[i] := true; end; end; @@ -5635,7 +5636,7 @@ begin for var i := 0 to df.ColumnCount - 1 do begin if isNumeric[i] then - res.AddFloatColumn(df.fSchema.ColumnNames[i], new real[df.RowCount], nil) + res.AddFloatColumn(df.fSchema.NameAt(i), new real[df.RowCount], nil) else res.AddColumnAlias(df.GetColumn(i)); end; @@ -5708,7 +5709,7 @@ begin begin var (mn, mx) := df.MinMax(i); if mn = mx then - Error(ER_ZERO_RANGE_COLUMN, df.fSchema.ColumnNames[i]); + Error(ER_ZERO_RANGE_COLUMN, df.fSchema.NameAt(i)); mins[i] := mn; maxs[i] := mx; isNumeric[i] := true; @@ -5719,7 +5720,7 @@ begin for var i := 0 to df.ColumnCount - 1 do begin if isNumeric[i] then - res.AddFloatColumn(df.fSchema.ColumnNames[i], new real[df.RowCount], nil) + res.AddFloatColumn(df.fSchema.NameAt(i), new real[df.RowCount], nil) else res.AddColumnAlias(df.GetColumn(i)); end; diff --git a/bin/Lib/DataFrameABCCore.pas b/bin/Lib/DataFrameABCCore.pas index 8e3337acf..70ab8e47c 100644 --- a/bin/Lib/DataFrameABCCore.pas +++ b/bin/Lib/DataFrameABCCore.pas @@ -39,36 +39,55 @@ type function GetTypes: array of ColumnType; function GetCategoricalFlags: array of boolean; public + /// Возвращает количество столбцов в схеме property ColumnCount: integer read fNames.Length; + /// Возвращает копию массива имён столбцов property ColumnNames: array of string read GetColumnNames; + /// Возвращает копию массива типов столбцов property Types: array of ColumnType read GetTypes; + /// Возвращает копию массива categorical-флагов property CategoricalFlags: array of boolean read GetCategoricalFlags; + /// Возвращает индекс столбца по имени function IndexOf(name: string): integer; + /// Проверяет наличие столбца с указанным именем function HasColumn(name: string): boolean; + /// Возвращает тип столбца по индексу function ColumnTypeAt(i: integer): ColumnType; + /// Проверяет categorical-флаг столбца по индексу function IsCategoricalAt(i: integer): boolean; + /// Возвращает тип столбца по имени function GetColumnType(name: string): ColumnType; + /// Проверяет categorical-флаг столбца по имени function IsCategorical(name: string): boolean; + /// Возвращает имя столбца по индексу function NameAt(i: integer): string; + /// Создаёт схему по именам, типам и categorical-флагам constructor Create(names: array of string; types: array of ColumnType; isCategorical: array of boolean := nil); + /// Печатает схему без перевода строки в конце procedure Print; + /// Печатает схему и переводит строку procedure Println; { --- schema operations (immutable) --- } + /// Возвращает схему, содержащую только указанные столбцы function Select(indices: array of integer): DataFrameSchema; + /// Возвращает схему без указанных столбцов function Drop(indices: array of integer): DataFrameSchema; + /// Возвращает схему с переименованным столбцом function Rename(oldName, newName: string): DataFrameSchema; + /// Возвращает схему с изменённым categorical-флагом столбца function WithCategorical(name: string; value: boolean := True): DataFrameSchema; { --- join helpers --- } + /// Объединяет две схемы по правилам Join class function Merge( left, right: DataFrameSchema; leftKeys, rightKeys: array of integer; diff --git a/bin/Lib/MLABC.pas b/bin/Lib/MLABC.pas index 1dc0d8fb1..5a641db94 100644 --- a/bin/Lib/MLABC.pas +++ b/bin/Lib/MLABC.pas @@ -71,6 +71,7 @@ type DataFrame = DataFrameABC.DataFrame; DataFrameCursor = DataFrameABCCore.DataFrameCursor; ColumnType = DataFrameABCCore.ColumnType; + Column = DataFrameABCCore.Column; Statistics = DataFrameABC.Statistics; CsvLoader = DataFrameABC.CsvLoader; @@ -127,6 +128,7 @@ type Datasets = MLDatasets.Datasets; Dataset = MLDatasets.Dataset; + LabelEncoder = MLDatasets.LabelEncoder; IModel = MLCoreABC.IModel; ISupervisedModel = MLCoreABC.ISupervisedModel; diff --git a/bin/Lib/MLCoreABC.pas b/bin/Lib/MLCoreABC.pas index 5096d1b9c..f5679b176 100644 --- a/bin/Lib/MLCoreABC.pas +++ b/bin/Lib/MLCoreABC.pas @@ -158,6 +158,7 @@ type /// как признаков X, так и целевой переменной y. /// Запоминает необходимые параметры, которые будут использоваться при Transform. function Fit(X: Matrix; y: Vector): ISupervisedTransformer; + function FitTransform(X: Matrix; y: Vector): Matrix; end; /// Интерфейс преобразования признаков без учёта целевой переменной. @@ -167,6 +168,7 @@ type /// Обучает преобразование на данных с использованием признаков X /// Запоминает необходимые параметры, которые будут использоваться при Transform. function Fit(X: Matrix): IUnsupervisedTransformer; + function FitTransform(X: Matrix): Matrix; end; IColumnExpander = interface diff --git a/bin/Lib/MLDatasets.pas b/bin/Lib/MLDatasets.pas index da313ae89..ab4c912b3 100644 --- a/bin/Lib/MLDatasets.pas +++ b/bin/Lib/MLDatasets.pas @@ -21,6 +21,7 @@ type private function ValueLabel(feature, value: string): string; function CloneMeta(df: DataFrame): Dataset; + function GetCategoricalFeatures: array of string; public FeatureLabels: Dictionary; ValueLabels: Dictionary>; @@ -65,10 +66,34 @@ type function RowCount: integer := Data.RowCount; function GetFeatureColumns: array of string; + property CategoricalFeatures: array of string read GetCategoricalFeatures; function HasTarget: boolean; end; + /// Кодирует target-колонку классификационного Dataset в числовой Vector. + /// Используется только для целевой переменной, не для признаков. + LabelEncoder = class + private + fClasses: array of string; + fClassToIndex: Dictionary; + + function GetClasses: array of string; + procedure EnsureFitted; + procedure CheckDataset(ds: Dataset); + function TargetLabels(ds: Dataset): array of string; + public + function Fit(ds: Dataset): LabelEncoder; + function Transform(ds: Dataset): Vector; + function FitTransform(ds: Dataset): Vector; + + property Classes: array of string read GetClasses; + + function ClassName(index: integer): string; + function ClassIndex(name: string): integer; + function Decode(y: Vector): array of string; + end; + /// Набор генераторов и загрузчиков датасетов для задач машинного обучения. /// Содержит синтетические генераторы (MakeBlobs, MakeMoons, MakeRegression) /// и реальные учебные датасеты (например, RussianHousing, StudentExam). @@ -263,6 +288,10 @@ type /// Датасет пассажиров Титаника (задача классификации) static function TitanicRu: Dataset; + + /// Датасет цен на автомобили с пробегом (задача регрессии) + static function UsedCarsPrice: Dataset; + {/// Датасет результатов экзамена студентов (классификация) static function StudentExam: Dataset; @@ -340,6 +369,13 @@ const ER_ENCODELABELS_UNSUPPORTED_TYPE = 'Неподдерживаемый тип столбца для кодирования меток: {0}!!' + 'Unsupported column type for label encoding: {0}'; + ER_LABEL_ENCODER_NOT_FITTED = + 'LabelEncoder не обучен. Сначала вызовите Fit или FitTransform.!!' + + 'LabelEncoder is not fitted. Call Fit or FitTransform first.'; + ER_LABEL_ENCODER_UNKNOWN_CLASS = + 'Неизвестная метка класса: {0}!!Unknown class label: {0}'; + ER_LABEL_ENCODER_INDEX_OUT_OF_RANGE = + 'Индекс класса вне диапазона: {0}!!Class index out of range: {0}'; C_DATASET = 'Датасет: {0}!!Dataset: {0}'; C_DESCRIPTION = 'Описание:!!Description:'; @@ -352,6 +388,7 @@ const C_URL = 'Ссылка: {0}!!URL: {0}'; C_CLASSES = 'Классов: {0}!!Classes: {0}'; C_FEATURE_LIST = 'Признаки:!!Features:'; + C_CATEGORICAL = 'Категориальные признаки: {0}!!Categorical features: {0}'; function Normal(rnd: System.Random): real; begin @@ -509,6 +546,9 @@ begin if (Target <> nil) and (Target <> '') then PrintlnTr(C_TARGET, Target); + if CategoricalFeatures.Length > 0 then + PrintlnTr(C_CATEGORICAL, CategoricalFeatures.JoinToString(', ')); + Println; var maxLen := Features.Max(f -> f.Length); @@ -604,12 +644,153 @@ begin Result := Data.Schema.ColumnNames; end; +function Dataset.GetCategoricalFeatures: array of string; +begin + Result := Features + .Where(f -> Data.IsCategorical(f)) + .ToArray; +end; + function Dataset.HasTarget: boolean; begin Result := (Target <> nil) and (Target <> ''); end; +//----------------------------- +// LabelEncoder +//----------------------------- + +function LabelEncoder.GetClasses: array of string; +begin + EnsureFitted; + Result := Copy(fClasses); +end; + +procedure LabelEncoder.EnsureFitted; +begin + if (fClasses = nil) or (fClassToIndex = nil) then + Error(ER_LABEL_ENCODER_NOT_FITTED); +end; + +procedure LabelEncoder.CheckDataset(ds: Dataset); +begin + if ds = nil then + ArgumentNullError(ER_ARG_NULL, 'ds'); + + if ds.Data = nil then + ArgumentNullError(ER_ARG_NULL, 'Data'); + + if ds.Task <> TaskType.Classification then + ArgumentError(ER_CLASSES_ONLY_CLASSIFICATION); + + if not ds.HasTarget then + ArgumentError(ER_DATASET_TARGET_MISSING); + + if not ds.Data.HasColumn(ds.Target) then + ArgumentError(ER_DATASET_TARGET_NOT_FOUND, ds.Target); +end; + +function LabelEncoder.TargetLabels(ds: Dataset): array of string; +begin + CheckDataset(ds); + + case ds.Data.GetColumnType(ds.Target) of + ColumnType.ctStr: + Result := ds.Data.GetStrColumn(ds.Target); + ColumnType.ctInt: + Result := ds.Data.GetIntColumn(ds.Target).Select(x -> x.ToString).ToArray; + else + ArgumentError(ER_ENCODELABELS_UNSUPPORTED_TYPE, ds.Target); + end; +end; + +function LabelEncoder.Fit(ds: Dataset): LabelEncoder; +begin + var labels := TargetLabels(ds); + + var classes := new List; + fClassToIndex := new Dictionary; + + foreach var labelName in labels do + if not fClassToIndex.ContainsKey(labelName) then + begin + fClassToIndex[labelName] := classes.Count; + classes.Add(labelName); + end; + + fClasses := classes.ToArray; + Result := self; +end; + +function LabelEncoder.Transform(ds: Dataset): Vector; +begin + EnsureFitted; + + var labels := TargetLabels(ds); + var y := new integer[labels.Length]; + + for var i := 0 to labels.Length - 1 do + begin + var labelName := labels[i]; + + if not fClassToIndex.ContainsKey(labelName) then + ArgumentError(ER_LABEL_ENCODER_UNKNOWN_CLASS, labelName); + + y[i] := fClassToIndex[labelName]; + end; + + Result := new Vector(y); +end; + +function LabelEncoder.FitTransform(ds: Dataset): Vector; +begin + Fit(ds); + Result := Transform(ds); +end; + +function LabelEncoder.ClassName(index: integer): string; +begin + EnsureFitted; + + if (index < 0) or (index >= fClasses.Length) then + ArgumentError(ER_LABEL_ENCODER_INDEX_OUT_OF_RANGE, index); + + Result := fClasses[index]; +end; + +function LabelEncoder.ClassIndex(name: string): integer; +begin + EnsureFitted; + + if not fClassToIndex.ContainsKey(name) then + ArgumentError(ER_LABEL_ENCODER_UNKNOWN_CLASS, name); + + Result := fClassToIndex[name]; +end; + +function LabelEncoder.Decode(y: Vector): array of string; +begin + EnsureFitted; + + if y = nil then + ArgumentNullError(ER_ARG_NULL, 'y'); + + Result := new string[y.Length]; + + for var i := 0 to y.Length - 1 do + begin + var value := y.Data[i]; + var index := Round(value); + + if (Abs(value - index) > 1e-12) or (index < 0) or (index >= fClasses.Length) then + ArgumentError(ER_LABEL_ENCODER_INDEX_OUT_OF_RANGE, index); + + Result[i] := fClasses[index]; + end; +end; + + //----------------------------- // Datasets //----------------------------- @@ -1174,6 +1355,18 @@ begin ArgumentError(ER_DATASET_TASK_MISSING, name); var df := DataFrame.FromCsv(csvPath); + var categoricalCols := new List; + + foreach var k in meta.Keys do + if k.StartsWith('feature.') and not k.EndsWith('.ru') and not k.EndsWith('.en') then + begin + var colName := k.Substring('feature.'.Length); + if (meta[k] = 'categorical') and df.HasColumn(colName) then + categoricalCols.Add(colName); + end; + + if categoricalCols.Count > 0 then + df := df.SetCategorical(categoricalCols.ToArray); var ds := new Dataset; @@ -1255,50 +1448,27 @@ end; static function Datasets.Iris: Dataset; begin - var ds := Load('Iris'); - - ds.Data := ds.Data.SetCategorical([ - 'species' - ]); - - Result := ds; + Result := Load('Iris'); end; static function Datasets.MoscowHousing: Dataset; begin - var ds := Load('moscow_housing'); - - ds.Data := ds.Data.SetCategorical([ - 'renovation' - ]); - - Result := ds; + Result := Load('moscow_housing'); end; static function Datasets.RussianCities: Dataset; begin - var ds := Load('russian_cities'); - - ds.Data := ds.Data.SetCategorical([ - 'region_name', - 'federal_district' - ]); - - Result := ds; + Result := Load('russian_cities'); end; static function Datasets.TitanicRu: Dataset; begin - var ds := Load('titanic_ru'); + Result := Load('titanic_ru'); +end; - ds.Data := ds.Data.SetCategorical([ - 'Выжил', - 'Класс', - 'Пол', - 'ПортПосадки' - ]); - - Result := ds; +static function Datasets.UsedCarsPrice: Dataset; +begin + Result := Load('used_cars_price'); end; {static function Datasets.StudentExam: Dataset; diff --git a/bin/Lib/MLModelsABC.pas b/bin/Lib/MLModelsABC.pas index d3ebc28e4..ef2c08654 100644 --- a/bin/Lib/MLModelsABC.pas +++ b/bin/Lib/MLModelsABC.pas @@ -1735,7 +1735,7 @@ type // В отличие от этого, DataPipeline.Build используется, // когда исходные данные представлены в виде DataFrame // и требуется выполнить препроцессинг таблицы - // (Imputer, OneHotEncoder, LabelEncoder и др.) + // (Imputer, OneHotEncoder, OrdinalEncoder и др.) // перед преобразованием данных в Matrix/Vector. var pipe1 := @@ -1932,6 +1932,9 @@ type /// Примечание: /// • вычисленные параметры НЕ копируются методом Clone function Fit(X: Matrix): IUnsupervisedTransformer; + + /// Последовательно выполняет Fit и Transform на одних и тех же данных. + function FitTransform(X: Matrix): Matrix; /// Применяет стандартизацию к данным. function Transform(X: Matrix): Matrix; @@ -1978,6 +1981,9 @@ type /// Примечание: /// • вычисленные параметры НЕ копируются методом Clone function Fit(X: Matrix): IUnsupervisedTransformer; + + /// Последовательно выполняет Fit и Transform на одних и тех же данных. + function FitTransform(X: Matrix): Matrix; /// Применяет линейное масштабирование признаков к диапазону [0, 1]. function Transform(X: Matrix): Matrix; @@ -2027,6 +2033,9 @@ type /// Примечание: /// • вычисленные параметры НЕ копируются методом Clone function Fit(X: Matrix): IUnsupervisedTransformer; + + /// Последовательно выполняет Fit и Transform на одних и тех же данных. + function FitTransform(X: Matrix): Matrix; /// Преобразует матрицу X в пространство главных компонент. /// Возвращает матрицу m × k. @@ -2067,6 +2076,9 @@ type /// Примечание: /// • выбранные признаки НЕ копируются методом Clone function Fit(X: Matrix): IUnsupervisedTransformer; + + /// Последовательно выполняет Fit и Transform на одних и тех же данных. + function FitTransform(X: Matrix): Matrix; /// Возвращает матрицу, содержащую только отобранные признаки. function Transform(X: Matrix): Matrix; @@ -2151,6 +2163,9 @@ type /// Примечание: /// • выбранные признаки НЕ копируются методом Clone function Fit(X: Matrix; y: Vector): ISupervisedTransformer; + + /// Последовательно выполняет Fit и Transform на одних и тех же данных. + function FitTransform(X: Matrix; y: Vector): Matrix; /// Возвращает матрицу, содержащую только выбранные признаки. function Transform(X: Matrix): Matrix; @@ -2198,6 +2213,9 @@ type /// • у трансформера отсутствует обученное состояние function Fit(X: Matrix): IUnsupervisedTransformer; + /// Последовательно выполняет Fit и Transform на одних и тех же данных. + function FitTransform(X: Matrix): Matrix; + /// Применяет нормализацию к матрице X. /// Каждая строка масштабируется так, чтобы ее норма соответствовала выбранному типу. /// Возвращает новую матрицу с нормализованными объектами. @@ -3416,12 +3434,40 @@ end; function LogisticRegression.PredictLabels(X: Matrix): array of integer; begin - var P := PredictProba(X); - - SetLength(Result, P.RowCount); - - for var i := 0 to P.RowCount - 1 do - Result[i] := P.RowArgMax(i); + if not fFitted then + NotFittedError(ER_FIT_NOT_CALLED); + + if X = nil then + ArgumentNullError(ER_X_NULL); + + if MLConfig.ValidateFiniteInputs then + CheckXForPredict(X); + + if X.ColCount <> fW.RowCount then + DimensionError(ER_FEATURE_COUNT_MISMATCH, X.ColCount, fW.RowCount); + + var m := X.RowCount; + var Z := X * fW; + + SetLength(Result, m); + + for var i := 0 to m - 1 do + begin + var best := 0; + var bestVal := Z[i,0] + fIntercept[0]; + + for var k := 1 to fClassCount - 1 do + begin + var score := Z[i,k] + fIntercept[k]; + if score > bestVal then + begin + bestVal := score; + best := k; + end; + end; + + Result[i] := best; + end; end; function LogisticRegression.ToString: string; @@ -4074,8 +4120,8 @@ begin var leftOrders, rightOrders: array of array of integer; SplitNodeOrders(nodeOrders, split.Feature, split.LeftCount, split.LeftOrderSize, leftOrders, rightOrders); - var leftArr := leftOrders[0]; - var rightArr := rightOrders[0]; + //var leftArr := leftOrders[0]; + //var rightArr := rightOrders[0]; var rightCount := n - split.LeftCount; if (split.LeftCount < fMinSamplesLeaf) or @@ -6105,11 +6151,8 @@ begin yPred[i] := fInitValue; foreach var tree in fEstimators do - begin - var delta := tree.Predict(X); for var i := 0 to n - 1 do - yPred[i] += fLearningRate * delta[i]; - end; + yPred[i] += fLearningRate * tree.PredictOne(X, i); Result := yPred; end; @@ -6912,12 +6955,8 @@ begin // --- накопление логитов foreach var trees in fEstimators do for var cls := 0 to classCount - 1 do - begin - var delta := trees[cls].Predict(X); - for var i := 0 to nSamples - 1 do - logits[i, cls] += fLearningRate * delta[i]; - end; + logits[i, cls] += fLearningRate * trees[cls].PredictOne(X, i); var probs := new Matrix(nSamples, classCount); @@ -8786,6 +8825,12 @@ begin Result := Self; end; +function StandardScaler.FitTransform(X: Matrix): Matrix; +begin + Fit(X); + Result := Transform(X); +end; + function StandardScaler.Transform(X: Matrix): Matrix; begin if not fFitted then @@ -8880,6 +8925,12 @@ begin Result := Self; end; +function MinMaxScaler.FitTransform(X: Matrix): Matrix; +begin + Fit(X); + Result := Transform(X); +end; + function MinMaxScaler.Transform(X: Matrix): Matrix; begin if not fFitted then @@ -8998,6 +9049,12 @@ begin Result := Self; end; +function PCATransformer.FitTransform(X: Matrix): Matrix; +begin + Fit(X); + Result := Transform(X); +end; + function PCATransformer.Transform(X: Matrix): Matrix; begin if not fFitted then @@ -9075,6 +9132,12 @@ begin Result := Self; end; +function VarianceThreshold.FitTransform(X: Matrix): Matrix; +begin + Fit(X); + Result := Transform(X); +end; + function VarianceThreshold.Transform(X: Matrix): Matrix; begin if not fFitted then @@ -9396,6 +9459,12 @@ begin Result := Self; end; +function SelectKBest.FitTransform(X: Matrix; y: Vector): Matrix; +begin + Fit(X, y); + Result := Transform(X); +end; + function SelectKBest.Transform(X: Matrix): Matrix; begin if not fFitted then @@ -9465,6 +9534,12 @@ begin Result := Self; end; +function Normalizer.FitTransform(X: Matrix): Matrix; +begin + Fit(X); + Result := Transform(X); +end; + function Normalizer.Transform(X: Matrix): Matrix; begin if not fFitted then diff --git a/bin/Lib/MLPipelineABC.pas b/bin/Lib/MLPipelineABC.pas index cc7c68435..a01201591 100644 --- a/bin/Lib/MLPipelineABC.pas +++ b/bin/Lib/MLPipelineABC.pas @@ -96,6 +96,18 @@ type features: array of string; params steps: array of IPipelineStep ): DataPipeline; + + /// Строит только preprocessing-часть supervised-конвейера без модели. + /// Модель затем можно присоединить методом WithModel + static function BuildPreprocessing( + task: TaskKind; + target: string; + features: array of string; + params steps: array of IPipelineStep + ): DataPipeline; + + /// Возвращает копию текущего preprocessing-конвейера с добавленной моделью. + function WithModel(model: ISupervisedModel): DataPipeline; /// Обучает конвейер на DataFrame. /// Семантика: @@ -185,6 +197,13 @@ type /// Строит unsupervised-конвейер из шагов обработки данных и модели. static function Build(features: array of string; params steps: array of IPipelineStep): UDataPipeline; + + /// Строит только preprocessing-часть unsupervised-конвейера без модели. + static function BuildPreprocessing(features: array of string; + params steps: array of IPipelineStep): UDataPipeline; + + /// Возвращает копию текущего preprocessing-конвейера с добавленной моделью. + function WithModel(model: IUnsupervisedModel): UDataPipeline; /// Обучает конвейер на DataFrame. function Fit(df: DataFrame): UDataPipeline; @@ -299,10 +318,13 @@ const 'Метки классов недоступны. Убедитесь, что конвейер обучен и задача — классификация!!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_ORDINALENCODER_TARGET_NOT_ALLOWED = + 'OrdinalEncoder нельзя применять к целевой переменной — кодирование выполняется внутри модели!!OrdinalEncoder 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_CLASSIFICATION_TARGET_MUST_BE_CATEGORICAL_STR_OR_INT = + 'Целевой столбец "{0}" должен быть категориальным строковым или целочисленным для задач классификации!!' + + 'Target column "{0}" must be a categorical string or integer column for classification tasks'; ER_REGRESSION_TARGET_MUST_BE_NUMERIC = 'Целевой столбец "{0}" должен быть числовым для задач регрессии!!Target column "{0}" must be numeric for regression tasks'; ER_PREPROCESSOR_ROWCOUNT_CHANGED = @@ -557,10 +579,10 @@ begin Result := Self; end; -class function DataPipeline.Build( +class function DataPipeline.BuildPreprocessing( task: TaskKind; target: string; - features: array of string; + features: array of string; params steps: array of IPipelineStep ): DataPipeline; begin @@ -579,6 +601,38 @@ begin if (steps = nil) or (Length(steps) = 0) then ArgumentError(ER_PIPELINE_NO_STEPS); + for var i := 0 to High(steps) do + begin + var step := steps[i]; + + if step = nil then + ArgumentError(ER_PIPELINE_STEP_NULL, i); + + if step is ISupervisedModel then + ArgumentError(ER_PIPELINE_INVALID_STEP_ORDER); + end; + + var p := new DataPipeline; + p.fTarget := target; + p.fFeatures := Copy(features); + p.fTask := task; + + for var i := 0 to High(steps) do + p.Add(steps[i]); + + Result := p; +end; + +class function DataPipeline.Build( + task: TaskKind; + target: string; + features: array of string; + params steps: array of IPipelineStep +): DataPipeline; +begin + if (steps = nil) or (Length(steps) = 0) then + ArgumentError(ER_PIPELINE_NO_STEPS); + var last := steps[High(steps)]; if last = nil then @@ -611,14 +665,32 @@ begin ArgumentError(ER_PIPELINE_INVALID_STEP_ORDER); end; - var p := new DataPipeline; - p.fTarget := target; - p.fFeatures := Copy(features); - p.fTask := task; + var prepSteps := new IPipelineStep[steps.Length - 1]; + for var i := 0 to High(prepSteps) do + prepSteps[i] := steps[i]; - for var i := 0 to High(steps) do - p.Add(steps[i]); + Result := + BuildPreprocessing(task, target, features, prepSteps) + .WithModel(last as ISupervisedModel); +end; +function DataPipeline.WithModel(model: ISupervisedModel): DataPipeline; +begin + if model = nil then + ArgumentError(ER_MODEL_NULL); + + var p := Clone as DataPipeline; + + if p = nil then + Error(ER_MODEL_CLONE_TYPE); + + if p.fFitted then + Error(ER_PIPELINE_MODIFY_AFTER_FIT); + + if p.fModel <> nil then + ArgumentError(ER_PIPELINE_MULTIPLE_MODELS); + + p.Add(model as IPipelineStep); Result := p; end; @@ -790,8 +862,13 @@ begin case fTask of tkClassification: - if not df.IsCategorical(fTarget) then - ArgumentError(ER_ENCODELABELS_NOT_CATEGORICAL, fTarget); + begin + if not df.IsCategorical(fTarget) then + ArgumentError(ER_ENCODELABELS_NOT_CATEGORICAL, fTarget); + var ct := df.GetColumnType(fTarget); + if not (ct in [ColumnType.ctStr, ColumnType.ctInt]) then + ArgumentError(ER_CLASSIFICATION_TARGET_MUST_BE_CATEGORICAL_STR_OR_INT, fTarget); + end; tkRegression: begin @@ -923,11 +1000,37 @@ begin Result := Self; end; -class function UDataPipeline.Build(features: array of string; +class function UDataPipeline.BuildPreprocessing(features: array of string; params steps: array of IPipelineStep): UDataPipeline; begin ValidateFeatureList(features); + if (steps = nil) or (Length(steps) = 0) then + ArgumentError(ER_PIPELINE_NO_STEPS); + + for var i := 0 to High(steps) do + begin + var step := steps[i]; + + if step = nil then + ArgumentError(ER_PIPELINE_STEP_NULL, i); + + if step is IUnsupervisedModel then + ArgumentError(ER_PIPELINE_INVALID_STEP_ORDER); + end; + + var p := new UDataPipeline; + p.fFeatures := Copy(features); + + for var i := 0 to High(steps) do + p.Add(steps[i]); + + Result := p; +end; + +class function UDataPipeline.Build(features: array of string; + params steps: array of IPipelineStep): UDataPipeline; +begin if (steps = nil) or (Length(steps) = 0) then ArgumentError(ER_PIPELINE_NO_STEPS); @@ -950,12 +1053,32 @@ begin ArgumentError(ER_PIPELINE_INVALID_STEP_ORDER); end; - var p := new UDataPipeline; - p.fFeatures := Copy(features); + var prepSteps := new IPipelineStep[steps.Length - 1]; + for var i := 0 to High(prepSteps) do + prepSteps[i] := steps[i]; - for var i := 0 to High(steps) do - p.Add(steps[i]); + Result := + BuildPreprocessing(features, prepSteps) + .WithModel(last as IUnsupervisedModel); +end; +function UDataPipeline.WithModel(model: IUnsupervisedModel): UDataPipeline; +begin + if model = nil then + ArgumentError(ER_MODEL_NULL); + + var p := Clone as UDataPipeline; + + if p = nil then + Error(ER_INVALID_MODEL_TYPE, 'UDataPipeline'); + + if p.fFitted then + Error(ER_PIPELINE_MODIFY_AFTER_FIT); + + if p.fModel <> nil then + ArgumentError(ER_PIPELINE_MULTIPLE_MODELS); + + p.Add(model as IPipelineStep); Result := p; end; diff --git a/bin/Lib/MetricsABC.pas b/bin/Lib/MetricsABC.pas index ce124e679..dae07dc11 100644 --- a/bin/Lib/MetricsABC.pas +++ b/bin/Lib/MetricsABC.pas @@ -244,6 +244,8 @@ type procedure EnsureBinary; + function GetMatrix: array[,] of integer; + function GetTPBinary: integer; function GetFPBinary: integer; function GetFNBinary: integer; @@ -292,6 +294,11 @@ type /// /// Используется для построения матрицы ошибок размера K × K, где K = ClassCount. property ClassCount: integer read fClassCount; + + /// Матрица ошибок. + /// + /// Matrix[i, j] — количество объектов класса i, которые модель предсказала как класс j. + property Matrix: array[,] of integer read GetMatrix; /// Число истинно положительных предсказаний для класса c. /// Здесь c — номер класса в матрице ошибок. @@ -2001,6 +2008,15 @@ begin end; end; +function ConfusionMatrix.GetMatrix: array[,] of integer; +begin + Result := new integer[fClassCount, fClassCount]; + + for var i := 0 to fClassCount - 1 do + for var j := 0 to fClassCount - 1 do + Result[i, j] := fMatrix[i, j]; +end; + function ConfusionMatrix.GetLabel(c: integer): integer; begin if (c < 0) or (c >= fClassCount) then diff --git a/bin/Lib/PlotML.pas b/bin/Lib/PlotML.pas index efc741ba0..76e31d038 100644 --- a/bin/Lib/PlotML.pas +++ b/bin/Lib/PlotML.pas @@ -22,7 +22,8 @@ uses System, System.Windows.Threading, System.Text, InteractiveDataDisplay.WPF, - LinearAlgebraML; + LinearAlgebraML, + MetricsABC; type ApplicationWPF = System.Windows.Application; @@ -31,6 +32,7 @@ type ChartWPF = InteractiveDataDisplay.WPF.Chart; LineGraphWPF = InteractiveDataDisplay.WPF.LineGraph; MarkerGraphWPF = InteractiveDataDisplay.WPF.CircleMarkerGraph; + BarGraphWPF = InteractiveDataDisplay.WPF.BarGraph; PlotWPF = InteractiveDataDisplay.WPF.Plot; BrushWPF = System.Windows.Media.SolidColorBrush; Colors = System.Windows.Media.Colors; @@ -42,6 +44,7 @@ const DefaultColor = default(ColorWPF); type MarkerType = (Circle, Box, Triangle, Diamond, Cross); + MatrixNormalization = (None, Rows, All); Palette = class public @@ -77,6 +80,7 @@ type parentGrid: GridWPF; row,col: integer; chart: ChartWPF; + visual: UIElement; palette: Palette; paletteIndex: integer := 0; @@ -85,6 +89,8 @@ type procedure EnsureChart; procedure SetTitle(s: string); + procedure SetXLabel(s: string); + procedure SetYLabel(s: string); public constructor Create(g: GridWPF; r,c: integer); @@ -96,6 +102,12 @@ type size: real := 6; marker: MarkerType := MarkerType.Circle); procedure Hist(x: array of real; bins: integer := 0; color: ColorWPF := DefaultColor; alpha: real := 0.7; legend: string := nil); + procedure Bar(y: array of real; color: ColorWPF := DefaultColor; + width: real := 0.8; alpha: real := 0.85; legend: string := nil); + procedure Bar(x, y: array of real; color: ColorWPF := DefaultColor; + width: real := 0.8; alpha: real := 0.85; legend: string := nil); + procedure Bar(labels: array of string; values: array of real; color: ColorWPF := DefaultColor; + width: real := 0.75; alpha: real := 0.85; legend: string := nil); procedure Heatmap(m: array[,] of real); // --- Vector overloads @@ -107,17 +119,29 @@ type size: real := 6; marker: MarkerType := MarkerType.Circle); procedure Hist(x: Vector; bins: integer := 0; color: ColorWPF := DefaultColor; alpha: real := 0.7; legend: string := nil); + procedure Bar(y: Vector; color: ColorWPF := DefaultColor; + width: real := 0.8; alpha: real := 0.85; legend: string := nil); + procedure Bar(x, y: Vector; color: ColorWPF := DefaultColor; + width: real := 0.8; alpha: real := 0.85; legend: string := nil); 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 ConfusionMatrix(m: array[,] of integer; labels: array of string := nil; + normalize: MatrixNormalization := MatrixNormalization.None); + procedure ConfusionMatrix(m: Matrix; labels: array of string := nil; + normalize: MatrixNormalization := MatrixNormalization.None); + procedure ConfusionMatrix(cm: MetricsABC.ConfusionMatrix; labels: array of string := nil; + normalize: MatrixNormalization := MatrixNormalization.None); procedure Text(s: string; x: real := 0.5; y: real := 0.5); procedure SetPalette(p: PaletteWPF); - procedure XLabel(s: string); - procedure YLabel(s: string); + procedure HideXAxis; + procedure HideYAxis; + procedure HideGrid; + procedure HideAxesAndGrid; procedure Limits(xmin, xmax, ymin, ymax: real); procedure XLim(xmin,xmax: real); @@ -126,6 +150,8 @@ type procedure Clear; property Title: string write SetTitle; + property XLabel: string write SetXLabel; + property YLabel: string write SetYLabel; end; Figure = class @@ -154,12 +180,21 @@ type color: ColorWPF; size: real; marker: MarkerType; legend: string); static procedure DrawHeatmap(chart: ChartWPF; m: array[,] of real; names: array of string := nil); + + static function CreateConfusionMatrixView(m: array[,] of real; labels: array of string; + title: string; xlabel: string; ylabel: string; percentValues: boolean := false): UIElement; 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 DrawBar(chart: ChartWPF; x, y: array of real; + color: ColorWPF; width: real; alpha: real; legend: string); + + static procedure DrawHorizontalBar(chart: ChartWPF; labels: array of string; values: array of real; + color: ColorWPF; width: real; alpha: real; legend: string); static procedure DrawSurface(chart: ChartWPF; labels: array of integer; nx, ny: integer; xmin, xmax, ymin, ymax: real; pal: Palette); @@ -167,6 +202,8 @@ type static function MakeGrid(xmin, xmax, ymin, ymax: real; nx, ny: integer): Matrix; static procedure SetTitle(s: string); + static procedure SetXLabel(s: string); + static procedure SetYLabel(s: string); public static procedure AddSeries(chart: ChartWPF; series: UIElement); @@ -181,14 +218,31 @@ type static procedure Hist(x: array of real; bins: integer := 0; color: ColorWPF := DefaultColor; alpha: real := 0.7; legend: string := nil); + + static procedure Bar(y: array of real; color: ColorWPF := DefaultColor; + width: real := 0.8; alpha: real := 0.85; legend: string := nil); + + static procedure Bar(x, y: array of real; color: ColorWPF := DefaultColor; + width: real := 0.8; alpha: real := 0.85; legend: string := nil); + + static procedure Bar(labels: array of string; values: array of real; color: ColorWPF := DefaultColor; + width: real := 0.75; alpha: real := 0.85; 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 PairPlot(X: array[,] of real; names: array of string := nil; maxPoints: integer := 2000); + static procedure PairPlot(X: array[,] of real; values: array of real; names: array of string := nil; bins: integer := 5; maxPoints: integer := 2000); + static procedure PairPlot(X: array[,] of real; labels: array of integer; names: array of string; maxPoints: integer := 2000); static procedure Heatmap(m: array[,] of real); static procedure Heatmap(m: array[,] of real; names: array of string); + static procedure ConfusionMatrix(m: array[,] of integer; labels: array of string := nil; + normalize: MatrixNormalization := MatrixNormalization.None); + static procedure ConfusionMatrix(m: Matrix; labels: array of string := nil; + normalize: MatrixNormalization := MatrixNormalization.None); + static procedure ConfusionMatrix(cm: MetricsABC.ConfusionMatrix; labels: array of string := nil; + normalize: MatrixNormalization := MatrixNormalization.None); static procedure Surface(labels: array of integer; nx, ny: integer; xmin, xmax, ymin, ymax: real; pal: PlotML.Palette := nil); @@ -213,8 +267,20 @@ type color: ColorWPF := DefaultColor; alpha: real := 0.7; legend: string := nil) := Hist(x.Data, bins, color, alpha, legend); - static procedure PairPlot(X: Matrix; labels: array of integer; names: array of string) - := PairPlot(X.Data, labels, names); + static procedure Bar(y: Vector; color: ColorWPF := DefaultColor; + width: real := 0.8; alpha: real := 0.85; legend: string := nil) + := Bar(y.Data, color, width, alpha, legend); + + static procedure Bar(x, y: Vector; color: ColorWPF := DefaultColor; + width: real := 0.8; alpha: real := 0.85; legend: string := nil) + := Bar(x.Data, y.Data, color, width, alpha, legend); + + static procedure PairPlot(X: Matrix; names: array of string := nil; maxPoints: integer := 2000) + := PairPlot(X.Data, names, maxPoints); + static procedure PairPlot(X: Matrix; values: array of real; names: array of string := nil; bins: integer := 5; maxPoints: integer := 2000) + := PairPlot(X.Data, values, names, bins, maxPoints); + static procedure PairPlot(X: Matrix; labels: array of integer; names: array of string; maxPoints: integer := 2000) + := PairPlot(X.Data, labels, names, maxPoints); static procedure Heatmap(m: Matrix) := Heatmap(m.Data); static procedure Heatmap(m: Matrix; names: array of string) := Heatmap(m.Data, names); @@ -226,11 +292,13 @@ type static procedure EnsureAxes(chart: ChartWPF); + static procedure HideXAxis; + static procedure HideYAxis; + static procedure HideGrid; + static procedure HideAxesAndGrid; static procedure Limits(xmin,xmax,ymin,ymax: real); static procedure XLim(xmin,xmax: real); static procedure YLim(ymin,ymax: real); - static procedure XLabel(s: string); - static procedure YLabel(s: string); static procedure SetLabels(title: string := ''; xlabel: string := ''; ylabel: string := ''); static procedure Clear; @@ -240,6 +308,8 @@ type static function DebugVisualTree: string; static property Title: string write SetTitle; + static property XLabel: string write SetXLabel; + static property YLabel: string write SetYLabel; end; HistogramPlot = class(PlotWPF) @@ -265,6 +335,17 @@ type property MaxCount: integer read fMaxCount; end; + HorizontalBarPlot = class(PlotWPF) + private + fElements: List := new List; + fDescription: string; + public + constructor Create; + procedure SetData(labels: array of string; values: array of real; + color: ColorWPF; width: real; alpha: real); + property Description: string read fDescription write fDescription; + end; + HeatmapPlot = class(PlotWPF) private fCells: List := new List; @@ -304,6 +385,9 @@ var paletteDict: Dictionary; currentPalette: Palette; rootPaletteIndex: integer := 0; + rootTitleBlock: TextBlock; + rootXLabelBlock: TextBlock; + rootYLabelBlock: TextBlock; static function Palettes.&Default: Palette := paletteDict['default']; static function Palettes.Pastel: Palette := paletteDict['pastel']; @@ -419,6 +503,11 @@ begin Result := x; end; +function WithAlpha(c: ColorWPF; alpha: real): ColorWPF; +begin + Result := Color.FromArgb(byte(Round(Clamp01(alpha) * 255)), c.R, c.G, c.B); +end; + function LerpColor(c1, c2: ColorWPF; t: real): ColorWPF; begin t := Clamp01(t); @@ -454,6 +543,104 @@ begin Result := LerpColor(blue, red, (v - minValue) / (maxValue - minValue)); end; +function ConfusionMatrixColor(v, maxValue: real): ColorWPF; +begin + if maxValue <= 0 then + exit(Color.FromRgb(247, 251, 255)); + + Result := LerpColor( + Color.FromRgb(247, 251, 255), + Color.FromRgb(8, 48, 107), + v / maxValue); +end; + +function IntMatrixToReal(m: array[,] of integer): array[,] of real; +begin + if m = nil then + exit(nil); + + var rows := m.GetLength(0); + var cols := m.GetLength(1); + Result := new real[rows, cols]; + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + Result[i, j] := m[i, j]; +end; + +function NormalizeConfusionMatrix(m: array[,] of real; mode: MatrixNormalization): array[,] of real; +begin + if m = nil then + exit(nil); + + var rows := m.GetLength(0); + var cols := m.GetLength(1); + + Result := new real[rows, cols]; + + case mode of + MatrixNormalization.None: + begin + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + Result[i, j] := m[i, j]; + end; + + MatrixNormalization.Rows: + begin + for var i := 0 to rows - 1 do + begin + var s := 0.0; + for var j := 0 to cols - 1 do + s += m[i, j]; + + if s <> 0 then + for var j := 0 to cols - 1 do + Result[i, j] := m[i, j] / s; + end; + end; + + MatrixNormalization.All: + begin + var s := 0.0; + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + s += m[i, j]; + + if s <> 0 then + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + Result[i, j] := m[i, j] / s; + end; + end; +end; + +function FindTextBlockByName(obj: DependencyObject; name: string): TextBlock; +begin + if obj = nil then + exit(nil); + + var tb := obj as TextBlock; + if (tb <> nil) and (tb.Name = name) then + exit(tb); + + var cnt := VisualTreeHelper.GetChildrenCount(obj); + for var i := 0 to cnt - 1 do + begin + Result := FindTextBlockByName(VisualTreeHelper.GetChild(obj, i), name); + if Result <> nil then + exit; + end; +end; + +procedure SetOptionalTextBlockText(tb: TextBlock; s: string); +begin + if tb = nil then + exit; + + tb.Text := s; + tb.Visibility := if (s = nil) or (s = '') then Visibility.Collapsed else Visibility.Visible; +end; + procedure DumpVisualNode(sb: StringBuilder; obj: DependencyObject; level: integer); begin if obj = nil then @@ -472,6 +659,209 @@ begin DumpVisualNode(sb, VisualTreeHelper.GetChild(obj, i), level + 1); end; +procedure HidePlotGridInVisualTree(obj: DependencyObject); +begin + if obj = nil then + exit; + + if obj is AxisGrid then + (obj as AxisGrid).Visibility := Visibility.Collapsed; + + var cnt := VisualTreeHelper.GetChildrenCount(obj); + for var i := 0 to cnt - 1 do + HidePlotGridInVisualTree(VisualTreeHelper.GetChild(obj, i)); +end; + +procedure HidePlotAxisInVisualTree(obj: DependencyObject; orientation: AxisOrientation); +begin + if obj = nil then + exit; + + if obj is PlotAxis then + begin + var axis := obj as PlotAxis; + if axis.AxisOrientation = orientation then + axis.Visibility := Visibility.Collapsed; + end; + + var cnt := VisualTreeHelper.GetChildrenCount(obj); + for var i := 0 to cnt - 1 do + HidePlotAxisInVisualTree(VisualTreeHelper.GetChild(obj, i), orientation); +end; + +procedure HidePlotAxesAndGridInVisualTree(obj: DependencyObject); +begin + if obj = nil then + exit; + + if obj is PlotAxis then + (obj as PlotAxis).Visibility := Visibility.Collapsed; + + if obj is AxisGrid then + (obj as AxisGrid).Visibility := Visibility.Collapsed; + + var cnt := VisualTreeHelper.GetChildrenCount(obj); + for var i := 0 to cnt - 1 do + HidePlotAxesAndGridInVisualTree(VisualTreeHelper.GetChild(obj, i)); +end; + +function FindD3FigureInVisualTree(obj: DependencyObject): InteractiveDataDisplay.WPF.Figure; +begin + if obj = nil then + exit(nil); + + if obj is InteractiveDataDisplay.WPF.Figure then + exit(obj as InteractiveDataDisplay.WPF.Figure); + + var cnt := VisualTreeHelper.GetChildrenCount(obj); + for var i := 0 to cnt - 1 do + begin + Result := FindD3FigureInVisualTree(VisualTreeHelper.GetChild(obj, i)); + if Result <> nil then + exit; + end; +end; + +function FigureSlotSize(fig: InteractiveDataDisplay.WPF.Figure; placement: InteractiveDataDisplay.WPF.Placement): real; +begin + Result := 0; + if fig = nil then + exit; + + foreach var child in fig.Children do + begin + var fe := child as FrameworkElement; + if (fe = nil) or (InteractiveDataDisplay.WPF.Figure.GetPlacement(fe) <> placement) then + continue; + + if (placement = InteractiveDataDisplay.WPF.Placement.Left) or + (placement = InteractiveDataDisplay.WPF.Placement.Right) then + Result += Max(fe.ActualWidth, fe.DesiredSize.Width) + else + Result += Max(fe.ActualHeight, fe.DesiredSize.Height); + end; +end; + +procedure AddFigureSlotSpacer(fig: InteractiveDataDisplay.WPF.Figure; placement: InteractiveDataDisplay.WPF.Placement; size: real); +begin + if (fig = nil) or (size <= 0.1) then + exit; + + var spacer := new Border; + spacer.Opacity := 0; + spacer.IsHitTestVisible := false; + + if (placement = InteractiveDataDisplay.WPF.Placement.Left) or + (placement = InteractiveDataDisplay.WPF.Placement.Right) then + spacer.Width := size + else + spacer.Height := size; + + InteractiveDataDisplay.WPF.Figure.SetPlacement(spacer, placement); + fig.Children.Insert(0, spacer); +end; + +procedure AddCategoryLabelsToLeftSlot(chart: ChartWPF; labels: array of string); +begin + if (chart = nil) or (labels = nil) or (labels.Length = 0) then + exit; + + chart.ApplyTemplate; + chart.UpdateLayout; + + var d3fig := FindD3FigureInVisualTree(chart); + if d3fig = nil then + exit; + + var labelGrid := new GridWPF; + labelGrid.Margin := new Thickness(0, 0, 8, 0); + labelGrid.MinWidth := 1; + + for var i := 0 to labels.Length - 1 do + labelGrid.RowDefinitions.Add(new RowDefinition); + + for var i := 0 to labels.Length - 1 do + begin + var tb := new TextBlock; + tb.Text := labels[i]; + tb.FontSize := 12; + tb.TextAlignment := TextAlignment.Right; + tb.HorizontalAlignment := System.Windows.HorizontalAlignment.Right; + tb.VerticalAlignment := System.Windows.VerticalAlignment.Center; + tb.TextTrimming := TextTrimming.CharacterEllipsis; + GridWPF.SetRow(tb, i); + labelGrid.Children.Add(tb); + end; + + InteractiveDataDisplay.WPF.Figure.SetPlacement(labelGrid, InteractiveDataDisplay.WPF.Placement.Left); + d3fig.Children.Insert(0, labelGrid); +end; + +procedure AlignPairPlotSlots(fig: Figure; outerOnly: boolean := false); +begin + if fig = nil then + exit; + + var rows := fig.cells.GetLength(0); + var cols := fig.cells.GetLength(1); + var d3figs := new InteractiveDataDisplay.WPF.Figure[rows, cols]; + + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + begin + var c := fig.cells[i, j]; + if (c = nil) or (c.chart = nil) then + continue; + + c.chart.ApplyTemplate; + c.chart.UpdateLayout; + + d3figs[i, j] := FindD3FigureInVisualTree(c.chart); + end; + + var maxLeft := 0.0; + var maxBottom := 0.0; + + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + begin + var d3fig := d3figs[i, j]; + if d3fig = nil then + continue; + + maxLeft := Max(maxLeft, FigureSlotSize(d3fig, InteractiveDataDisplay.WPF.Placement.Left)); + //maxBottom := Max(maxBottom, FigureSlotSize(d3fig, InteractiveDataDisplay.WPF.Placement.Bottom)); + end; + + maxLeft := Max(0, maxLeft - 16); + + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + begin + var d3fig := d3figs[i, j]; + if d3fig = nil then + continue; + + d3fig.InvalidateMeasure; + d3fig.UpdateLayout; + + if (not outerOnly) or (j = 0) then + AddFigureSlotSpacer( + d3fig, + InteractiveDataDisplay.WPF.Placement.Left, + maxLeft - FigureSlotSize(d3fig, InteractiveDataDisplay.WPF.Placement.Left)); + + if (not outerOnly) or (i = rows - 1) then + begin + AddFigureSlotSpacer( + d3fig, + InteractiveDataDisplay.WPF.Placement.Bottom, + maxBottom - FigureSlotSize(d3fig, InteractiveDataDisplay.WPF.Placement.Bottom)); + end; + end; + +end; + function MakeHistogram(data: array of real; bins: integer): (array of real, array of real); begin var xmin := data.Min; @@ -493,8 +883,6 @@ begin Result := (xs,counts); end; - - procedure InitUI; begin uiThread := new Thread(() -> @@ -574,24 +962,54 @@ procedure Cell.SetTitle(s: string); begin Plot.RunUI(() -> begin + if visual <> nil then + begin + var tb := FindTextBlockByName(visual, 'PlotML_Title'); + if tb <> nil then + begin + SetOptionalTextBlockText(tb, s); + exit; + end; + end; + EnsureChart; chart.Title := s; end); end; -procedure Cell.XLabel(s: string); +procedure Cell.SetXLabel(s: string); begin Plot.RunUI(() -> begin + if visual <> nil then + begin + var tb := FindTextBlockByName(visual, 'PlotML_XLabel'); + if tb <> nil then + begin + SetOptionalTextBlockText(tb, s); + exit; + end; + end; + EnsureChart; chart.BottomTitle := s; end); end; -procedure Cell.YLabel(s: string); +procedure Cell.SetYLabel(s: string); begin Plot.RunUI(() -> begin + if visual <> nil then + begin + var tb := FindTextBlockByName(visual, 'PlotML_YLabel'); + if tb <> nil then + begin + SetOptionalTextBlockText(tb, s); + exit; + end; + end; + EnsureChart; chart.LeftTitle := s; end); @@ -637,11 +1055,18 @@ procedure Cell.Clear; begin Plot.RunUI(() -> begin - if chart = nil then exit; + if chart <> nil then + begin + var container := chart.Content as GridWPF; + if container <> nil then + container.Children.Clear; + end; - var container := chart.Content as GridWPF; - if container <> nil then - container.Children.Clear; + if visual <> nil then + begin + parentGrid.Children.Remove(visual); + visual := nil; + end; paletteIndex := 0; end); @@ -651,6 +1076,12 @@ procedure Cell.EnsureChart; begin if chart <> nil then exit; + if visual <> nil then + begin + parentGrid.Children.Remove(visual); + visual := nil; + end; + chart := new ChartWPF; chart.Margin := new Thickness(2); chart.LegendVisibility := Visibility.Hidden; @@ -755,6 +1186,61 @@ begin end); end; +procedure Cell.ConfusionMatrix(m: array[,] of integer; labels: array of string; normalize: MatrixNormalization); +begin + Plot.RunUI(() -> + begin + if chart <> nil then + begin + parentGrid.Children.Remove(chart); + chart := nil; + end; + + if visual <> nil then + parentGrid.Children.Remove(visual); + + visual := Plot.CreateConfusionMatrixView( + NormalizeConfusionMatrix(IntMatrixToReal(m), normalize), + labels, '', '', '', normalize <> MatrixNormalization.None); + GridWPF.SetRow(visual, row); + GridWPF.SetColumn(visual, col); + parentGrid.Children.Add(visual); + end); +end; + +procedure Cell.ConfusionMatrix(m: Matrix; labels: array of string; normalize: MatrixNormalization); +begin + Plot.RunUI(() -> + begin + if chart <> nil then + begin + parentGrid.Children.Remove(chart); + chart := nil; + end; + + if visual <> nil then + parentGrid.Children.Remove(visual); + + visual := Plot.CreateConfusionMatrixView( + NormalizeConfusionMatrix(m.Data, normalize), + labels, '', '', '', normalize <> MatrixNormalization.None); + GridWPF.SetRow(visual, row); + GridWPF.SetColumn(visual, col); + parentGrid.Children.Add(visual); + end); +end; + +procedure Cell.ConfusionMatrix(cm: MetricsABC.ConfusionMatrix; labels: array of string; normalize: MatrixNormalization); +begin + if cm = nil then + raise new System.ArgumentNullException; + + if labels = nil then + labels := cm.Labels.Select(x -> x.ToString).ToArray; + + ConfusionMatrix(cm.Matrix, labels, normalize); +end; + procedure Cell.Hist(x: array of real; bins: integer; color: ColorWPF; alpha: real; legend: string); begin Plot.RunUI(() -> @@ -767,6 +1253,36 @@ begin end); end; +procedure Cell.Bar(y: array of real; color: ColorWPF; width: real; alpha: real; legend: string); +begin + var x: array of real := nil; + Bar(x, y, color, width, alpha, legend); +end; + +procedure Cell.Bar(x, y: array of real; color: ColorWPF; width: real; alpha: real; legend: string); +begin + Plot.RunUI(() -> + begin + EnsureChart; + + var clr := if color<>DefaultColor then color else NextColor; + + Plot.DrawBar(chart, x, y, clr, width, alpha, legend); + end); +end; + +procedure Cell.Bar(labels: array of string; values: array of real; color: ColorWPF; width: real; alpha: real; legend: string); +begin + Plot.RunUI(() -> + begin + EnsureChart; + + var clr := if color<>DefaultColor then color else NextColor; + + Plot.DrawHorizontalBar(chart, labels, values, clr, width, alpha, legend); + end); +end; + procedure Cell.LineGraph(x, y: Vector; color: ColorWPF; thickness: real; legend: string); begin LineGraph(x.Data, y.Data, color, thickness, legend); @@ -793,6 +1309,16 @@ begin Hist(x.Data, bins, color, alpha, legend); end; +procedure Cell.Bar(y: Vector; color: ColorWPF; width: real; alpha: real; legend: string); +begin + Bar(y.Data, color, width, alpha, legend); +end; + +procedure Cell.Bar(x, y: Vector; color: ColorWPF; width: real; alpha: real; legend: string); +begin + Bar(x.Data, y.Data, color, width, alpha, legend); +end; + procedure Cell.Text(s: string; x: real; y: real); begin Plot.RunUI(() -> @@ -803,6 +1329,50 @@ begin end); end; +procedure Cell.HideXAxis; +begin + Plot.RunUI(() -> + begin + EnsureChart; + chart.ApplyTemplate; + chart.UpdateLayout; + HidePlotAxisInVisualTree(chart, AxisOrientation.Bottom); + end); +end; + +procedure Cell.HideYAxis; +begin + Plot.RunUI(() -> + begin + EnsureChart; + chart.ApplyTemplate; + chart.UpdateLayout; + HidePlotAxisInVisualTree(chart, AxisOrientation.Left); + end); +end; + +procedure Cell.HideGrid; +begin + Plot.RunUI(() -> + begin + EnsureChart; + chart.ApplyTemplate; + chart.UpdateLayout; + HidePlotGridInVisualTree(chart); + end); +end; + +procedure Cell.HideAxesAndGrid; +begin + Plot.RunUI(() -> + begin + EnsureChart; + chart.ApplyTemplate; + chart.UpdateLayout; + HidePlotAxesAndGridInVisualTree(chart); + end); +end; + constructor Figure.Create(rows,cols: integer); begin grid := new GridWPF; @@ -858,12 +1428,58 @@ begin end); end; +static procedure Plot.HideXAxis; +begin + RunUI(() -> + begin + rootChart.ApplyTemplate; + rootChart.UpdateLayout; + HidePlotAxisInVisualTree(rootChart, AxisOrientation.Bottom); + end); +end; + +static procedure Plot.HideYAxis; +begin + RunUI(() -> + begin + rootChart.ApplyTemplate; + rootChart.UpdateLayout; + HidePlotAxisInVisualTree(rootChart, AxisOrientation.Left); + end); +end; + +static procedure Plot.HideGrid; +begin + RunUI(() -> + begin + rootChart.ApplyTemplate; + rootChart.UpdateLayout; + HidePlotGridInVisualTree(rootChart); + end); +end; + +static procedure Plot.HideAxesAndGrid; +begin + RunUI(() -> + begin + rootChart.ApplyTemplate; + rootChart.UpdateLayout; + HidePlotAxesAndGridInVisualTree(rootChart); + end); +end; + var gridMode := false; static procedure Plot.SetTitle(s: string); begin RunUI(() -> begin + if rootTitleBlock <> nil then + begin + SetOptionalTextBlockText(rootTitleBlock, s); + exit; + end; + if gridMode then win.Title := s else @@ -871,18 +1487,30 @@ begin end); end; -static procedure Plot.XLabel(s: string); +static procedure Plot.SetXLabel(s: string); begin RunUI(() -> begin + if rootXLabelBlock <> nil then + begin + SetOptionalTextBlockText(rootXLabelBlock, s); + exit; + end; + rootChart.BottomTitle := s; end); end; -static procedure Plot.YLabel(s: string); +static procedure Plot.SetYLabel(s: string); begin RunUI(() -> begin + if rootYLabelBlock <> nil then + begin + SetOptionalTextBlockText(rootYLabelBlock, s); + exit; + end; + rootChart.LeftTitle := s; end); end; @@ -892,13 +1520,13 @@ begin RunUI(() -> begin if title <> '' then - rootChart.Title := title; + SetTitle(title); if xlabel <> '' then - rootChart.BottomTitle := xlabel; + SetXLabel(xlabel); if ylabel <> '' then - rootChart.LeftTitle := ylabel; + SetYLabel(ylabel); end); end; @@ -1042,6 +1670,80 @@ begin AddSeries(chart, g); end; +static procedure Plot.DrawBar(chart: ChartWPF; x, y: array of real; + color: ColorWPF; width: real; alpha: real; legend: string); +begin + if y = nil then + raise new System.ArgumentNullException; + + if (x <> nil) and (x.Length <> y.Length) then + raise new System.ArgumentException('Bar: array sizes mismatch'); + + if width <= 0 then + width := 0.8; + + var g := new BarGraphWPF; + var fillColor := WithAlpha(color, alpha); + g.Color := new BrushWPF(fillColor); + g.Stroke := new BrushWPF(ColorWPF.FromArgb(180, color.R, color.G, color.B)); + g.StrokeThickness := 1; + g.BarsWidth := width; + + if legend <> nil then + begin + g.Description := legend; + chart.LegendVisibility := Visibility.Visible; + end; + + g.PlotBars(x, y); + + AddSeries(chart, g); +end; + +static procedure Plot.DrawHorizontalBar(chart: ChartWPF; labels: array of string; values: array of real; + color: ColorWPF; width: real; alpha: real; legend: string); +begin + if (labels = nil) or (values = nil) then + raise new System.ArgumentNullException; + + if labels.Length <> values.Length then + raise new System.ArgumentException('Bar: labels and values sizes mismatch'); + + if values.Length = 0 then + exit; + + if width <= 0 then + width := 0.75; + + if width > 1 then + width := 1; + + var g := new HorizontalBarPlot; + g.SetData(labels, values, color, width, alpha); + + if legend <> nil then + begin + g.Description := legend; + chart.LegendVisibility := Visibility.Visible; + end; + + AddSeries(chart, g); + + var xmax := Max(0.0, values.Max); + if xmax <= 0 then + xmax := 1; + + chart.PlotOriginX := 0; + chart.PlotWidth := xmax * 1.06; + chart.PlotOriginY := -0.5; + chart.PlotHeight := values.Length; + + chart.ApplyTemplate; + chart.UpdateLayout; + HidePlotAxisInVisualTree(chart, AxisOrientation.Left); + AddCategoryLabelsToLeftSlot(chart, labels); +end; + static procedure Plot.DrawHeatmap(chart: ChartWPF; m: array[,] of real; names: array of string); begin var rows := m.GetLength(0); @@ -1123,6 +1825,155 @@ begin end; end; +static function Plot.CreateConfusionMatrixView(m: array[,] of real; labels: array of string; + title: string; xlabel: string; ylabel: string; percentValues: boolean): UIElement; +begin + if m = nil then + raise new System.ArgumentNullException; + + var rows := m.GetLength(0); + var cols := m.GetLength(1); + + if rows <> cols then + raise new System.ArgumentException('ConfusionMatrix: matrix must be square'); + + if labels = nil then + begin + labels := new string[rows]; + for var i := 0 to rows - 1 do + labels[i] := i.ToString; + end; + + if labels.Length <> rows then + raise new System.ArgumentException('ConfusionMatrix: labels length must match matrix size'); + + var maxValue := 0.0; + for var i := 0 to rows - 1 do + for var j := 0 to cols - 1 do + maxValue := Max(maxValue, m[i, j]); + + var outer := new GridWPF; + outer.Margin := new Thickness(4); + + var outerTitleRow := new RowDefinition; + outerTitleRow.Height := GridLength.Auto; + outer.RowDefinitions.Add(outerTitleRow); + outer.RowDefinitions.Add(new RowDefinition); + var outerXLabelRow := new RowDefinition; + outerXLabelRow.Height := GridLength.Auto; + outer.RowDefinitions.Add(outerXLabelRow); + var outerYLabelColumn := new ColumnDefinition; + outerYLabelColumn.Width := GridLength.Auto; + outer.ColumnDefinitions.Add(outerYLabelColumn); + outer.ColumnDefinitions.Add(new ColumnDefinition); + + var titleBlock := new TextBlock; + titleBlock.Name := 'PlotML_Title'; + titleBlock.FontSize := 14; + titleBlock.FontWeight := FontWeights.SemiBold; + titleBlock.TextAlignment := TextAlignment.Center; + titleBlock.HorizontalAlignment := System.Windows.HorizontalAlignment.Center; + titleBlock.Margin := new Thickness(0, 0, 0, 4); + SetOptionalTextBlockText(titleBlock, title); + GridWPF.SetRow(titleBlock, 0); + GridWPF.SetColumn(titleBlock, 1); + outer.Children.Add(titleBlock); + + var yBlock := new TextBlock; + yBlock.Name := 'PlotML_YLabel'; + yBlock.FontSize := 13; + yBlock.VerticalAlignment := System.Windows.VerticalAlignment.Center; + yBlock.Margin := new Thickness(0, 0, 4, 0); + yBlock.LayoutTransform := new RotateTransform(-90); + SetOptionalTextBlockText(yBlock, ylabel); + GridWPF.SetRow(yBlock, 1); + GridWPF.SetColumn(yBlock, 0); + outer.Children.Add(yBlock); + + var xBlock := new TextBlock; + xBlock.Name := 'PlotML_XLabel'; + xBlock.FontSize := 13; + xBlock.HorizontalAlignment := System.Windows.HorizontalAlignment.Center; + xBlock.Margin := new Thickness(0, 4, 0, 0); + SetOptionalTextBlockText(xBlock, xlabel); + GridWPF.SetRow(xBlock, 2); + GridWPF.SetColumn(xBlock, 1); + outer.Children.Add(xBlock); + + var table := new GridWPF; + for var i := 0 to rows do + table.RowDefinitions.Add(new RowDefinition); + table.RowDefinitions[rows].Height := GridLength.Auto; + + var rowLabelColumn := new ColumnDefinition; + rowLabelColumn.Width := GridLength.Auto; + table.ColumnDefinitions.Add(rowLabelColumn); + for var j := 0 to cols - 1 do + table.ColumnDefinitions.Add(new ColumnDefinition); + + for var j := 0 to cols - 1 do + begin + var tb := new TextBlock; + tb.Text := labels[j]; + tb.FontSize := 12; + tb.TextAlignment := TextAlignment.Center; + tb.HorizontalAlignment := System.Windows.HorizontalAlignment.Center; + tb.Margin := new Thickness(6, 6, 6, 0); + GridWPF.SetRow(tb, rows); + GridWPF.SetColumn(tb, j + 1); + table.Children.Add(tb); + end; + + for var i := 0 to rows - 1 do + begin + var rowLabel := new TextBlock; + rowLabel.Text := labels[i]; + rowLabel.FontSize := 12; + rowLabel.TextAlignment := TextAlignment.Right; + rowLabel.HorizontalAlignment := System.Windows.HorizontalAlignment.Right; + rowLabel.VerticalAlignment := System.Windows.VerticalAlignment.Center; + rowLabel.Margin := new Thickness(0, 4, 8, 4); + GridWPF.SetRow(rowLabel, i); + GridWPF.SetColumn(rowLabel, 0); + table.Children.Add(rowLabel); + + for var j := 0 to cols - 1 do + begin + var v := m[i, j]; + var border := new Border; + border.Background := new SolidColorBrush(ConfusionMatrixColor(v, maxValue)); + border.BorderBrush := Brushes.White; + border.BorderThickness := new Thickness(0.5); + + var valueText := new TextBlock; + if percentValues then + valueText.Text := $'{v * 100:F1}%' + else if Abs(v - Round(v)) < 1e-9 then + valueText.Text := Round(v).ToString + else + valueText.Text := $'{v:F2}'; + valueText.FontSize := 12; + valueText.FontWeight := FontWeights.SemiBold; + valueText.TextAlignment := TextAlignment.Center; + valueText.HorizontalAlignment := System.Windows.HorizontalAlignment.Center; + valueText.VerticalAlignment := System.Windows.VerticalAlignment.Center; + valueText.Foreground := + if (maxValue > 0) and (v / maxValue > 0.5) then Brushes.White else Brushes.DimGray; + + border.Child := valueText; + GridWPF.SetRow(border, i); + GridWPF.SetColumn(border, j + 1); + table.Children.Add(border); + end; + end; + + GridWPF.SetRow(table, 1); + GridWPF.SetColumn(table, 1); + outer.Children.Add(table); + + Result := outer; +end; + class procedure Plot.AddSeries(chart: ChartWPF; series: UIElement); begin var container := chart.Content as GridWPF; @@ -1188,6 +2039,32 @@ begin end; end; +static procedure Plot.Bar(y: array of real; color: ColorWPF; width: real; alpha: real; legend: string); +begin + var x: array of real := nil; + Bar(x, y, color, width, alpha, legend); +end; + +static procedure Plot.Bar(x, y: array of real; color: ColorWPF; width: real; alpha: real; legend: string); +begin + RunUI(() -> + begin + var clr := if color<>DefaultColor then color else NextRootColor; + + DrawBar(rootChart, x, y, clr, width, alpha, legend); + end); +end; + +static procedure Plot.Bar(labels: array of string; values: array of real; color: ColorWPF; width: real; alpha: real; legend: string); +begin + RunUI(() -> + begin + var clr := if color<>DefaultColor then color else NextRootColor; + + DrawHorizontalBar(rootChart, labels, values, clr, width, alpha, legend); + end); +end; + static procedure Plot.Heatmap(m: array[,] of real); begin RunUI(() -> @@ -1204,6 +2081,47 @@ begin end); end; +static procedure Plot.ConfusionMatrix(m: array[,] of integer; labels: array of string; normalize: MatrixNormalization); +begin + RunUI(() -> + begin + var view := CreateConfusionMatrixView( + NormalizeConfusionMatrix(IntMatrixToReal(m), normalize), + labels, '', '', '', normalize <> MatrixNormalization.None); + win.Content := view; + rootTitleBlock := FindTextBlockByName(view, 'PlotML_Title'); + rootXLabelBlock := FindTextBlockByName(view, 'PlotML_XLabel'); + rootYLabelBlock := FindTextBlockByName(view, 'PlotML_YLabel'); + gridMode := false; + end); +end; + +static procedure Plot.ConfusionMatrix(m: Matrix; labels: array of string; normalize: MatrixNormalization); +begin + RunUI(() -> + begin + var view := CreateConfusionMatrixView( + NormalizeConfusionMatrix(m.Data, normalize), + labels, '', '', '', normalize <> MatrixNormalization.None); + win.Content := view; + rootTitleBlock := FindTextBlockByName(view, 'PlotML_Title'); + rootXLabelBlock := FindTextBlockByName(view, 'PlotML_XLabel'); + rootYLabelBlock := FindTextBlockByName(view, 'PlotML_YLabel'); + gridMode := false; + end); +end; + +static procedure Plot.ConfusionMatrix(cm: MetricsABC.ConfusionMatrix; labels: array of string; normalize: MatrixNormalization); +begin + if cm = nil then + raise new System.ArgumentNullException; + + if labels = nil then + labels := cm.Labels.Select(x -> x.ToString).ToArray; + + ConfusionMatrix(cm.Matrix, labels, normalize); +end; + // --- Vector overloads {static procedure Plot.LineGraph(x, y: Vector; @@ -1285,9 +2203,248 @@ begin Result := counts; end; -class procedure Plot.PairPlot(X: array[,] of real; labels: array of integer; names: array of string); +function PairPlotNames(names: array of string; n: integer): array of string; begin - var n := names.Length; + if names = nil then + begin + Result := new string[n]; + for var i := 0 to n - 1 do + Result[i] := $'x{i + 1}'; + exit; + end; + + if names.Length <> n then + raise new System.ArgumentException('PairPlot: names length must match column count'); + + Result := names; +end; + +function PairPlotValueBins(values: array of real; bins: integer): array of integer; +begin + if values = nil then + raise new System.ArgumentNullException; + + if bins < 2 then + bins := 2; + + Result := new integer[values.Length]; + + if values.Length = 0 then + exit; + + var vmin := values.Min; + var vmax := values.Max; + + if vmin = vmax then + exit; + + var w := (vmax - vmin) / bins; + if w <= 0 then + exit; + + for var i := 0 to values.Length - 1 do + begin + var k := trunc((values[i] - vmin) / w); + if k < 0 then + k := 0; + if k >= bins then + k := bins - 1; + Result[i] := k; + end; +end; + +function EvenlySpacedIndices(n, maxPoints: integer): array of integer; +begin + if (maxPoints <= 0) or (n <= maxPoints) then + begin + Result := ArrGen(n, i -> i); + exit; + end; + + Result := new integer[maxPoints]; + + if maxPoints = 1 then + begin + Result[0] := 0; + exit; + end; + + for var k := 0 to maxPoints - 1 do + Result[k] := Round(k * (n - 1.0) / (maxPoints - 1)); +end; + +function SubsampleMatrixRows(X: array[,] of real; indices: array of integer): array[,] of real; +begin + var rows := indices.Length; + var cols := X.GetLength(1); + Result := new real[rows, cols]; + + for var i := 0 to rows - 1 do + begin + var srcRow := indices[i]; + for var j := 0 to cols - 1 do + Result[i, j] := X[srcRow, j]; + end; +end; + +function SubsampleVector(values: array of real; indices: array of integer): array of real; +begin + Result := new real[indices.Length]; + for var i := 0 to indices.Length - 1 do + Result[i] := values[indices[i]]; +end; + +function SubsampleLabels(labels: array of integer; indices: array of integer): array of integer; +begin + Result := new integer[indices.Length]; + for var i := 0 to indices.Length - 1 do + Result[i] := labels[indices[i]]; +end; + +function PairPlotValueColor(binIndex, bins: integer): ColorWPF; +begin + var anchors := Arr( + Color.FromRgb(43, 92, 168), // холодный синий + Color.FromRgb(104, 162, 214), // голубой + Color.FromRgb(220, 220, 210), // нейтральный светлый + Color.FromRgb(236, 167, 84), // тёплый оранжевый + Color.FromRgb(191, 54, 54) // тёплый красный + ); + + if bins <= 1 then + exit(anchors[0]); + + var t := binIndex / (bins - 1.0); + var scaled := t * (anchors.Length - 1); + var k := trunc(scaled); + + if k >= anchors.Length - 1 then + exit(anchors[anchors.Length - 1]); + + Result := LerpColor(anchors[k], anchors[k + 1], scaled - k); +end; + + +class procedure Plot.PairPlot(X: array[,] of real; names: array of string; maxPoints: integer); +begin + var sampleIdx := EvenlySpacedIndices(X.GetLength(0), maxPoints); + X := SubsampleMatrixRows(X, sampleIdx); + + var noLabels: array of integer := nil; + PairPlot(X, noLabels, PairPlotNames(names, X.GetLength(1)), 0); +end; + +class procedure Plot.PairPlot(X: array[,] of real; values: array of real; names: array of string; bins: integer; maxPoints: integer); +begin + if values = nil then + raise new System.ArgumentNullException; + + var sampleIdx := EvenlySpacedIndices(X.GetLength(0), maxPoints); + X := SubsampleMatrixRows(X, sampleIdx); + values := SubsampleVector(values, sampleIdx); + + var n := X.GetLength(1); + names := PairPlotNames(names, n); + + if X.GetLength(0) <> values.Length then + raise new System.ArgumentException('PairPlot: values length must match row count'); + + if bins < 2 then + bins := 2; + + var valueBins := PairPlotValueBins(values, bins); + var fig := Plot.Grid(n, n); + + var xmin := new real[n]; + var xmax := new real[n]; + var ymax := new real[n]; + var histBins := 20; + + for var j := 0 to n - 1 do + begin + var col := X.Col(j); + xmin[j] := Floor(col.Min); + xmax[j] := Ceil(col.Max); + var counts := HistogramCounts(col, histBins, xmin[j], xmax[j]); + ymax[j] := counts.Max * 1.1; + end; + + for var i := 0 to n - 1 do + for var j := 0 to n - 1 do + begin + var ax := fig[i, j]; + + if i = j then + begin + var xs := X.Col(i); + var ys := xs; + + for var b := 0 to bins - 1 do + begin + var ind := valueBins.Indices(v -> v = b).ToArray; + if ind.Length = 0 then + continue; + + var xb := ind.ConvertAll(k -> xs[k]); + var yb := ind.ConvertAll(k -> ys[k]); + ax.Points(xb, yb, PairPlotValueColor(b, bins), size := 3); + end; + + ax.XLim(xmin[j], xmax[j]); + ax.YLim(xmin[i], xmax[i]); + end + else + begin + var xs := X.Col(j); + var ys := X.Col(i); + + for var b := 0 to bins - 1 do + begin + var ind := valueBins.Indices(v -> v = b).ToArray; + if ind.Length = 0 then + continue; + + var xb := ind.ConvertAll(k -> xs[k]); + var yb := ind.ConvertAll(k -> ys[k]); + ax.Points(xb, yb, PairPlotValueColor(b, bins), size := 3); + end; + + ax.XLim(xmin[j], xmax[j]); + ax.YLim(xmin[i], xmax[i]); + end; + + if i = n - 1 then + ax.XLabel := names[j]; + //else ax.HideXAxis; + + if j = 0 then + ax.YLabel := names[i]; + // else ax.HideYAxis; + + end; + + + RunUI(() -> + begin + //fig[1,0].chart.Margin := new Thickness(8, 0, 2, 0); + //fig[2,0].chart.Margin := new Thickness(14, 0, 2, 0); + //fig[3,0].chart.Margin := new Thickness(17, 0, 2, 0); + AlignPairPlotSlots(fig); + end); + +end; + +class procedure Plot.PairPlot(X: array[,] of real; labels: array of integer; names: array of string; maxPoints: integer); +begin + if labels <> nil then + begin + var sampleIdx := EvenlySpacedIndices(X.GetLength(0), maxPoints); + X := SubsampleMatrixRows(X, sampleIdx); + labels := SubsampleLabels(labels, sampleIdx); + end; + + var n := X.GetLength(1); + names := PairPlotNames(names, n); var bins := 20; var fig := Plot.Grid(n,n); @@ -1334,13 +2491,18 @@ begin ax.YLim(xmin[i],xmax[i]); end; - // подписи только по краям - if i=n-1 then - ax.XLabel(names[j]); - - if j=0 then - ax.YLabel(names[i]); + if i = n - 1 then + ax.XLabel := names[j]; + + if j = 0 then + ax.YLabel := names[i]; end; + + RunUI(() -> + begin + AlignPairPlotSlots(fig); + //WidenFirstPairPlotColumn(fig, 10); + end); end; class procedure Plot.EnsureAxes(chart: ChartWPF); @@ -1560,6 +2722,63 @@ begin fMaxCount := counts.Max; end; +constructor HorizontalBarPlot.Create; +begin + IsAutoFitEnabled := false; +end; + +procedure HorizontalBarPlot.SetData(labels: array of string; values: array of real; + color: ColorWPF; width: real; alpha: real); +begin + Children.Clear; + fElements.Clear; + + if (labels = nil) or (values = nil) then + exit; + + if labels.Length <> values.Length then + exit; + + if values.Length = 0 then + exit; + + var xmax := Max(0.0, values.Max); + if xmax <= 0 then + xmax := 1; + + var barLeft := 0.0; + + var fill := new SolidColorBrush(WithAlpha(color, alpha)); + var stroke := new SolidColorBrush(ColorWPF.FromArgb(180, color.R, color.G, color.B)); + var halfWidth := width / 2; + var n := values.Length; + + for var i := 0 to n - 1 do + begin + var y := n - 1 - i; + var v := Max(0.0, values[i]); + var x0 := barLeft; + var x1 := barLeft + v; + + var poly := new Polygon; + var pts := new PointCollection; + + pts.Add(new Point(x0, y - halfWidth)); + pts.Add(new Point(x0, y + halfWidth)); + pts.Add(new Point(x1, y + halfWidth)); + pts.Add(new Point(x1, y - halfWidth)); + + PlotWPF.SetPoints(poly, pts); + poly.Fill := fill; + poly.Stroke := stroke; + poly.StrokeThickness := 0.7; + + Children.Add(poly); + fElements.Add(poly); + + end; +end; + constructor HeatmapPlot.Create; begin IsAutoFitEnabled := false; diff --git a/bin/Lib/PreprocessorABC.pas b/bin/Lib/PreprocessorABC.pas index 8c081f061..5a5b633a5 100644 --- a/bin/Lib/PreprocessorABC.pas +++ b/bin/Lib/PreprocessorABC.pas @@ -445,6 +445,8 @@ begin var srcIdx := df.Schema.IndexOf(col); if srcIdx < 0 then ArgumentError(ER_COLUMN_NOT_FOUND, col); + if df.Schema.ColumnTypeAt(srcIdx) <> ColumnType.ctStr then + Error(ER_ONEHOT_NOT_STRING, col); var rowCount := df.RowCount; var catCount := categories.Length;