# -*- coding: utf-8 -*-
"""ExaplainableAPI.ipynb

Automatically generated by Colab.

Original file is located at
    https://colab.research.google.com/drive/1CesI23LUw_ZXlSSqIDGpD_jYIgsk8xko

# **Install Libs**
"""

# !jupyter nbconvert --to script ExplainableAPI.ipynb

# !pip install lime==0.1.1.37

# !pip install shap==0.46.0

"""# **IMPORTS**"""

# prompt: crie um classe em python chamada ExplainableAPI com o método load_data que recebe o caminho, o separador e retorna um x e um y
import pandas as pd
import numpy as np

import seaborn as sns
import matplotlib.pyplot as plt
import plotly.express as px

from sklearn.preprocessing import MinMaxScaler
import tensorflow as tf
from keras.models import load_model
# from multipledispatch import dispatch

import lime.lime_tabular # !pip install lime==0.1.1.37
import shap #!pip install shap==0.46.0

"""# **GENERAL API**"""

class ExplainableAPI:
  QUERY_FUNGI = '''SELECT
            f.name AS Fungi_Name,
            v.VOC_Category,
            COUNT(*) AS VOC_Count
        FROM
            FUNGI f
        JOIN
            FUNGI_VOC fv ON f.id = fv.FUNGI_ID
        JOIN
            VOCs v ON fv.VOC_ID = v.id_VOC
        GROUP BY
            f.name, v.VOC_Category
        ORDER BY
            f.name, v.VOC_Category,VOC_Count;
        '''
  QUERY_SENSOR = '''SELECT
            f.name AS Sensor_Name,
            v.VOC_Category,
            COUNT(*) AS VOC_Count
        FROM
            SENSOR f
        JOIN
            SENSOR_VOC fv ON f.id = fv.SENSOR_ID
        JOIN
            VOCs v ON fv.VOC_ID = v.id_VOC
        GROUP BY
            f.name, v.VOC_Category
        ORDER BY
            f.name,VOC_Count, v.VOC_Category;
        '''
  def __init__(self, features_name):
    self.features_name = features_name;

  def load_data(self, path, sep):
      data = pd.read_csv(path, delimiter=sep,header=None)
      print()
      y = data.iloc[:, data.shape[1]-1].values
      x = data.iloc[:,0: data.shape[1]-1].copy().values
      return x, y

  def create_df_2(self, list_weights):
      df = pd.DataFrame({
      'Feature': range(len(list_weights)),
      'Weight': list_weights
      })
      return df


  def create_df(self, list_weights, features):
      df = pd.DataFrame({
      'Feature': features,
      'Weight': list_weights
      })
      return df

  def weight_by_feature(self, features_name, weight_list):
      # Converte a lista de pesos em um array NumPy para facilitar a manipulação
      weight_array = np.array(weight_list)

      # Calcula o número de linhas que o DataFrame terá
      num_rows = len(weight_list) // len(features_name)

      # Redimensiona o array para ter 'num_rows' linhas e 'len(features_name)' colunas
      reshaped_array = weight_array[:num_rows * len(features_name)].reshape(num_rows, len(features_name))

      # Cria o DataFrame a partir do array redimensionado
      df = pd.DataFrame(reshaped_array, columns=features_name)
      return df
  def get_final_result(self, wigths, feature):
      df = self.create_df(wigths, feature)
      self.ploat_heatmap(df)
      self.ploat_bar(df)
      df1 = self.weight_by_feature(self.features_name, df.Weight)
      df1.max().plot(kind='bar')
      df1.mean().plot(kind='bar')
      return df1
  def get_most_important_features(self, dataFrame, top_feature_number):

      df = self.weight_by_feature(self.features_name, dataFrame.Weight)

      newDf = df.mean().reset_index()
      newDf.columns = ['Column', 'Mean']
      newDf = newDf.sort_values(by='Mean', ascending=False)
      top_features = newDf.head(top_feature_number)
      return top_features

  def concat_all_mothods(self, means_lime, means_shap, means_grad):
      scaler = MinMaxScaler(feature_range=(-1, 1))
      # Calculando as médias das colunas
      means_lime['Mean'] = scaler.fit_transform(means_lime[['Mean']])
      means_shap['Mean'] = scaler.fit_transform(means_shap[['Mean']])
      means_grad['Mean'] = scaler.fit_transform(means_grad[['Mean']])

      # # Renomeando as colunas para facilitar a concatenação
      # means_lime.columns = ['Column', 'Mean']
      # means_shap.columns = ['Column', 'Mean']
      # means_grad.columns = ['Column', 'Mean']
      print(means_lime)
      # Adicionando uma coluna para identificar o dataframe
      means_lime['DataFrame'] = 'LIME'
      means_shap['DataFrame'] = 'SHAP'
      means_grad['DataFrame'] = 'GRAD'

      # Concatenando os dataframes
      means_all = pd.concat([means_lime, means_shap, means_grad])
      print(means_all)
      # means_all['Mean'] = scaler.fit_transform(means_all[['Mean']])
      self.plot_bar_chart_all_methods(means_all)
      return means_all, means_lime, means_shap, means_grad

  def get_top_features(self,means_lime,means_shap,means_grad,top_index ):
      df_sorted_1 = means_lime.sort_values(by='Mean', ascending=False)
      df_sorted_2 = means_shap.sort_values(by='Mean', ascending=False)
      df_sorted_3 = means_grad.sort_values(by='Mean', ascending=False)

      # Selecionar as três primeiras linhas
      top_3_1 = df_sorted_1.head(top_index)
      top_3_2 = df_sorted_2.head(top_index)
      top_3_3 = df_sorted_3.head(top_index)

      return top_3_1, top_3_2, top_3_3
  def get_features_in_common(self, df1, df2, df3):
      means_all, means_lime, means_shap, means_grad = self.concat_all_mothods(df1, df2, df3)
      top_3_1, top_3_2, top_3_3 = self.get_top_features(means_lime, means_shap, means_grad, 3 )
      # Adicionar uma coluna de peso a cada DataFrame. Para remover os pesos, comentar essas linhas e a de média ponderada com o [X]
      # top_3_1['Peso'] = 0.25
      # top_3_2['Peso'] = 0.35
      # top_3_3['Peso'] = 0.40

      df_concat = pd.concat([top_3_1, top_3_2, top_3_3])
      # Contar a frequência de cada valor na coluna 'Coluna'
      coluna_counts = df_concat['Column'].value_counts()
      # Filtrar os valores que aparecem mais de uma vez
      repeated_sensors = coluna_counts[coluna_counts > 1].index
      # Criar um novo DataFrame com as linhas que têm 'Coluna' repetida
      df_final = df_concat[df_concat['Column'].isin(repeated_sensors)]

      # Calcular a média ponderada para cada valor na coluna 'Coluna' [X]
      # df_final = df_final.groupby('Column').apply(lambda x: (x['Peso'] * x.drop(columns=['Peso'])).sum() / x['Peso'].sum())

      df_final = df_final.sort_values(by='Column')
      return df_final
  def get_sensor_repeats(self, df_final):
      coluna_counts = df_final['Column'].value_counts()
      df_counts = pd.DataFrame({
          'Sensors': coluna_counts.index,
          'Repeats': coluna_counts.values
      })
      df_counts = df_counts.sort_values(by='Sensors').reset_index(drop=True)
      return df_counts

  def get_dict_by_query(self, query_sql, conn):
      cursor = conn.cursor()
      cursor.execute(query_sql)
      conn.commit()
      result = cursor.fetchall()
      result_dict = {}
      for data, category, count in result:
          if data not in result_dict:
              result_dict[data] = {}
          result_dict[data][category] = count
      return result_dict, result

  # Função para verificar compatibilidade e retornar os 3 fungos mais compatíveis
  def find_top_compatible_fungi(self, sensor,fungi_dict,sensor_dict ):
      sensor_categories = sensor_dict.get(sensor, {})
      compatibility = []

      for fungus, categories in fungi_dict.items():
          common_categories = set(sensor_categories.keys()).intersection(categories.keys())
          if len(common_categories) >= 1:
              common_details = {category: (sensor_categories[category], categories[category]) for category in common_categories}
              compatibility.append((fungus, len(common_categories), common_details))

      # Ordenar por número de categorias compatíveis (maior para menor)
      compatibility.sort(key=lambda x: x[1], reverse=True)

      # Ordenar as categorias compatíveis por quantidade de repetições (maior para menor)
      for i in range(len(compatibility)):
          compatibility[i] = (compatibility[i][0], compatibility[i][1], dict(sorted(compatibility[i][2].items(), key=lambda item: item[1][1], reverse=True)))

      # Retornar os 3 fungos mais compatíveis
      return compatibility[:3]

  def print_result(self, sensor,fungi_dict, sensor_dict, df_caounts ):
      for sensor in df_caounts.Sensors:
          print(sensor)
          top_fungi = self.find_top_compatible_fungi(sensor,fungi_dict,sensor_dict)
          print(f"Os 3 fungos mais compatíveis com o sensor {sensor} são:")
          for fungus, count, details in top_fungi:
              print(f"{fungus} com {count} categorias compatíveis:")
              for category, (sensor_count, fungus_count) in details.items():
                  print(f"  - {category}: Sensor ({sensor_count} vezes), Fungo ({fungus_count} vezes)")
              print()

  def plot_bar_chart_all_methods(self, dataFrame):
      sns.set(style="whitegrid")
      # Criando a figura com maior DPI e tamanho
      plt.figure(figsize=(12, 8), dpi=200)

      # Criando o gráfico de barras
      sns.barplot(x='Column', y='Mean', hue='DataFrame', data=dataFrame, palette='viridis')

      # Adicionando labels e título
      plt.xlabel('Sensors', fontweight='bold')
      plt.ylabel('Average values', fontweight='bold')
      plt.title('DataFrame Sensors Averages')

      # Rotacionando os nomes das colunas no eixo X
      plt.xticks(rotation=45)

      # Exibindo o gráfico
      plt.show()
  # Adicionando uma coluna de índice para o eixo X
  def plot_chart_line_df(self, dataFrame):
      dataFrame['Index'] = dataFrame.index
      # Transformando o DataFrame para o formato longo
      df_long = pd.melt(dataFrame, id_vars=['Index'], var_name='Sensor', value_name='Valor')
      # Plotando o gráfico de linha
      plt.figure(figsize=(14, 8))
      sns.lineplot(data=df_long, x='Index', y='Valor', hue='Sensor')
      plt.xlabel('Index')
      plt.ylabel('Value')
      plt.title('Line chart sensors')
      plt.legend(title='Sensors')
      plt.show()

  def ploat_heatmap(self, dataFrame):
      fig = px.density_heatmap(dataFrame, x='Feature', y='Weight', nbinsx=20, nbinsy=20, color_continuous_scale='Viridis')
      # Atualizar o layout para permitir zoom e melhor visualização
      fig.update_layout(
          title='Dynamic Heatmap of Feature Weightss',
          xaxis=dict(title='Feature'),
          yaxis=dict(title='Weight'),
          autosize=True
      )
      # Mostrar o mapa de calor
      fig.show()

  def ploat_bar(self, dataFrame):
      fig = px.bar(dataFrame, x='Feature', y='Weight', color='Weight', color_continuous_scale='Viridis')
      # Atualizar o layout para permitir zoom e melhor visualização
      fig.update_layout(
          title='Feature Weights Bar Chart',
          xaxis=dict(title='Feature'),
          yaxis=dict(title='Weight'),
          autosize=True
      )
      # Mostrar o gráfico de barras
      fig.show()

"""# **LIME METHOD**"""

class LIME_Method:
  def __init__(self):
    pass
  def run_LIME_Method(self, train_data, test_data,sample_index, model, class_names):
    feature_names = np.array(range(train_data.shape[1]))
    explainer = lime.lime_tabular.LimeTabularExplainer(
        training_data=train_data,
        feature_names=feature_names,
        class_names=class_names,
        discretize_continuous=False  # ou True, dependendo da natureza dos seus dados
    )
    num_features = train_data.shape[1]
    print(num_features)
    instance = test_data[sample_index]
    explanation = explainer.explain_instance(
        instance,
        model.predict,
        num_features= num_features
    )
    # Print the explanation
    # for feature, weight in explanation.as_list():
    #     print(f'{feature}: {weight}')
    feature , weigths = zip(*sorted(explanation.as_list()))
    weigths_array = np.array(weigths)
    # weigths_matriz = weigths_array.reshape(1, -1)  # Redimensiona para 1 linha e 'n' colunas
    return explanation, feature , weigths_array

  def run_mulltiple_LIME_Method(self, train_data, test_data,sample_index, model, class_names,REPEATS):
      df_results = []
      for i in range(REPEATS):
          print(f"Cicle: {i+1} of {REPEATS}...")
          explanation, index, wiegths = self.run_LIME_Method(train_data=X_train, test_data=X_test, sample_index=SAMPLE_INDEX, model=model, class_names=class_names)
          dataFrame = api.create_df(wiegths, index)
          df = api.get_most_important_features(dataFrame, 3)
          df_results.append(df)
      return self.summarize_df(df_results)

  def summarize_df(self, df_results):
      #Colocar isso em um método
      df_final = pd.concat(df_results, ignore_index=True)
      # Contar a frequência de cada valor na coluna 'Coluna'
      coluna_counts = df_final['Column'].value_counts()

      # Filtrar os valores que aparecem mais de uma vez
      repeated_sensors = coluna_counts[coluna_counts > 1].index

      # Criar um novo DataFrame com as linhas que têm 'Coluna' repetida
      df_final = df_final[df_final['Column'].isin(repeated_sensors)]

      # Calcular a média das colunas repetidas
      df_final = df_final.groupby('Column').mean().reset_index()

      df_final = df_final.sort_values(by='Column')
      return df_final

  def plot_bar_chart(self, df_final):
      # Configurar o estilo do Seaborn
      sns.set(style="whitegrid")

      # Criar o gráfico de barras
      plt.figure(figsize=(12, 8))
      barplot = sns.barplot(x='Column', y='Mean', hue='Column', data=df_final, palette='viridis')

      # Adicionar títulos e rótulos
      plt.xlabel('Sensores')
      plt.ylabel('Média')
      plt.title('Média dos Sensores para o método LIME')

      # Mostrar o gráfico
      plt.show()

  def plot_class_proba(self, class_name, explanation):
      # Seus valores de probabilidade
      probabilidades = explanation.predict_proba
      # Nomes das classes para o eixo x do gráfico
      plt.figure(figsize=(10, 7))  # Ajuste o tamanho conforme necessário
      # Criar o gráfico de barras
      plt.bar(class_name, probabilidades)

      # Adicionar título e rótulos aos eixos
      plt.title('Probabilidades das Classes')
      plt.xlabel('Classes')
      plt.ylabel('Probabilidade')

      # Mostrar o gráfico
      plt.show()

# df1 = api.get_final_result(wieghts,index)

"""# **SHAP METHOD**"""

class SHAP_Method:
  def __init__(self, features_name):
    self.features_name = features_name
  def run_SHAP_Method(self, train_data, test_data,sample_index, model, class_names, npermutations):
    # explainer = shap.KernelExplainer(model.predict, train_data)
    explainer = shap.PermutationExplainer(model.predict, train_data)
    shap_values = explainer.shap_values(test_data[sample_index-1:sample_index], npermutations=npermutations)

    predicted_classes, main_class_predicted = self.get_model_results(model, test_data,sample_index )

    feature_weights = shap_values[:,:,main_class_predicted][0]  # Sum weights across classes #max_index
    # culture_time_weigth = np.mean(feature_weights[0][train_data.shape[1]])
    # mean_array = np.mean(feature_weights, axis=2)
    # mean_array = mean_array[0][:-1]
    # df2 = api.weight_by_feature(self.features_name, mean_array,culture_time_weigth)
    return explainer,shap_values, feature_weights
  def run_mulltiple_SHAP_Method(self, train_data, test_data,sample_index, model, class_names,REPEATS, npermutations):
      df_results = []
      for i in range(REPEATS):
          print(f"Cicle: {i+1} of {REPEATS}...")
          explanation, index, wieghts = self.run_SHAP_Method(train_data=X_train, test_data=X_test, sample_index=SAMPLE_INDEX, model=model, class_names=class_names, npermutations=npermutations)
          dataFrame = api.create_df_2(wieghts)
          df = api.get_most_important_features(dataFrame, 3)
          df_results.append(df)
      return self.summarize_df(df_results)

  def get_model_results(self, model, test_data, sample_index):
      predicted_classes = model.predict(test_data[sample_index-1:sample_index])
      main_class_predicted = np.argmax(predicted_classes)
      return predicted_classes, main_class_predicted

  def summarize_df(self, df_results):
      #Colocar isso em um método
      df_final = pd.concat(df_results, ignore_index=True)
      # Contar a frequência de cada valor na coluna 'Coluna'
      coluna_counts = df_final['Column'].value_counts()

      # Filtrar os valores que aparecem mais de uma vez
      repeated_sensors = coluna_counts[coluna_counts > 1].index

      # Criar um novo DataFrame com as linhas que têm 'Coluna' repetida
      df_final = df_final[df_final['Column'].isin(repeated_sensors)]

      df_final = df_final.sort_values(by='Column')
      return df_final

  def plot_bar_chart(self, df_final):
      # Configurar o estilo do Seaborn
      sns.set(style="whitegrid")

      # Criar o gráfico de barras
      plt.figure(figsize=(12, 8))
      barplot = sns.barplot(x='Column', y='Mean', hue='Column', data=df_final, palette='viridis')

      # Adicionar títulos e rótulos
      plt.xlabel('Sensores')
      plt.ylabel('Média')
      plt.title('Média dos Sensores para o método LIME')

      # Mostrar o gráfico
      plt.show()

"""# **GRAD-CAM**"""

class GRAD_CAM_Method:
  def __init__(self, features_name):
    self.features_name = features_name

  def run_GRAD_CAM_Method(self, test_data,sample_index, model, class_names, last_layer_name):
      # Selecionar uma instância para visualização
      instancia = test_data[sample_index]  # Por exemplo, a primeira instância do conjunto de dados

      # Preparar a instância para o modelo (adicionar uma dimensão extra se necessário)
      instancia = np.expand_dims(instancia, axis=0)

      # Obter a saída do modelo para a instância selecionada
      predicao = model.predict(instancia)

      # Obter a classe prevista
      classe_prevista = np.argmax(predicao[0])

      # Obter o output do último layer convolucional
      ultimo_conv_layer = model.get_layer(last_layer_name) #em alguns casos, isso pode mudar

      # Criar um modelo para Grad-CAM
      grad_model = tf.keras.models.Model(
          [model.inputs],
          [ultimo_conv_layer.output, model.output]
      )

      # Obter os gradientes da classe prevista em relação ao último layer convolucional
      with tf.GradientTape() as tape:
          conv_outputs, predictions = grad_model(instancia)
          predictions = tf.convert_to_tensor(predictions)  # Converter para tensor

          # Verificar o tamanho de predictions
          num_classes = predictions.shape[-1]
          if classe_prevista >= num_classes:
              raise ValueError(f"Classe prevista ({classe_prevista}) está fora dos limites (0 a {num_classes-1}).")

          # Garantir que predictions é um tensor de float32
          predictions = tf.cast(predictions, tf.float32)
          print(predictions[0][0][classe_prevista])
          loss = predictions[0][0][classe_prevista] #predictions[:, classe_prevista]

      # Gradientes em relação à saída do último layer convolucional
      grads = tape.gradient(loss, conv_outputs)[0]

      # Média ponderada dos canais da saída do layer convolucional
      pooled_grads = tf.reduce_mean(grads, axis=(0, 1))

      # Multiplicar cada canal na saída do feature map pelo "importance" desse canal
      heatmap = tf.reduce_sum(tf.multiply(pooled_grads, conv_outputs[0]), axis=-1)
      return heatmap, grad_model, classe_prevista
      # Normalizar o heatmap
      # heatmap = np.maximum(heatmap, 0) / np.max(heatmap)
      # heatmap
  def summarize_df(self, df_results):
      # Concatenar os DataFrames
      df_final = pd.concat(df_results, ignore_index=True)

      # Contar a frequência de cada valor na coluna 'Coluna'
      coluna_counts = df_final['Column'].value_counts()

      # Filtrar os valores que aparecem mais de uma vez
      repeated_sensors = coluna_counts[coluna_counts > 1].index

      # Criar um novo DataFrame com as linhas que têm 'Coluna' repetida
      df_final = df_final[df_final['Column'].isin(repeated_sensors)]

      # Calcular a média das colunas repetidas
      df_final = df_final.groupby('Column').mean().reset_index()

      # Ordenar o DataFrame final pela coluna 'Coluna'
      df_final = df_final.sort_values(by='Column')

      return df_final

  def run_mulltiple_GRAD_Method(self, test_data,sample_index, model, class_names,REPEATS, last_layer_name):
      df_results = []
      for i in range(REPEATS):
          print(f"Cicle: {i+1} of {REPEATS}...")
          heatmap, grad_model, classe_prevista = self.run_GRAD_CAM_Method(test_data=X_test, sample_index=SAMPLE_INDEX, model=model, class_names=class_names,last_layer_name=last_layer_name)
          dataFrame = api.create_df_2(heatmap)
          df = api.get_most_important_features(dataFrame, 3)
          df_results.append(df)
      return self.summarize_df(df_results)
  def plot_heatmap(self, df_grad):
      # Criar o gráfico de barras
      fig = px.bar(df_grad, x='Feature', y='Weight', color='Weight', color_continuous_scale='Viridis')

      # Atualizar o layout para permitir zoom e melhor visualização
      fig.update_layout(
          title='Gráfico de Barras dos Pesos das Características (Grad-CAM)',
          xaxis=dict(title='Feature'),
          yaxis=dict(title='Weight'),
          autosize=True
      )

      # Mostrar o gráfico de barras
      fig.show()

  def plot_bar_chart(self, df_final):
      # Configurar o estilo do Seaborn
      sns.set(style="whitegrid")

      # Criar o gráfico de barras
      plt.figure(figsize=(12, 8))
      barplot = sns.barplot(x='Column', y='Mean', hue='Column', data=df_final, palette='viridis')

      # Adicionar títulos e rótulos
      plt.xlabel('Sensores')
      plt.ylabel('Média')
      plt.title('Média dos Sensores para o método LIME')

      # Mostrar o gráfico
      plt.show()

# dataFrame = api.create_df_2(list_weights=heatmap)
# df_GRAD = api.get_most_important_features(dataFrame, 3)