classdef Model < matlab.mixin.internal.Scalar & matlab.mixin.CustomDisplay % disallow vector operations, add custom display
    % Superclass for all semi-supervised classifiers
    
%   Copyright 2020 The MathWorks, Inc.

properties (SetAccess = protected, GetAccess = public)
        %FITTEDLABELS Class labels fitted to the unlabeled data
        %   The FittedLabels property is an array of class labels of the
        %   same type as Y passed by the user and of, size M-by-1 where M
        %   is the number of unlabeled observations.
        FittedLabels
        
        %LABELSCORES Scores of the labels fitted to the unlabeled data
        %   The LabelScores property is an array of class labels of the
        %   same type as Y passed by the user and of the same size as the
        %   number of unlabeled observations.
        LabelScores
    end
    
    properties (Dependent=true,GetAccess=public,SetAccess=protected,Hidden=true)
        % allow public get access for debugging purposes, same as classreg.learning.Predictor.
        VariableRange % needed for converting (categoricals) table input in predict
        % for numeric matrices, classreg object should have this info in
        % its datasummary
        TableInput % just a diagnostic flag to know if data was trained on table
        Mu
        Sigma
    end
    
    
    
    properties(GetAccess=public,SetAccess=protected,Hidden=true)
        % Use a summary struct similar to classreg.learning.Predictor.
        DataSummary = struct(...
            'PredictorNames',{},... % stored as a number equal to dimensionality when trained on a numeric matrix, a cellstr otherwise
            'CategoricalPredictors',[],...
            'ResponseName','',...
            'VariableRange',{},...
            'TableInput',false,...
            'Mu', [],... % for standardization, mean of the predictors
            'Sigma',[]); % for standardization, std of the predictors
        % use a struct similar to
        % classreg.learning.classif.ClassificationModel, albeit with only
        % one field for now
        ClassSummary = struct('ClassNames',{},...
            'NonzeroProbClasses', {}); % fields in this struct stored as classreg.learning.internal.ClassLabel
    end
    
    
    properties (GetAccess = public, SetAccess = protected, Dependent = true) % other user visible properties
        %CATEGORICALPREDICTORS Indices of categorical predictors.
        %   The CategoricalPredictors property is an array with indices of
        %   categorical predictors. The indices are in the range from 1 to the
        %   number of predictors in X.
        %
        %   See also classreg.learning.Predictor.
        CategoricalPredictors;
        
        %PREDICTORNAMES Names of predictors used for this model.
        %   The PredictorNames property is a cell array of strings with names of
        %   predictor variables, one name per predictor of X.
        PredictorNames
        
        %RESPONSENAME Name of the response variable.
        %   The ResponseName property is a string with the name of the response
        %   variable Y.
        %
        %   See also classreg.learning.Predictor.
        ResponseName;
        
        %CLASSNAMES Names of classes in Y.
        %   The ClassNames property is an array containing the class names for the
        %   response variable Y.
        %
        %   See also classreg.learning.classif.ClassificationModel.
        ClassNames;
    end
    
    methods % define getters for dependent properties
        function c = get.CategoricalPredictors(this)
            c = this.DataSummary.CategoricalPredictors;
        end
        
        function n = get.PredictorNames(this)
            names = this.DataSummary.PredictorNames;
            if isnumeric(names)
                n = classreg.learning.internal.defaultPredictorNames(names);
            else
                n = names;
            end
        end
        
        function r = get.ResponseName(this)
            r = this.DataSummary.ResponseName;
        end
        
        function cnames = get.ClassNames(this)
            cnames = labels(this.ClassSummary.ClassNames);
        end
        
        function vr = get.VariableRange(this)
            vr = this.DataSummary.VariableRange;
        end
        
        function ti = get.TableInput(this)
            ti = this.DataSummary.TableInput;
        end
        
        function m = get.Mu(this)
            m = this.DataSummary.Mu;
        end
        
        function s = get.Sigma(this)
            s = this.DataSummary.Sigma;
        end
    end
    
    methods (Access = protected) % custom display methods
        function propStruct = propsForDisplay(this) % propList is a struct of properties, derived classes can append to this struct
            % most useful properties for semi-supervised learning
            propStruct.('FittedLabels') = this.FittedLabels;
            propStruct.('LabelScores') = this.LabelScores;
            cnames = this.ClassNames;
            if ~ischar(cnames)
                cnames = cnames'; % flatten the classnames - columns in scores correspond to classnames
            end
            propStruct.('ClassNames') = cnames;
            
            % other properties
            names = this.DataSummary.PredictorNames;
            if iscellstr(names)  && numel(names)<=100 %#ok<ISCLSTR>
                propStruct.('PredictorNames') = names;
            end
            
            propStruct.('ResponseName') = this.ResponseName;
            propStruct.('CategoricalPredictors') = this.CategoricalPredictors; % include CategoricalPredictors below ResponseName
        end
        
        function grps = getPropertyGroups(this) % override used by the CustomDisplay interface to display properties
            propList =  propsForDisplay(this);
            grps =  matlab.mixin.util.PropertyGroup(propList);
        end
        
        function footerText = getFooter(this)  % override used by the CustomDisplay interface to display the footer
            % use the same footer defined in
            % internal.stats.displayMethodsProperties for classreg objects
            
            mc = metaclass(this);
            hotlinks = feature('hotlinks');
            if hotlinks
                footerText = sprintf('\n  <a href="matlab: properties(''%s'')">%s</a>, ', ...
                    mc.Name,'Properties');
                footerText = [footerText sprintf('<a href="matlab: methods(''%s'')">%s</a>\n\n', ...
                    mc.Name,'Methods')];
            else
                footerText = newline;
            end
        end
    end
    
    methods(Access=protected)
        function this = Model(dataSummary, classSummary)
            if ~isfield(dataSummary,'VariableRange')
                dataSummary.VariableRange = {};
            end
            if ~isfield(dataSummary,'TableInput')
                dataSummary.TableInput = false;
            end
            this.DataSummary = dataSummary;
            this.ClassSummary = classSummary;
        end
    end
    
    methods (Static, Hidden)
        
        function  [X,Y,XU, dataSummary,userclassnames, nonzeroClassNames, varargin] = prepareData(X,Y, XU, varargin)
            % prepareData is currently used by
            % SemiSupervisedGraphModels only, because self training uses data
            % preparation from classreg, however, this method is put here so
            % that any future child classes of SemiSupervisedModel can use this
            % utility- modify this method if a semi supervised learner supports observationsin
            
            [numlabeled,D] = size(X);
            if numlabeled~=length(Y)
                error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:InputSizeMismatch'));
            end
            
            
            % class names defaults from individual models
            % rest of the defaults are from FullClassificationRegressionModel
            classregparams = {'predictornames', 'responsename', 'categoricalpredictors',...
                'variablerange', 'tableinput', 'classnames', 'standardize'};
            classregdflts = { [], [], [],...
                {}, false, [], false} ;
            
            % parse classreg parameters
            [predictornames, responsename, categoricalpredictors,...
                variablerange, tableinput, userclassnames, standardize, ~, varargin] = ...
                internal.stats.parseArgs(classregparams, classregdflts, varargin{:});
            
            
            % validate the parsed inputs
            standardize = internal.stats.parseOnOff(standardize, 'Standardize');
            
            if isempty(predictornames)
                predictornames = D;
            elseif isnumeric(predictornames)
                if ~(isscalar(predictornames) && predictornames==D)
                    error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:BadNumericPredictor', D));
                end
            else
                if ~iscellstr(predictornames) %#ok<ISCLSTR>
                    if ~ischar(predictornames)
                        error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:BadPredictorType'));
                    end
                    predictornames = cellstr(predictornames);
                end
                if length(predictornames)~=D || length(unique(predictornames))~=D
                    error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:PredictorMismatch', D));
                end
            end
            predictornames = predictornames(:)'; % flatten the names into a row
            
            % Process response name
            if isempty(responsename)
                responsename = 'Y';
            else
                if ~ischar(responsename)
                    error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:BadResponseName'));
                end
            end
            
            % validate categorical predictors
            categoricalpredictors = semisupervised.Model.validateCategoricalPredictors(categoricalpredictors, predictornames, D);
            
            % Process class names in the same way as classreg
            % Get class names before any rows might be removed
            allClassNames = levels(Y); % this removes all nan labels - nan classes do not show up in class names in classreg
            if isempty(allClassNames)
                error(message('stats:classreg:learning:classif:FullClassificationModel:prepareData:EmptyClassNames'));
            end
            
            % remove nans
            nanX = any(isnan(X),2);
            if any(nanX)
                X(nanX,:)   = [];
                Y(nanX) = [];
            end
            if isempty(X) % X can be emptied by prepareData
                error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:NoGoodXData'));
            end
            
            % if processClassNames empties X, it will throw an error
            % message
            [X,Y,userclassnames, nonzeroClassNames] = semisupervised.Model.processClassNames(X,Y,userclassnames,allClassNames, true); % this class will remove data NaNs
            
            % remove nan values from Y as well just like classreg
            t = ismissing(Y);
            if any(t)
                Y(t) = [];
                X(t,:) = [];
            end
            if isempty(X)
                error(message('stats:classreg:learning:classif:FullClassificationModel:removeMissingVals:NoGoodYData'));
            end
            numlabeled = size(X,1); % the size of X can be different here
            isemptyXU = isempty(XU);
            if ~isemptyXU
                nanXU = any(isnan(XU),2);
                XU(nanXU,:) = [];
            end
            
            
            iscategoric = false(D,1);
            iscategoric(categoricalpredictors) = true;
            
            % for matrices with categorical predictors, calculate variable
            % range and encode categoricals, for tables it is already done
            % once by table2FitMatrix
            if ~tableinput && any(iscategoric)
                variablerange = cell(1,D);
                trainX = [X; XU]; % concatenate so that categoricals can be encoded together
                for k=1:D
                    if iscategoric(k)
                        x = trainX(:,k);
                        vrk = unique(x);
                        variablerange{k} = vrk(~isnan(vrk));
                        [~,trainX(:,k)] = ismember(x,variablerange{k}); % encode as integer for heteropdist2
                    end
                end
                X = trainX(1:numlabeled,:);
                XU = trainX(numlabeled+1:end,:);
            end
            
            % standardize continuous predictors if needed
            % note that other algorithms might not combine the data
            if standardize && ~all(iscategoric)
                trainX = [X; XU]; % standardize based on the entire data
                [trainX, mu, sigma] = semisupervised.Model.standardizeData(trainX, iscategoric);
                X = trainX(1:numlabeled,:);
                XU = trainX(numlabeled+1:end,:);
            else
                mu = [];
                sigma = [];
            end
            
            % prepare a struct, dataSummary
            dataSummary.PredictorNames = predictornames;
            dataSummary.CategoricalPredictors = categoricalpredictors;
            dataSummary.ResponseName = responsename;
            dataSummary.TableInput = tableinput;
            dataSummary.VariableRange = variablerange;
            dataSummary.Mu = mu;
            dataSummary.Sigma = sigma;
        end
        
        function [trainX, mu, sigma] = standardizeData(trainX, iscategoric)
            dim = size(trainX,2);
            mu = zeros(1,dim,'like',trainX);
            sigma = ones(1,dim,'like',trainX);
            mu(~iscategoric) = mean(trainX(:,~iscategoric),'omitnan');
            sigma(~iscategoric) = var(trainX(:,~iscategoric),'omitnan');
            trainX = trainX - mu;
            nonZeroSigma = sigma>0;
            sigma(~nonZeroSigma) = 0;
            if any(nonZeroSigma)
                sigma = sqrt(sigma);
                trainX(:,nonZeroSigma) = trainX(:,nonZeroSigma)./sigma(nonZeroSigma);
            end
        end
        
        function X = standardizePredictData(X,mu,sigma)
            sigma0 = sigma;
            sigma0(sigma0==0) = 1;
            X = X - mu;
            X = X./sigma0;
        end
        
        function [X,Y,userclassnames, nonzeroClassNames]  = processClassNames(X,Y,userclassnames, allClassNames, obsInRows)
       
            nonzeroClassNames = levels(Y); % this call removes NaN labels
            % process class names
            if isempty(userclassnames)
                userclassnames = nonzeroClassNames;
            else
                userclassnames = classreg.learning.internal.ClassLabel(userclassnames);
                % Make sure at least some of the requested classes are
                % found and get rid of extra classes
                missingC = ~ismember(nonzeroClassNames, userclassnames);
                unmatchedY = ismember(Y,nonzeroClassNames(missingC));
                Y(unmatchedY) = [];
                % Exclude extra classes found in data but not requested by
                % the user
                if obsInRows
                    X(unmatchedY,:) = [];
                else
                    X(:,unmatchedY) = [];
                end
                % cover the edge case where the user passes valid class
                % names but all observations are nan and were removed
                if isempty(X) 
                    if any(ismember(allClassNames, userclassnames)) 
                                        error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:NoGoodXData')); % if the user passed a single class name and it had only nans
                    end
                end
                
                if all(missingC)
                    error(message('stats:classreg:learning:classif:FullClassificationModel:prepareData:ClassNamesNotFound'));
                end
                
                nonzeroClassNames(missingC) = []; % trim non-zero class names, just like classreg
            end
        end
        
        function categoricalpredictors = validateCategoricalPredictors(categoricalpredictors, predictornames, D)            
            if isnumeric(categoricalpredictors) % indices of categorical predictors
                categoricalpredictors = ceil(categoricalpredictors);
                if any(categoricalpredictors<1) || any(categoricalpredictors>D)
                    error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:BadCatPredIntegerIndex', D));
                end
            elseif islogical(categoricalpredictors)
                if length(categoricalpredictors)~=D
                    error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:BadCatPredLogicalIndex', D));
                end
                idx = 1:D;
                categoricalpredictors = idx(categoricalpredictors);
            elseif ischar(categoricalpredictors) && strcmpi(categoricalpredictors,'all')
                categoricalpredictors = 1:D;
            else
                if ~ischar(categoricalpredictors) && ~iscellstr(categoricalpredictors) %#ok<ISCLSTR>
                    error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:BadCatVarType'));
                end
                if ~iscellstr(categoricalpredictors) %#ok<ISCLSTR>
                    categoricalpredictors = cellstr(categoricalpredictors);
                end
                if isnumeric(predictornames)
                    error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:CharCatVarWithoutVarNames'));
                end
                [tf,pos] = ismember(categoricalpredictors,predictornames);
                if any(~tf)
                    error(message('stats:classreg:learning:FullClassificationRegressionModel:prepareDataCR:BadCatVarName', ...
                        categoricalpredictors{ find( ~tf, 1, 'first' ) }));
                end
                categoricalpredictors = pos;
            end
            
            if isempty(categoricalpredictors)
                categoricalpredictors = [];  % avoid 1-by-0 display
            end
        end
        
    end
    
    methods (Abstract, Hidden = true, Static = true)
        fit(XL,YL,XU, varargin)
    end
    methods (Abstract)
        predict(mdl,X)
    end
    
    methods (Access = protected)
        function [predlabels, scores] = predictEmptyX(this)
            % same as
            % classreg.learning.classif.ClassificationModel.predictEmptyX
            predlabels = repmat(this.ClassNames(1,:),0,1);
            K = numel(this.ClassSummary.ClassNames);
            scores = NaN(0,K);
        end
    end % end hidden methods
    
end