#################################################################### MODULE COMMENTS ####################################################################
# The following Python object is responsible for calculating two loss functions to identify a series of statistical data points for a programmer to view #
# In order to see how 'Well' the Naive bayes program is functioning. The two loss functions that Nick Stone and Matteo Bjornsson implemented for this pr-#
# -oject were the 0/1 loss function which we will use to calculate the algorithms precision and the F1 score for a multidimensional data set.            #
# All of the functions have been documented such that a programmer can understand the mathematics and statistics involved for undersanding each of the l-#
# -oss Functions. The main datastructures used were a dataframe and a dictionary to keep track of a given confusion matrix                               # 
#################################################################### MODULE COMMENTS ####################################################################

import pandas as pd
import numpy as np

"""
loss functions 

use 0/1 loss
https://stats.stackexchange.com/questions/296014/why-is-the-naive-bayes-classifier-optimal-for-0-1-loss
0/1 loss penalizes missclassification
https://stats.stackexchange.com/questions/284028/0-1-loss-function-explanation/284062

multiclass confusion matrix 
https://stats.stackexchange.com/questions/179835/how-to-build-a-confusion-matrix-for-a-multiclass-classifier

multiclass precision and recall
https://towardsdatascience.com/multi-class-metrics-made-simple-part-i-precision-and-recall-9250280bddc2

multiclass f1 score
https://towardsdatascience.com/multi-class-metrics-made-simple-part-ii-the-f1-score-ebe8b2c2ca1

"""


class Results: 

    #Parameters: DataFrames
    #Returns: List 
    #Function: Take in a dataframe and count the number of correct classifications and return the percentage value 
    def ZeroOneLoss(self, df: pd.DataFrame)->list(): 
        #Store off the guessed classifier 
        guessIndex = len(df.columns)-1
        #Store off the true classification 
        groundTruthIndex = len(df.columns) -2
        #Set the count correct to 0 
        countCorrect = 0
        #Get the total number of rows from the dataframe 
        totalCount = len(df)
        #For each of the rows in the dataframe 
        for i in range(totalCount): 
            #If the classified true is equal to the guess classification 
            if df.iloc[i][guessIndex] == df.iloc[i][groundTruthIndex]: 
                #INcrement the correct value 
                countCorrect += 1
        #The percent Correct divided by total count * 100 
        percentCorrect = (countCorrect / totalCount) * 100 
        #TotalWrong = (len(self.ClassificationWrong) / TotalTestSet) * 100 
        #Return the percent correct 
        return percentCorrect

    """
    https://towardsdatascience.com/multi-class-metrics-made-simple-part-i-precision-and-recall-9250280bddc2
https://towardsdatascience.com/multi-class-metrics-made-simple-part-i-precision-and-recall-9250280bddc2

    
    """
    #Parameters: Dataframe 
    #Returns: DataFrame, Dictionary 
    #Function: Take in a given dataframe and get a series of statistics about the given dataframe 
    def statsSummary(self, df: pd.DataFrame) -> (pd.DataFrame, dict, dict):
        #Create a dataframe of the confusion matrix 
        cMatrix = self.ConfusionMatrix(df)
        #Create a Dataframe to get stats about the classes 
        classStats = self.perClassStats(cMatrix)
        # tpList = list(classStats["TP"])
        # fpList = list(classStats["FP"])
        # fnList = list(classStats["FN"])
        # microStats = self.microAverageStats(tpList, fpList, fnList)
        macroF1Average = self.weightedMacroAverageStats(classStats, cMatrix)
        return macroF1Average
    #Parameters: DataFrame 
    #Returns: DataFrame
    #Function: Take in a dataframe and generate all of the class stats from the given dataframe and return it 
    def perClassStats(self, cMatrix): 
        #Set the dataframe of the class stats
        classStats = self.classStats(cMatrix)
        #return the dataframe of class stats
        return classStats
    
    
    #Parameters: Float, Float
    #Returns: Float 
    #Function: Take in the float of class stats and the cmatrix and generate the weighted macro averages 
    def weightedMacroAverageStats(self, perClassStats, cMatrix) -> dict: 
        #Create a list of the names of classes 
        classValues = list(perClassStats.index.values)
        #Create an empty dictionary To store macro stats
        macroStatsDict = {}
        #Get the total count of occurence of each class 
        classCounts = self.countClassOccurence(cMatrix)
        #Set a total class count variable to 0 
        totalClassCount = 0
        #For each of the class keys 
        for key in classCounts.keys():
            #Add the total class occurence to the variable initialized above 
            totalClassCount += classCounts[key]
        #Set a series of statistical variables to 0 
        # macroRecallAverage = 0
        # macroPrecisionAverage = 0
        macroF1Average = 0
        #Loop through all of the classes 
        for i in range(len(classValues)):
            # #Add the total number of occurence of the class multiplied by the stat percentage 
            # macroRecallAverage += (perClassStats["Recall"].iloc[i] * classCounts[classValues[i]])
            # #Add the total number of occurence of the class multiplied by the stat percentage 
            # macroPrecisionAverage += (perClassStats["Precision"].iloc[i] * classCounts[classValues[i]])
            #Add the total number of occurence of the class multiplied by the stat percentage 
            macroF1Average += (perClassStats["F1"].iloc[i] * classCounts[classValues[i]])
        #THen divide by the total number of examples 
        # macroStatsDict["macroRecall"] = macroRecallAverage / totalClassCount
        # macroStatsDict["macroPrecision"] = macroPrecisionAverage / totalClassCount
        macroF1Average = macroF1Average / totalClassCount
        #Return the dictionary 
        return macroF1Average
        
    #Parameters: Float, Float
    #Returns: Float 
    #Function: Count the number of times a class occurs in a given dataframe and return this in a dictionary 
    def countClassOccurence(self, cMatrix: pd.DataFrame) -> dict:
        #Get a list of all the class names 
        classValues = list(cMatrix.index.values)
        #Create an empty dictionary with class names as key values 
        classCounts = dict.fromkeys(classValues)
        # print("perclass stats from countclass occurence method: \n", cMatrix)
        #Loop through all class names stored in the list above 
        for i in range(len(classValues)):
            #Store the given class stats from row i 
            x = cMatrix.iloc[i]
            #Set the count to 0 
            count = 0
            #For each stat and value in the row 
            for stat, statValue in x.items():
                #Adding stat value to the variable we instantiated above 
                count += statValue
            #Set the count to the class value 
            classCounts[classValues[i]] = count
        #Return the dictionary 
        return classCounts
        
    #Parameters: Float, Float
    #Returns: Float 
    #Function: This set takes in the number of true positives followed by a series of lists of several other confusion matrix values to generate micro average stats 
    def microAverageStats(self, truePositives: list, falsePositives: list, falseNegatives: list) -> dict:
        #Create an empty dictionary 
        microStatsDict = {}
        #Store off the sum of the given values into the respective variable name 
        tpSum = sum(truePositives)
        fpSum = sum(falsePositives)
        fnSum = sum(falseNegatives)
        #Get the micro recall from the values above 
        microRecall = self.recall(tpSum, fnSum)
        #Get the micro precision from the values above 
        microPrecision = self.precision(tpSum, fpSum)
        #Save the micro recall value in the dictionary with the following key 
        microStatsDict["microRecall"] = microRecall
        #Save the micro precision value with the following key in the dictionary 
        microStatsDict["microPrecision"] = microPrecision
        #Calculate and save the following F1 score with the following key in the dictionary 
        microStatsDict["microF1Score"] = self.f1Score(precision=microPrecision, recall=microRecall)
        #Return the dicationary that is full of data 
        return microStatsDict

    #Parameters: Float, Float
    #Returns: Float 
    #Function: Take in the precision and recall that is calculated below and generate the F1 score 
    def f1Score(self, precision, recall) -> float:
            #If the precision + recall is 0 
            if (precision + recall) == 0:
                #Return 0 
                return 0
            #Otherwise 
            else:
                #Return the value asscoiated with the following computation 
                return 2 * precision * recall / (precision + recall)

    #Parameters: Inter, Integer
    #Returns: Float 
    #Function: Take in the true positive count and the false negative count to calculate and return the recall 
    def recall(self, truePositiveCount, falseNegativeCount) -> float:
        #If the TP + FN is 0 
        if truePositiveCount + falseNegativeCount == 0:
            #Return 0 
            return 0
        #Otherwise 
        else:
            #Return the following computation( TP/ *TPC + FN )
            return truePositiveCount/(truePositiveCount + falseNegativeCount)
    #Parameters: Integer, Integer
    #Returns: Float
    #Function:Function takes in the true positives and the false positive from the confusion matrix and calulates the precision  
    def precision(self, truePositiveCount, falsePositiveCount) -> float:
        #If the TP + FP is 0 
        if truePositiveCount + falsePositiveCount == 0:
            #Return 0 
            return 0
        #Otherwise 
        else:
            #Return the formula for precision 
            return truePositiveCount/(truePositiveCount + falsePositiveCount)
    
    #Parameters: Integer, DataFame
    #Returns: List 
    #Function: return a list of true positive counts for each class
    def truePositive(self, classCount: range, cMatrix: pd.DataFrame) -> list:
        tp = []
        # print("cmatrix true positive method: \n", cMatrix)
        for i in classCount:
            # true positive for each class is where truth == guess
            tp.append(cMatrix.iloc[i][i])
        return tp

    #Parameters: Integer, DataFrame 
    #Returns: List 
    #Function: return a list of false positive counts for each class
    def falsePositive(self, classCount: range, cMatrix: pd.DataFrame) -> list:
        # print("cmatrix in true positive method: \n")
        # print(cMatrix)
        fp = []
        for i in classCount:
            count = 0
            for j in classCount:
                if i == j:
                    continue
                else:
                    # false positive is the sum of every count in the class
                    # column except the true positive count
                    count += cMatrix.iloc[j][i]
            fp.append(count)
        return fp

    #Parameters: Integer, DataFrame 
    #Returns: List 
    #Function: return a list of false negative counts for each class
    def falseNegative(self, classCount: range, cMatrix: pd.DataFrame) -> list:
        #Create an empty array 
        fn = []
        #For each of the values in the classcount 
        for i in classCount:
            #Set count to 0 
            count = 0
            for j in classCount:
                #If the class count is the same 
                if i == j:
                    #Do nothing 
                    continue
                #Otherwise 
                else:
                    # false negative is the sum of every count in the class
                    # row except the true positive count
                    count += cMatrix.iloc[i][j]
            fn.append(count)
        #Return the list 
        return fn

    #Parameters: Int, DataFrame, List, List,List 
    #Returns: List 
    #Function: return a list of true negative counts for each class
    def trueNegative(self, classCount: range, cMatrix: pd.DataFrame, tp: list, fp: list, fn: list) -> list:
        #Create an empty array 
        tn = []
        #For each of the values in classcount 
        for i in classCount:
            #Set count to 0 d
            count = 0
            # sum the value of every cell
            for j in classCount:
                for k in classCount:
                    #Add the value at the given dataframe position to count 
                    count += cMatrix.iloc[j][k]
            # true negative counts are the sum of every cell minus true 
            # positive, false positive and false negative.
            count = count - tp[i] - fp[i] - fn[i]
            tn.append(count)
        #Return the list 
        return tn

    #Parameters: DataFrame
    #Returns: DataFrame
    #Function: create a stats summary matrix for all classes
    def classStats(self, cMatrix: pd.DataFrame) -> pd.DataFrame:
        # grab the class names
        #Print some data to the screen 
        # print("cmatrix stats method: \n")
        # print(cMatrix)
        #Create a list to the column names from the dataframe 
        ClassList = list(cMatrix.columns.values)
        #Print some data to the screen 
        # print("classList class stats method: \n")
        # print(ClassList)
        #Sent a class count to the len of the class list 
        classCount = range(len(ClassList))
        # init an empty matrix with class indexes labeled
        statsMatrix = pd.DataFrame(index=ClassList)

        # calculate stats
        tp = self.truePositive(classCount, cMatrix)
        fp = self.falsePositive(classCount, cMatrix)
        fn = self.falseNegative(classCount, cMatrix)
        tn = self.trueNegative(classCount, cMatrix, tp, fp, fn)

        # insert stats into matrix
        statsMatrix["TP"] = tp
        statsMatrix["FP"] = fp
        statsMatrix["FN"] = fn
        statsMatrix["TN"] = tn
        #Create some empty lists 
        precisionList = []
        recallList = []
        fScoreList = []
        #For each of the values in the class count 
        for i in classCount:
            #Get the value of the given value 
            singleClassStats = statsMatrix.iloc[i]

            tp = singleClassStats["TP"]
            fp = singleClassStats["FP"]
            fn = singleClassStats["FN"]
            #Set the following values 
            prec = self.precision(tp, fp)
            rec = self.recall(tp, fn)
            #Get the value of the f1 score 
            f1 = self.f1Score(prec, rec)
            #Print some data to the screen 
            # print("i: ", i, " prec: ", prec, " recall: ", rec, " f1: ", f1)
            #Append data to the following lists 
            precisionList.append(prec)
            recallList.append(rec)
            fScoreList.append(f1)

        statsMatrix["Precision"] = precisionList
        statsMatrix["Recall"] = recallList
        statsMatrix["F1"] = fScoreList
        #Return the Dataframe 
        return statsMatrix

    #Parameters: DataFrame 
    #Returns: DataFrame
    #Function: generate a matrix that checks classified test data against ground truth
    def ConfusionMatrix(self, df: pd.DataFrame) -> pd.DataFrame:
        # identify column index of ground truth and classification
        GroundTruthIndex = len(df.columns)- 2
        ClassifierGuessIndex = len(df.columns)-1 

        # generate a list of all unique classes
        UniqueClasses = list() 
        #Loop through all of the rows in the dataframe 
        for i in range(len(df)): 
            #If the Ground truth classification not in the unique classes list 
            if str(df.iloc[i][GroundTruthIndex]) not in UniqueClasses:
                #Append the value to the list 
                UniqueClasses.append(str(df.iloc[i][GroundTruthIndex]))
            #If the classifcation is not in the unique classes list 
            if str(df.iloc[i][ClassifierGuessIndex]) not in UniqueClasses:
                #Add the value to the list 
                UniqueClasses.append(str(df.iloc[i][ClassifierGuessIndex]))
            #Go to the next one 
            continue 
        #Set the class count to the length of unique classes 
        ClassCount = len(UniqueClasses)

        # initialize empty confusion matrix
        zeroArray = np.zeros(shape=(ClassCount, ClassCount))
        #Set a variable to a dataframe that has the columns from the unique classes 
        matrix = pd.DataFrame(zeroArray, columns=UniqueClasses, index=UniqueClasses)
        #Print some data to the screen 
        # print("empty Cmatrix, cmatrix method: \n", matrix)
        #For each of the rows in the dataframe 
        for i in range(len(df)):
            # for each example, increment a counter where row = truth, col = guess
            truth = str(df.iloc[i][GroundTruthIndex])
            guess = str(df.iloc[i][ClassifierGuessIndex])
            #Increment the count 
            matrix.at[truth, guess] += 1
            #Go to the next one 
            continue
        #Return the dataframe 
        return matrix


#Unit Testing the object created above 
#Code not run on creation of object just testing function calls and logic above 
if __name__ == '__main__':
    print("Program Start")


    print("Program Finish")



