// Copyright (c) Ivan Bondarev, Stanislav Mikhalkovich (for details please see \doc\copyright.txt) // This code is distributed under the GNU LGPL (for details please see \doc\license.txt) // PreprocessorABC v.1.0 { DataFrameABC ↓ PreprocessorABC ├─ IPreprocessor ├─ Scalers / Encoders / Imputer └─ Pipeline ↓ MLCore } /// PreprocessorABC — модуль подготовки табличных данных для анализа данных и машинного обучения. /// Содержит типовые преобразования признаков с семантикой Fit / Transform. /// Работает совместно с DataFrameABC и не содержит моделей машинного обучения. unit PreprocessorABC; interface uses DataFrameABC; uses System; uses MLCoreABC; type /// Базовый интерфейс шагов подготовки данных. /// Определяет семантику операций Fit и Transform IPreprocessor = interface(IDataStep) /// Анализирует DataFrame и и сохраняет параметры шага function Fit(df: DataFrame): IPreprocessor; /// Применяет сохранённые параметры к DataFrame. /// Возвращает новый DataFrame function Transform(df: DataFrame): DataFrame; /// Выполняет Fit и Transform последовательно function FitTransform(df: DataFrame): DataFrame; function Clone: IPreprocessor; end; /// Кодирует строковый категориальный столбец в целочисленные индексы (0,1,2,...). /// Соответствие значений и индексов фиксируется при вызове Fit /// в порядке первого появления категорий. /// Работает только со строковыми столбцами и предназначен для признаков. /// Не должен применяться к целевому столбцу (target). LabelEncoder = class(IPreprocessor, IColumnBoundStep) private col: string; mapping: Dictionary; fitted: boolean; public /// Создаёт LabelEncoder для указанного столбца constructor Create(column: string); /// Определяет множество категорий и сохраняет их коды function Fit(df: DataFrame): IPreprocessor; /// Заменяет категории их числовыми кодами /// Возвращает новый DataFrame function Transform(df: DataFrame): DataFrame; /// Выполняет Fit и Transform последовательно function FitTransform(df: DataFrame): DataFrame; function ToString: string; override; property ColumnName: string read col; function Clone: IPreprocessor; end; /// Кодирует строковый категориальный столбец в набор бинарных (one-hot) столбцов /// Категории фиксируются при Fit /// Неизвестные категории кодируются нулями /// Пропущенные значения (NA) кодируются нулями OneHotEncoder = class(IPreprocessor, IColumnBoundStep) private col: string; categories: array of string; indexByValue: Dictionary; fitted: boolean; public /// Создаёт OneHotEncoder для указанного столбца constructor Create(column: string); /// Определяет множество категорий столбца function Fit(df: DataFrame): IPreprocessor; /// Заменяет столбец набором бинарных столбцов /// Возвращает новый DataFrame function Transform(df: DataFrame): DataFrame; /// Выполняет Fit и Transform последовательно function FitTransform(df: DataFrame): DataFrame; function ToString: string; override; property ColumnName: string read col; function Clone: IPreprocessor; end; ImputeStrategy = (isMean, isConstant, isMedian); /// Заполняет пропущенные значения (NA) в числовых столбцах /// Поддерживает стратегии isMean и isConstant /// Работает только с Int и Float столбцами Imputer = class(IPreprocessor, IColumnsBoundStep) private cols: array of string; strategy: ImputeStrategy; constants: array of object; means: array of real; medians: array of real; fitted: boolean; public /// Создаёт Imputer с заполнением средним значением constructor Create(params columns: array of string); /// Создаёт Imputer с заданной стратегией заполнения constructor Create(strategy: ImputeStrategy; params columns: array of string); /// Создаёт Imputer с константной стратегией заполнения constructor Create(value: object; params columns: array of string); /// Вычисляет значения для заполнения пропусков function Fit(df: DataFrame): IPreprocessor; /// Заполняет пропущенные значения в DataFrame /// Возвращает новый DataFrame function Transform(df: DataFrame): DataFrame; /// Выполняет Fit и Transform последовательно function FitTransform(df: DataFrame): DataFrame; function ToString: string; override; property Columns: array of string read cols; function Clone: IPreprocessor; end; implementation uses MLExceptions; const ER_SCALER_NO_COLUMNS = 'StandardScaler: столбцы не указаны!!StandardScaler: columns not specified'; ER_SCALER_COLUMN_NOT_NUMERIC = 'StandardScaler: столбец "{0}" не является числовым!!StandardScaler: column "{0}" is not numeric'; ER_SCALER_NO_VALID_VALUES = 'StandardScaler: столбец "{0}" не содержит допустимых значений!!' + 'StandardScaler: column "{0}" has no valid values'; ER_SCALER_ZERO_VARIANCE = 'StandardScaler: нулевая дисперсия в столбце "{0}"!!' + 'StandardScaler: zero variance in column "{0}"'; ER_MINMAX_NO_COLUMNS = 'MinMaxScaler: столбцы не указаны!!MinMaxScaler: columns not specified'; ER_MINMAX_COLUMN_NOT_NUMERIC = 'MinMaxScaler: столбец "{0}" не является числовым!!MinMaxScaler: column "{0}" is not numeric'; ER_MINMAX_NO_VALID_VALUES = 'MinMaxScaler: столбец "{0}" не содержит допустимых значений!!' + 'MinMaxScaler: column "{0}" has no valid values'; ER_MINMAX_CONSTANT_COLUMN = 'MinMaxScaler: постоянный столбец "{0}"!!MinMaxScaler: constant column "{0}"'; ER_LABELENCODER_NO_COLUMN = 'LabelEncoder: столбец не указан!!LabelEncoder: column not specified'; ER_LABELENCODER_NOT_STRING = 'LabelEncoder: столбец "{0}" не является строковым!!' + 'LabelEncoder: column "{0}" is not string'; ER_LABELENCODER_NA = 'LabelEncoder: NA значение не допускается!!LabelEncoder: NA value not allowed'; ER_LABELENCODER_UNSEEN_CATEGORY = 'LabelEncoder: неизвестная категория "{0}"!!' + 'LabelEncoder: unseen category "{0}"'; ER_ONEHOT_NO_COLUMN = 'OneHotEncoder: столбец не указан!!OneHotEncoder: column not specified'; ER_ONEHOT_NOT_STRING = 'OneHotEncoder: столбец "{0}" не является строковым или не содержит допустимых значений!!' + 'OneHotEncoder: column "{0}" is not string or has no valid values'; ER_ONEHOT_UNSEEN_CATEGORY = 'OneHotEncoder: неизвестная категория "{0}"!!' + 'OneHotEncoder: unseen category "{0}"'; ER_IMPUTER_INVALID_STRATEGY_MEAN = 'Imputer: данный конструктор предназначен для стратегии isMean!!' + 'Imputer: this constructor is for isMean'; ER_IMPUTER_INVALID_STRATEGY_CONSTANT = 'Imputer: данный конструктор предназначен для стратегии isConstant!!' + 'Imputer: this constructor is for isConstant'; ER_IMPUTER_NO_COLUMNS = 'Imputer: столбцы не указаны!!Imputer: columns not specified'; ER_IMPUTER_COLUMN_NOT_NUMERIC = 'Imputer: столбец "{0}" не является числовым!!' + 'Imputer: column "{0}" is not numeric'; ER_IMPUTER_NO_VALID_VALUES = 'Imputer(mean): столбец "{0}" не содержит допустимых значений!!' + 'Imputer(mean): column "{0}" has no valid values'; ER_IMPUTER_CONSTANT_VALUE_NULL = 'Imputer(constant): значение nil для столбца "{0}"!!' + 'Imputer(constant): value is nil for column "{0}"'; ER_IMPUTER_CONSTANT_TYPE_MISMATCH = 'Imputer(constant): несоответствие типа значения для столбца "{0}"!!' + 'Imputer(constant): value type mismatch for column "{0}"'; ER_ONEHOT_EMPTY_COLUMN = 'Столбец "{0}" не содержит категориальных значений!!Column "{0}" contains no categorical values'; ER_IMPUTER_CONSTANTS_INVALID = 'Массив констант не задан или имеет неверный размер!!Constants array is null or has invalid length'; ER_IMPUTER_STRATEGY_NOT_SUPPORTED = 'Стратегия импутации {0} не поддерживается!!Imputation strategy {0} is not supported'; ER_UNSUPPORTED_IMPUTE_STRATEGY = 'Неподдерживаемая стратегия заполнения: {0}!!Unsupported impute strategy: {0}'; //----------------------------- // LabelEncoder //----------------------------- constructor LabelEncoder.Create(column: string); begin if column = '' then ArgumentError(ER_LABELENCODER_NO_COLUMN); col := column; fitted := false; end; function LabelEncoder.Fit(df: DataFrame): IPreprocessor; begin if df = nil then ArgumentNullError(ER_ARG_NULL, 'df'); if not df.HasColumn(col) then ArgumentError(ER_COLUMN_NOT_FOUND, col); var idx := df.Schema.IndexOf(col); if df.Schema.ColumnTypeAt(idx) <> ColumnType.ctStr then Error(ER_LABELENCODER_NOT_STRING, col); mapping := new Dictionary; var cur := df.GetCursor; var nextId := 0; while cur.MoveNext do begin if not cur.IsValid(idx) then continue; var s := cur.Str(idx); if not mapping.ContainsKey(s) then begin mapping[s] := nextId; nextId += 1; end; end; fitted := true; Result := Self; end; function LabelEncoder.Transform(df: DataFrame): DataFrame; begin if not fitted then NotFittedError(ER_FIT_NOT_CALLED); var idx := df.Schema.IndexOf(col); var n := df.RowCount; var data := new integer[n]; var valid: array of boolean := nil; var cur := df.GetCursor; var row := 0; while cur.MoveNext do begin if not cur.IsValid(idx) then begin if valid = nil then begin valid := new boolean[n]; for var j := 0 to row - 1 do valid[j] := true; end; valid[row] := false; data[row] := 0; end else begin var s := cur.Str(idx); if not mapping.ContainsKey(s) then Error(ER_LABELENCODER_UNSEEN_CATEGORY, s); data[row] := mapping[s]; if valid <> nil then valid[row] := true; end; row += 1; end; var res := new DataFrame; foreach var src in df.GetColumns do if src.Info.Name <> col then res.AddColumnAlias(src) else res.AddIntColumn(col, data, valid); Result := res.SetCategorical([col]); end; function LabelEncoder.FitTransform(df: DataFrame): DataFrame; begin Fit(df); Result := Transform(df); end; function LabelEncoder.ToString: string; begin Result := 'LabelEncoder(' + col + ')'; end; function LabelEncoder.Clone: IPreprocessor; begin Result := new LabelEncoder(col); end; //----------------------------- // OneHotEncoder //----------------------------- constructor OneHotEncoder.Create(column: string); begin if column = '' then ArgumentError(ER_ONEHOT_NO_COLUMN); col := column; fitted := false; end; function OneHotEncoder.Fit(df: DataFrame): IPreprocessor; begin if df = nil then ArgumentNullError(ER_ARG_NULL, 'df'); var idx := df.Schema.IndexOf(col); if idx < 0 then Error(ER_COLUMN_NOT_FOUND, col); if df.Schema.ColumnTypeAt(idx) <> ColumnType.ctStr then Error(ER_ONEHOT_NOT_STRING, col); indexByValue := new Dictionary; var values := new List; var cur := df.GetCursor; while cur.MoveNext do begin if not cur.IsValid(idx) then continue; var s := cur.Str(idx); var dummy: integer; if not indexByValue.TryGetValue(s, dummy) then begin indexByValue[s] := values.Count; values.Add(s); end; end; if values.Count = 0 then Error(ER_ONEHOT_EMPTY_COLUMN, col); categories := values.ToArray; fitted := true; Result := Self; end; function OneHotEncoder.Transform(df: DataFrame): DataFrame; begin if not fitted then NotFittedError(ER_FIT_NOT_CALLED); var srcIdx := df.Schema.IndexOf(col); if srcIdx < 0 then ArgumentError(ER_COLUMN_NOT_FOUND, col); var catCount := categories.Length; var res := df; // === Генерация one-hot столбцов === 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); end; // === удаление исходного столбца === res := res.Drop([srcIdx]); Result := res; end; function OneHotEncoder.FitTransform(df: DataFrame): DataFrame; begin Fit(df); Result := Transform(df); end; function OneHotEncoder.ToString: string; begin Result := 'OneHotEncoder(column=' + col + ')'; end; function OneHotEncoder.Clone: IPreprocessor; begin Result := new OneHotEncoder(col); end; //----------------------------- // Imputer //----------------------------- constructor Imputer.Create(strategy: ImputeStrategy; params columns: array of string); begin if (columns = nil) or (columns.Length = 0) then ArgumentError(ER_IMPUTER_NO_COLUMNS); self.strategy := strategy; self.cols := columns; self.constants := nil; fitted := false; end; constructor Imputer.Create(params columns: array of string); begin Create(ImputeStrategy.isMean, columns); end; constructor Imputer.Create(value: object; params columns: array of string); begin if (columns = nil) or (columns.Length = 0) then ArgumentError(ER_IMPUTER_NO_COLUMNS); self.strategy := ImputeStrategy.isConstant; self.cols := columns; // одна и та же константа для всех столбцов self.constants := new object[columns.Length]; for var i := 0 to columns.Length - 1 do self.constants[i] := value; fitted := false; end; function Imputer.Fit(df: DataFrame): IPreprocessor; begin case strategy of isMean: begin SetLength(means, cols.Length); for var i := 0 to cols.Length - 1 do begin var name := cols[i]; var idx := df.Schema.IndexOf(name); var ct := df.Schema.ColumnTypeAt(idx); if not (ct in [ColumnType.ctInt, ColumnType.ctFloat]) then Error(ER_IMPUTER_COLUMN_NOT_NUMERIC, name); var sum := 0.0; var cnt := 0; var cur := df.GetCursor; while cur.MoveNext do if cur.IsValid(idx) then begin sum += cur.Float(idx); cnt += 1; end; if cnt = 0 then Error(ER_IMPUTER_NO_VALID_VALUES, name); means[i] := sum / cnt; end; end; isConstant: begin // ничего делать не нужно end; isMedian: begin SetLength(medians, cols.Length); for var i := 0 to cols.Length - 1 do begin var name := cols[i]; var idx := df.Schema.IndexOf(name); var ct := df.Schema.ColumnTypeAt(idx); if not (ct in [ColumnType.ctInt, ColumnType.ctFloat]) then Error(ER_IMPUTER_COLUMN_NOT_NUMERIC, name); // --- собираем значения var values := new List; var cur := df.GetCursor; while cur.MoveNext do if cur.IsValid(idx) then values.Add(cur.Float(idx)); if values.Count = 0 then Error(ER_IMPUTER_NO_VALID_VALUES, name); // --- сортируем values.Sort; // --- медиана var n := values.Count; if n mod 2 = 1 then medians[i] := values[n div 2] else medians[i] := (values[n div 2 - 1] + values[n div 2]) / 2.0; end; end; end; fitted := true; Result := Self; end; function Imputer.Transform(df: DataFrame): DataFrame; begin if not fitted then NotFittedError(ER_FIT_NOT_CALLED); // --- проверка constants if (strategy = isConstant) and ((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 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); case strategy of isMean: begin var m := means[i]; res := res.ReplaceColumnFloat( name, c -> (if c.IsValid(idx) then c.Float(idx) 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(idx) then c.Int(idx) 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(idx) then c.Float(idx) else r) ); end; end; isMedian: begin var m := medians[i]; res := res.ReplaceColumnFloat( name, c -> (if c.IsValid(idx) then c.Float(idx) else m) ); end; else Error(ER_IMPUTER_STRATEGY_NOT_SUPPORTED, strategy); end; end; Result := res; end; function Imputer.FitTransform(df: DataFrame): DataFrame; begin Fit(df); Result := Transform(df); end; function Imputer.ToString: string; begin var colsStr := '[' + cols.JoinToString(', ') + ']'; case strategy of isMean: Result := 'Imputer(strategy=mean, columns=' + colsStr + ')'; isMedian: Result := 'Imputer(strategy=median, columns=' + colsStr + ')'; isConstant: begin var valStr := if (constants <> nil) and (constants.Length > 0) and (constants[0] <> nil) then constants[0].ToString else 'null'; Result := 'Imputer(strategy=constant, value=' + valStr + ', columns=' + colsStr + ')'; end; else Result := 'Imputer(strategy=unknown, columns=' + colsStr + ')'; end; end; function Imputer.Clone: IPreprocessor; begin case strategy of ImputeStrategy.isMean: Result := new Imputer(cols); ImputeStrategy.isMedian: Result := new Imputer(ImputeStrategy.isMedian, cols); ImputeStrategy.isConstant: begin var val := if (constants <> nil) and (constants.Length > 0) then constants[0] else nil; Result := new Imputer(val, cols); end; else Error(ER_UNSUPPORTED_IMPUTE_STRATEGY, strategy); end; end; end.