classdef OneClassSVM < anomaly.AnomalyDetector
%OneClassSVM One-class Support Vector Machine.
%   OneClassSVM is a model for anomaly detection.
%
%   An object of this class cannot be created by calling the constructor.
%   Use OCSVM to create a OneClassSVM object by fitting the model to the
%   data.
%
%   OneClassSVM properties:
%       CategoricalPredictors       - Indices of categorical predictors.
%       ContaminationFraction       - Fraction of anomalies in the training data.
%       KernelScale                 - Kernel scale.
%       Lambda                      - Regularization strength.
%       Mu                          - Predictor means.
%       NumExpansionDimensions      - Number of dimensions of expanded space.
%       ObjectiveValue              - Value of the objective function.
%       PredictorNames              - Names of predictors used for this model.
%       ScoreThreshold              - Threshold on anomaly score used to identify anomalies.
%       Sigma                       - Predictor standard deviations.
%
%   OneClassSVM methods:
%       isanomaly                   - Identify anomalies in data.
%       incrementalLearner          - Return an incremental one-class SVM model.
%
%   See also ocsvm.

%   Copyright 2022-2023 The MathWorks, Inc.

    properties(GetAccess=public,SetAccess=private,Dependent=true)
        %KernelScale Kernel scale.
        %   The KernelScale property is a nonnegative scalar used to
        %   amplify the Gaussian kernel.
        %  
        %   See also OneClassSVM.
        KernelScale

        %Lambda Regularization strength.
        %   The Lambda property is a scalar used to regularize the objective
        %   function.
        %  
        %   See also OneClassSVM.
        Lambda

        %NumExpansionDimensions Number of dimensions.
        %   The NumExpansionDimensions property is an integer representing the
        %   number of dimensions in transformed space after explicitly
        %   mapping the predictor data.
        %  
        %   See also OneClassSVM.
        NumExpansionDimensions

        %ObjectiveValue Value of the objective function.
        %   The ObjectiveValue property is a scalar representing the value of the
        %   objective function obtained by solving the one-class SVM optimization
        %   problem.
        %  
        %   See also OneClassSVM.
        ObjectiveValue

        %Mu Predictor means.
        %   The Mu property is either empty or a vector with P elements, one for
        %   each predictor. If training data were standardized, the Mu property is
        %   filled with means of predictors used for training. Otherwise the Mu
        %   property is empty. If the Mu property is not empty, the ISANOMALY
        %   method centers input data by subtracting the respective element of Mu
        %   from every column.
        %
        %   When there are categorical predictors, the corresponding entries in Mu
        %   are NaN, because categorical variables are not centered or scaled.
        %  
        %   See also OneClassSVM, isanomaly.
        Mu

        %Sigma Predictor standard deviations.
        %   The Sigma property is either empty or a vector with P elements, one for
        %   each predictor. If training data were standardized, the Sigma property
        %   is filled with standard deviations of predictors used for training.
        %   Otherwise the Sigma property is empty. If the Sigma property is not
        %   empty, the ISANOMALY method scales input data by dividing every column
        %   by the respective element of Sigma (after centering).
        %
        %   When there are categorical predictors, the corresponding entries in
        %   Sigma are NaN, because categorical variables are not centered or
        %   scaled.
        %  
        %   See also OneClassSVM, isanomaly.
        Sigma
    end

    properties(GetAccess=private,SetAccess=private)
        KernelModel
        KernelParams
        FitInfo
        PrivMu
        PrivSigma
        OrdinalPredictors
        ReferenceLevels
    end

    properties(GetAccess=protected, SetAccess=protected)
        % N-by-1 array for N training observations. This vector stores the
        % score values for each training observation.
        Scores     
    end    

    methods
        function ks = get.KernelScale(obj)
            ks = obj.KernelModel.KernelScale;
        end

        function lambda = get.Lambda(obj)
            lambda = obj.KernelModel.Lambda;
        end

        function ned = get.NumExpansionDimensions(obj)
            ned = obj.KernelModel.NumExpansionDimensions;
        end

        function val = get.ObjectiveValue(obj)
            val = obj.FitInfo.ObjectiveValue;
        end

        function mu = get.Mu(obj)
            mu = anomaly.utils.getMu(obj.PrivMu,obj.CategoricalPredictors); 
        end

        function sigma = get.Sigma(obj)
            sigma = anomaly.utils.getSigma(obj.PrivSigma,obj.CategoricalPredictors);  
        end

        function [isAnomaly,scores] = isanomaly(obj,X,varargin)
        %ISANOMALY Identify anomalies in data.
        %   ISANOM = ISANOMALY(MDL,TBL) returns logical array ISANOM with N
        %   elements for N observations in table TBL. TBL contains the predictor
        %   variables. MDL is an object of class OneClassSVM trained on a table of
        %   predictors.
        %
        %   ISANOM = ISANOMALY(MDL,X) accepts X as a matrix of predictors with one
        %   row per observation and one column per predictor for model MDL trained
        %   on a predictor matrix. You must use this syntax if you passed a matrix
        %   to OCSVM.
        %
        %   [ISANOM,SCORES] = ISANOMALY(...) also returns numeric array SCORES of
        %   anomaly scores with N elements using any of the input arguments in the
        %   previous syntaxes. The values in this array are between -Inf and +Inf.
        %   Large negative value indicates a normal observation, and large positive
        %   value indicates an anomaly.
        %
        %   ISANOM = ISANOMALY(...,Name=Value) specifies optional parameter
        %   name-value arguments:
        %       ScoreThreshold      - Numeric scalar between -Inf and +inf.
        %                             Observations with scores above this threshold
        %                             are identified as anomalies. Default: value
        %                             of the ScoreThreshold property
        %
        %   See also OneClassSVM, ocsvm, ScoreThreshold.

            [isAnomaly,scores] = obj.isanomalyHelper(X,varargin{:});
        end


        function incobj = incrementalLearner(obj,varargin)
        %INCREMENTALLEARNER Convert one-class SVM model to incremental learner.
        %   INCREMENTALMODEL=INCREMENTALLEARNER(MODEL) returns an
        %   incremental one-class SVM model INCREMENTALMODEL initialized
        %   using the parameters provided in MODEL.
        %
        %   INCREMENTALLEARNER initializes these INCREMENTALMODEL
        %   properties by using the properties of MODEL:
        %
        %       'KernelScale'
        %       'NumExpansionDimensions'
        %       'ContaminationFraction'        
        %       'RandomStream'
        %       'PredictorNames'
        %       'Lambda'
        %       'Mu'                      - Mu is copied to INCREMENTALMODEL if StandardizeData is true.
        %       'Sigma'                   - Sigma is copied to INCREMENTALMODEL if StandardizeData is true.
        %       'ScoreThreshold'          - If ScoreWindowSize is greater than number of observations in training data, 
        %                                   ScoreThreshold for the INCREMENTALMODEL will be approximated data subsampled
        %                                   from the training data. Otherwise, ScoreThreshold of MODEL will be copied to
        %                                   INCREMENTALMODEL.   
        %
        %   INCREMENTALMODEL=INCREMENTALLEARNER(MODEL,Name = Value)
        %   specifies optional parameter name-value arguments:
        %       'BatchSize'               - Mini-batch size for the stochastic
        %                                   solvers, specified as a positive
        %                                   scalar.
        %                                   Default is 10 for stochastic solvers.
        %                                   'BatchSize' is not supported
        %                                   for scale-invariant solver.
        %       'EstimationPeriod'        - Nonnegative integer specifying the number of
        %                                   observations that are used for
        %                                   estimation of hyperparameters prior to
        %                                   training. For a positive integer, if
        %                                   MODEL has unknown hyperparameters
        %                                   that require estimation,
        %                                   hyperparameters are estimated.
        %                                   Default value is 1000 when estimation is needed.
        %                                   Otherwise, default value is 0.
        %                                   Refer to documentation for more information.
        %       'LearnRate'               - Learning rate for the stochastic
        %                                   solvers, specified either as 'auto' or as a positive
        %                                   scalar. If 'Solver' is specified
        %                                   as 'sgd' or 'asgd', default is 'auto'.
        %                                   'LearnRate' is not supported for scale-invariant solver.
        %                                   Refer to documentation for info
        %                                   on 'auto' behavior.
        %       'LearnRateSchedule'       - String or a character vector specifying the
        %                                   learning rate schedule. Options are 'decaying' or 
        %                                   'constant'.
        %                                   For stochastic solvers, if 'decaying',
        %                                   learning rate decreases over
        %                                   iterations. If 'constant', learning
        %                                   rate stays constant. Default is 'decaying'.
        %                                   'LearnRateSchedule' is not
        %                                   supported for scale-invariant solver.
        %       'ScoreWarmupPeriod'       - Nonnegative integer specifying the number of
        %                                   observations that are used for
        %                                   training the model prior to
        %                                   score threshold update. Default is 0.      
        %       'ScoreWindowSize'         - Nonnegative integer specifying the number of
        %                                   observations that are used for
        %                                   estimating the score threshold.
        %                                   Default value is 1000.
        %       'Shuffle'                 - Logical value specifying
        %                                   whether observations
        %                                   are shuffled at each step.
        %                                   Default: true.
        %                                   This option is valid only when
        %                                   Solver is 'scale-invariant'.
        %                                   Observations are always
        %                                   shuffled for stochastic
        %                                   solvers.        
        %       'Solver'                  - Solver type, specified as a string or a character vector.
        %                                   Available options are
        %                                       'sgd' - stochastic gradient descent
        %                                       'asgd' - average stochastic gradient descent
        %                                       'scale-invariant' - adaptive scale-invariant
        %                                   Default is "scale-invariant".      

            warnState  = warning('query','all');
            warning('off','MATLAB:structOnObject');
            cleanupObj = onCleanup(@() warning(warnState));

            if ~isempty(obj.CategoricalPredictors)
                error(message('stats:incremental:anomaly:AnomalyDetector:CategoricalPredictorsNotSupported'));
            end
            mdl = struct(obj);
            kernelmdl = struct(mdl.KernelModel);
            featureMapper = kernelmdl.FeatureMapper;
            [args,additionalargs] = incremental.anomaly.utils.offlineOneClassSVMParameters(mdl,varargin{:});
            [args,additionalargs] = incremental.anomaly.utils.offlineAnomalyDetectorParameters(mdl,args,additionalargs);
            mergedargs = incremental.utils.mergeOfflineParameters(args,additionalargs);
            additionalargs = incremental.utils.convertStructToCell(additionalargs);
            kernelargs = extractIncrementalParameters(mdl.KernelModel,additionalargs{:});
            kernelargs.Lambda = mdl.Lambda;
            kernelargs.FitBias = false;
            mergedargs = incremental.utils.mergeOfflineParameters(kernelargs,mergedargs);
            
            incobj = incrementalOneClassSVM(mergedargs);
            incobj.Impl  = setFeatureMapper(incobj.Impl,featureMapper); % use the same feature mapper    
        end         
    end


    methods(Access=protected)
        function obj = OneClassSVM(dataSummary,anomalyParams,...
                kernelModel,kernelParams,mu,sigma,fitInfo)

            obj = obj@anomaly.AnomalyDetector(dataSummary.CategoricalPredictors,...
                dataSummary.PredictorNames,dataSummary.TableInput,...
                dataSummary.VariableRange,anomalyParams.ContaminationFraction);

            obj.KernelModel            = kernelModel;
            obj.KernelParams           = kernelParams;
            obj.PrivMu                 = mu;
            obj.PrivSigma              = sigma;
            obj.OrdinalPredictors      = dataSummary.OrdinalPredictors;
            obj.ReferenceLevels        = dataSummary.ReferenceLevels;
            obj.FitInfo                = fitInfo;
        end

        function propList = propsForDisplay(obj)
            propList = propsForDisplay@anomaly.AnomalyDetector(obj);

            propList.('KernelScale') = obj.KernelScale;
            propList.('Lambda')      = obj.Lambda;
        end

        function s = score(obj,X,varargin)
            internal.stats.parseArgs({},{},varargin{:}); % ensure empty varargin

            D = numel(obj.VariableRange);

            iscat = false(1,D);
            iscat(obj.CategoricalPredictors) = true;

            X = anomaly.utils.standardizeXTest(X,D,obj.PrivMu,obj.PrivSigma,obj.CategoricalPredictors); 

            if any(iscat)
                X = classreg.learning.internal.expandCategorical(X,...
                    obj.CategoricalPredictors,obj.OrdinalPredictors,...
                    obj.VariableRange,obj.ReferenceLevels);
            end

            s = -scoreWithoutDataChecks(obj.KernelModel,X);
            s = cast(s,class(X));
        end
    end

    methods(Static,Access=protected)
        function [anomalyParams,kernelParams,standardizeData,extraArgs] = validateModelParameters(varargin)

            [anomalyParams,extraArgs] = ...
                anomaly.AnomalyDetector.validateModelParameters('ocsvm',varargin{:});

            args = {'standardizedata'};
            defs = {            false};
            [standardizeData,~,kernelArgs] = ...
                internal.stats.parseArgs(args, defs, extraArgs{:});
            standardizeData = internal.stats.parseOnOff(standardizeData,'StandardizeData');

            [kernelParams,extraArgs] = classreg.learning.modelparams.KernelParams.make(...
                'classification',kernelArgs{:},'Learner','svm','LossFunction','hinge');
            
            
        end
    end


    methods(Static,Hidden)
        function [mdl,isAnomaly,scores] = fitandscore(X,varargin)

            [anomalyParams,kernelParams,standardizeData,extraArgs] = ...
                OneClassSVM.validateModelParameters(varargin{:});

            catchClassregParameters(extraArgs)

            [N,D] = size(X);
            y = classreg.learning.internal.ClassLabel(ones(N,1));
            w = ones(N,1);

            % Rely on ClassificationKernel.prepareData instead of
            % AnomalyDetector.prepareData because learning is through
            % fitckernel.
            %
            % This call returns X with observations in columns.
            try
                [X,y,w,dataSummary] = ...
                    ClassificationKernel.prepareData(X,y,'Weights',w,extraArgs{:});
            catch me
                switch me.identifier
                    case {'stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:NoData' ...
                        'stats:classreg:learning:classif:FullClassificationModel:prepareData:EmptyClassNames'}
                        error(message('stats:anomaly:ocsvm:EmptyData'))
                    case 'stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:NoGoodXData'
                        error(message('stats:anomaly:ocsvm:EmptyDataAfterNaNRemoval'))
                    otherwise
                        rethrow(me)
                end
            end

            iscat = false(D,1);
            iscat(dataSummary.CategoricalPredictors) = true;
            if standardizeData
                [X,mu,sigma] = anomaly.utils.standardize(X,iscat,false);
            else
                mu    = cast([],'like',X);
                sigma = cast([],'like',X);
            end

            if ~isempty(dataSummary.CategoricalPredictors)
                X = classreg.learning.internal.expandCategorical(X,...
                    dataSummary.CategoricalPredictors,dataSummary.OrdinalPredictors,...
                    dataSummary.VariableRange,dataSummary.ReferenceLevels,false);
            end

            kernelParams = fillDefaultParams(kernelParams);

            if strcmp(kernelParams.KernelScale,'auto')
                kernelParams.KernelScale = anomaly.svm.optimalKernelScale(X);
            end

            if strcmp(kernelParams.Lambda,'auto')
                kernelParams.Lambda = anomaly.svm.optimalLambda(X,kernelParams.KernelScale);
            end

            classSummary = ...
                classreg.learning.classif.FullClassificationModel.makeClassSummary(...
                classreg.learning.internal.ClassLabel(1),...
                classreg.learning.internal.ClassLabel(1),...
                1,0);
            scoreTransform = @classreg.learning.transform.identity;

            % Remove observations with NaNs and remember their indices
            hasnan = any(isnan(X),1);
            donan = any(hasnan);
            if donan
                warning(message('stats:classreg:learning:Linear:prepareDataCR:XwithNaNs'))
                X(:,hasnan) = [];
                y(hasnan)   = [];
                w(hasnan)   = [];
                if isempty(dataSummary.RowsUsed) || all(dataSummary.RowsUsed)
                    good = ~hasnan;
                else
                    good = find(dataSummary.RowsUsed);
                    good = good(~hasnan);
                end
            end

            fitoneclass = true;
            isExpandedX = true;
            [kernelModel,fitInfo,scores] = ClassificationKernel.fitClassificationKernel(...
                X,y,w,kernelParams,dataSummary,classSummary,scoreTransform,...
                fitoneclass,isExpandedX);

            mdl = OneClassSVM(dataSummary,anomalyParams,kernelModel,kernelParams,...
                mu,sigma,fitInfo);

            % Compute scores
            if isempty(scores)
                scores = -scoreWithoutDataChecks(kernelModel,X');
            else
                scores = -scores;
            end
            if donan
                scoresWithNaNs = NaN(N,1,'like',scores);
                scoresWithNaNs(good) = scores;
                scores = scoresWithNaNs;
            end
            mdl.Scores = scores;
            [mdl,isAnomaly] = anomaliesInXTrain(mdl,scores);
        end      
    end

    methods(Hidden)
        function str = toStruct(obj)
            % Convert to a struct for codegen.

            warnState  = warning('query','all');
            warning('off','MATLAB:structOnObject');
            cleanupObj = onCleanup(@() warning(warnState));

            str = toStruct@anomaly.AnomalyDetector(obj);         

            str.KernelModel = toStruct(obj.KernelModel);

            % When ClassificationKernel is part of OneClassSVM, conditions
            % for deciding if model has been trained with tall array may be
            % true even if the model has not been trained with tall data.
            % Thus, we manually set it to false.
            str.KernelModel.DataSummary.ModelTrainedWithTallArray = false;
            
            str.KernelParams = classreg.learning.coderutils.kernelParamsToCoderStruct(...
                obj.KernelParams);
            str.KernelModel.ModelParams.KernelScale = obj.KernelScale;
            str.KernelModel.Impl.Beta = -1*str.KernelModel.Impl.Beta;
            str.KernelModel.Impl.Bias = -1*str.KernelModel.Impl.Bias;
            str.Mu = obj.PrivMu;
            str.Sigma = obj.PrivSigma;
            str.FitInfo = obj.FitInfo;
            str.Scores = obj.Scores;

            % save the path to the fromStruct method
            str.FromStructFcn = 'OneClassSVM.fromStruct';

        end
    end

    methods(Static,Hidden)
        function obj = fromStruct(str)
            % Make a Kernel object from a codegen struct.

            [~,anomalyParams] = anomaly.AnomalyDetector.parametersFromStruct(str);

            fcn = str2func(str.KernelModel.FromStructFcn);
            str.KernelModel.Impl.Beta = -1*str.KernelModel.Impl.Beta;
            str.KernelModel.Impl.Bias = -1*str.KernelModel.Impl.Bias;            
            kernelModel = fcn(str.KernelModel);
            dataSummary = kernelModel.DataSummary;
            kernelParams = classreg.learning.coderutils.coderStructToKernelParams(str.KernelParams,false);
            % OneClassSVM holds an unfilled kernelParams separate from
            % KernelModel's own ModelParams.
            kernelParams.Filled = false;
            mu = str.Mu;
            sigma = str.Sigma;
            fitInfo = kernelModel.Impl.FitInfo;

            obj = OneClassSVM(dataSummary,anomalyParams,kernelModel,kernelParams,...
                mu,sigma,fitInfo);
            

            threshold = str.ScoreThreshold;
            obj = setScoreThreshold(obj,threshold);
            obj.FitInfo = str.FitInfo;
            if isfield(str,'Scores')
                obj.Scores = str.Scores;
            end
     
        end

        function name = matlabCodegenRedirect(~)
            name = 'anomaly.coder.OneClassSVM';
        end        
    end    
end


function catchClassregParameters(args)
params = {'Weights' 'ClassNames' 'Prior' 'Cost' 'ScoreTransform'};

for j=1:numel(params)
    [val,~,args] = internal.stats.parseArgs(params(j), {[]}, args{:});
    if ~isempty(val)
        error(message('stats:anomaly:ocsvm:Unsupported',params{j}))
    end
end
end

% LocalWords:  iscatexp iscat standardizedata catrows ISANOM NRemoval subsampled Xwith
