<kaggle_start><data_title>ECG Heartbeat Categorization Dataset<data_description># Context

# ECG Heartbeat Categorization Dataset

## Abstract

This dataset is composed of two collections of heartbeat signals derived from two famous datasets in heartbeat classification, [the MIT-BIH Arrhythmia Dataset](https://www.physionet.org/physiobank/database/mitdb/) and [The PTB Diagnostic ECG Database](https://www.physionet.org/physiobank/database/ptbdb/). The number of samples in both collections is large enough for training a deep neural network. 

This dataset has been used in exploring heartbeat classification using deep neural network architectures, and observing some of the capabilities of transfer learning on it. The signals correspond to electrocardiogram (ECG) shapes of heartbeats for the normal case and the cases affected by different arrhythmias and myocardial infarction. These signals are preprocessed and segmented, with each segment corresponding to a heartbeat.

## Content

### Arrhythmia Dataset
<ul>
    <li> Number of Samples: 109446</li>
    <li> Number of Categories: 5</li>
    <li> Sampling Frequency: 125Hz</li>
    <li> Data Source: Physionet's MIT-BIH Arrhythmia Dataset</li>
    <li> Classes: ['N': 0, 'S': 1, 'V': 2, 'F': 3, 'Q': 4]</li>
</ul>

### The PTB Diagnostic ECG Database
<ul>
    <li> Number of Samples: 14552</li>
    <li> Number of Categories: 2</li>
    <li> Sampling Frequency: 125Hz</li>
    <li> Data Source: Physionet's PTB Diagnostic Database</li>
</ul>


**Remark**: *All the samples are cropped, downsampled and padded with zeroes if necessary to the fixed dimension of 188*.

## Data Files

This dataset consists of a series of CSV files. Each of these CSV files contain a matrix, with each row representing an example in that portion of the dataset. The final element of each row denotes the class to which that example belongs.

## Acknowledgements

Mohammad Kachuee, Shayan Fazeli, and Majid Sarrafzadeh. "ECG Heartbeat Classification: A Deep Transferable Representation." [*arXiv preprint arXiv:1805.00794 (2018)*](https://arxiv.org/abs/1805.00794).

# Inspiration

Can you identify myocardial infarction?<data_name>heartbeat
<code># # TODO-List
# * Read train and test data
# * Data preprocessing (how data should look like to be passed to RNN model)
# * Create simple(for the first time) RNN class in PyTorch
# * Implement Genetic Algorithm for weights update
import numpy as np  # linear algebra
import pandas as pd  # data processing, CSV file I/O (e.g. pd.read_csv)

# Input data files are available in the read-only "../input/" directory
# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory
import os

for dirname, _, filenames in os.walk("/kaggle/input"):
    for filename in filenames:
        print(os.path.join(dirname, filename))
# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using "Save & Run All"
# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session
# '/kaggle/input/heartbeat/ptbdb_abnormal.csv'
# '/kaggle/input/heartbeat/ptbdb_normal.csv'
path_test = "/kaggle/input/heartbeat/mitbih_test.csv"
path_train = "/kaggle/input/heartbeat/mitbih_train.csv"
df_train = pd.read_csv(path_train, header=None)
df_test = pd.read_csv(path_test, header=None)
df_train.head()

# # Data Preprocessing
# * Normalizing (to converge faster)
# * Resampling (each category appears equally in training)
# * Transforming? (Fourier/Spectral analysis)
from sklearn.utils import resample
import torch.utils.data as data_utils

df_train[187] = df_train[187].astype(int)
df_test[187] = df_test[187].astype(int)
df_normal = df_train[df_train[187] == 0]
df_supraventricular = df_train[df_train[187] == 1]
df_ventricular = df_train[df_train[187] == 2]
df_fusion = df_train[df_train[187] == 3]
df_unknown = df_train[df_train[187] == 4]
n = 10000
normal_train = resample(df_normal, replace=True, n_samples=n)
supraventricular_train = resample(df_supraventricular, replace=True, n_samples=n)
ventricular_train = resample(df_ventricular, replace=True, n_samples=n)
fusion_train = resample(df_fusion, replace=True, n_samples=n)
unknown_train = resample(df_unknown, replace=True, n_samples=n)
train = pd.concat(
    (
        normal_train,
        supraventricular_train,
        ventricular_train,
        fusion_train,
        unknown_train,
    )
)
X_train = train.iloc[:, 0:-1]
y_train = train.iloc[:, -1]
print(train[187].value_counts())
print(train.shape)
# ecg values vary from 0.0 to 1.0, so normalizing is not necessary
print(train.min(), train.max())

# # Classification using RNNs
# * Simple Vanilla RNN
# * LSTM
# * GRU
# * ConvRNN
# * Own architecture
# importing necessary modules
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader


class EcgSequentialLoader(Dataset):
    def __init__(self, data, seq_len, input_size, num_classes):
        device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.data = torch.tensor(data.to_numpy(), dtype=torch.float32).to(device=device)
        self.seq_len = seq_len
        self.input_size = input_size
        self.num_classes = num_classes

    def __len__(self):
        #         return len(self.data) // self.seq_len
        return len(self.data)

    def __getitem__(self, index):
        x = self.data[index, :-1]
        y = self.data[index, -1].floor().long()
        x = x.reshape(-1, self.seq_len, self.input_size)
        # OH-encoded ECG classification categories
        #         y = torch.nn.functional.one_hot(y.to(torch.int64), num_classes=self.num_classes)
        # print(x.shape, y.shape)
        return x, y


class VanillaRNN(nn.Module):
    def __init__(self, input_size, hidden_size, num_classes):
        super(VanillaRNN, self).__init__()
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.hidden_size = hidden_size  # hidden state size
        # rnn
        self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
        # fully connected layer (goes after rnn computations)
        # for inputs is the last hidden state of the rnn, output logits to softmax
        self.fc_layer = nn.Linear(hidden_size, num_classes)

    #         self.sm_layer = nn.Softmax(dim=1)
    def forward(self, x):
        # because dataloader adds new axis=1 and we get 4D tensor
        x = x.squeeze(1)
        # initial state (null-state)
        h0 = self.init_hidden(x.size(0)).to(self.device)
        out, hidden = self.rnn(x, h0)
        logits = self.fc_layer(hidden[-1, :, :])
        #         out = self.sm_layer(logit)
        return logits

    def init_hidden(self, batch_size):
        hidden = torch.zeros(1, batch_size, self.hidden_size)
        return hidden


class ClassifierLSTM:
    def __init__():
        pass


class ClassifierGRU:
    def __init__():
        pass


class ClassifierConvRNN:
    def __init__():
        pass


class OwnArchitectureRNN:
    def __init__(self):
        pass


def get_train_loader(dataset, batch_size):
    return DataLoader(dataset=dataset, batch_size=batch_size, shuffle=True)


batch_size = 32
input_size = 11  # input size of input vectors
seq_len = 17  # iterations inside RNN
hidden_size = 10
num_classes = 5
ecg_dataset = EcgSequentialLoader(train, seq_len, input_size, num_classes)
dataloader = get_train_loader(ecg_dataset, batch_size)
vrnn = VanillaRNN(input_size, hidden_size, num_classes)
# vrnn.forward(few_signals)
for batch_idx, (x, y) in enumerate(dataloader):
    print(x.shape, y.shape)
    print(y)
    print(vrnn.forward(x))
    break
vrnn.state_dict()


# # Implementation of Genetic Algorithm Helper functions
# * Create initial population
# * Individual evaluation using environmental function (CrossEntropy or LogSoftmax?)
# * Selection (choose parents of the next generation)
# * Crossing-over (biology) of chromosomes
# * Mutations
# imports
import numpy as np
import random
import itertools
import functools
from typing import Callable, Iterable, Tuple, List, Union


def selection():
    pass


def crossover():
    pass


def mutation():
    pass


class GeneticAlgorithm:
    def __init__(
        self,
        model_class,
        model_params: Union[Tuple, List],
        population_size: int,
        mutation_rate: float,
        selection_method=None,
        crossover_method=None,
        mutation_method=None,
    ):
        """
        Genetic Algorithm parameters description
        population_size:
        mutation_rate:
        generations:
        environment_func:
        selection_method:
        crossover_method:
        mutation_method:
        """
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.model_class = model_class
        self.model_params = model_params
        self.population_size = population_size
        self.mutation_rate = mutation_rate
        self.selection_method = selection_method
        self.crossover_method = crossover_method
        self.mutation_method = mutation_method
        # ======
        self.population = []  # models
        self.fitness_values = (
            []
        )  # corresponding (by index) fitness values for each individual
        self.parent_pairs = []

    # ==========================================
    def init_population(self):
        self.population = [
            self.model_class(*self.model_params).to(device=self.device)
            for _ in range(self.population_size)
        ]

    def fitness_evaluation(self, train_loader, individual, fitness_function):
        # fitness_function (e.g. torch.nn.CrossEntropyLoss)
        total_loss = 0
        for x, y in train_loader:
            out = individual.forward(x)
            #             print(out.shape, y.shape)
            #             print(type(out), type(y))
            loss = fitness_function(out, y)
            total_loss += loss.item()
        # Compute the average loss and convert to fitness score
        avg_loss = total_loss / len(train_loader)
        # fitness_score = 1 / (1 + avg_loss)
        # return fitness_score
        return avg_loss

    def selection(self):
        # Implement selection method here
        pass

    def crossover(self):
        # Implement crossover method here
        pass

    def mutation(self):
        # Implement mutation method here
        pass

    def evolve(self, train_loader, n_generations, fitness_function, strategy):
        # Implement genetic algorithm methods here
        # returns best model after all passed generations
        self.init_population()
        for i in range(n_generations):
            # evaluation (compute fitness value for each individual)
            self.fitness_values = list(
                map(
                    lambda individual: self.fitness_evaluation(
                        train_loader, individual, fitness_function
                    ),
                    self.population,
                )
            )
            # print(self.fitness_values)
            print(f"gen {i}.....best fitness value", min(self.fitness_values))
            # selection (form parent pairs)
            parent_pairs = self.selection_tournament(strategy)
            # crossover (for each parent pair compute children)
            offspring = list(
                map(lambda ps: self.crossover_weights_exchange(*ps), parent_pairs)
            )
            # print(type(offspring), type(offspring[0]))
            offspring = list(itertools.chain(*offspring))
            # print(type(offspring), type(offspring[0]))
            # mutation
            offspring = list(
                map(lambda child: self.mutation_gaussian(child), offspring)
            )
            # replace existing population with a new one
            self.population = offspring.copy()
        # return the fittest
        return

    # ==========================================
    def mutation_gaussian(self, model, mean=0.0, std=0.033):
        weights = self.model_get_weights(model)
        keys_ = model.state_dict().keys()
        # 0 - no mutation, 1 - mutate
        mutate = [
            (torch.rand(w.shape).to(device=self.device) < self.mutation_rate).int()
            for w in weights
        ]
        gaussian = [
            torch.normal(
                mean=torch.ones(w.shape) * mean, std=torch.ones(w.shape) * std
            ).to(device=self.device)
            for w in weights
        ]
        # Hadamard product of gaussian values and mutations
        mutation_values = [mut * gauss for mut, gauss in zip(mutate, gaussian)]
        # add small mutation values to child's weights
        new_weights = [w + m for w, m in zip(weights, mutation_values)]
        model = self.model_update_weights(model, new_weights)
        return model

    def mutation_uniform(self, model, lo, hi):
        weights = self.model_get_weights(model)
        keys_ = model.state_dict().keys()
        # 0 - no mutation, 1 - mutate
        mutate = [(torch.rand(w.shape) < self.mutation_rate).int() for w in weights]
        uniform = [(hi - lo) * (torch.rand(w.shape)) + lo for w in weights]
        # Hadamard product of gaussian values and mutations
        mutation_values = [mut * uni for mut, uni in zip(mutate, uniform)]
        # add small mutation values to child weights
        new_weights = [w + m for w, m in zip(weights, mutation_values)]
        model = self.model_update_weights(model, new_weights)
        return model

    def mutation_bit_flip(self):
        pass

    # =============================
    def crossover_weights_exchange(self, parent1, parent2):
        p1_weights = self.model_get_weights(parent1)
        p2_weights = self.model_get_weights(parent2)
        # print(type(p1_weights))
        assert len(p1_weights) == len(p2_weights)
        # p1 = [w1, w2, ..., wn]
        # p2 = [v1, v2, ..., vn]
        # initialize children models
        child1 = self.model_class(*self.model_params).to(device=self.device)
        child2 = self.model_class(*self.model_params).to(device=self.device)
        c1_new_weights = []
        c2_new_weights = []
        for i in range(len(p1_weights)):
            # probabilities for each child to inherit some gene (weight)
            rand1 = torch.rand(1)
            rand2 = torch.rand(1)
            if rand1 < 0.5:
                c1_new_weights.append(p1_weights[i])
            else:
                c1_new_weights.append(p2_weights[i])
            if rand2 < 0.5:
                c2_new_weights.append(p1_weights[i])
            else:
                c2_new_weights.append(p2_weights[i])
        # assign new weights to children
        child1 = self.model_update_weights(child1, c1_new_weights)
        child2 = self.model_update_weights(child2, c2_new_weights)
        # print(type(child1), type(child2))
        return child1, child2

    # ============================
    @staticmethod
    def selection_random():
        pass

    def selection_roulette(self):
        pass

    def selection_tournament(self, strategy):
        tournament_pool = self.population.copy()
        fit_values = self.fitness_values.copy()
        zipped = zip(tournament_pool, fit_values)
        sorted_zipped = sorted(
            zipped, key=lambda x: x[1], reverse=False if strategy == "min" else True
        )
        # sort tournament_pool by fitness value (in asc order)
        # sorted_ = sorted(tournament_pool, key=lambda k: fit_values[k], reverse=False if strategy=='min' else True)
        # generate pairs of the fittest individuals
        sorted_ = [individual for individual, _ in sorted_zipped]
        pairs = [(sorted_[2 * i], sorted_[2 * i + 1]) for i in range(len(sorted_) // 2)]
        return pairs

    # ====== HELPER METHODS =======
    @staticmethod
    def model_get_weights(model) -> List[torch.tensor]:
        # get state dictionary
        return list(model.state_dict().values())

    @staticmethod
    def model_update_weights(model, new_weights):  # -> nn.Module inherited obj
        state = model.state_dict()
        keys_ = state.keys()
        assert len(keys_) == len(new_weights)
        new_state = dict(zip(keys_, new_weights))
        model.load_state_dict(new_state)
        return model

    # ======== additional ideas
    # use when some fuzzy rule (e.g. population stuck at local extrema)
    def genocide(self):
        # reinitialize some population
        pass

    # ????
    def regulizer(self):
        pass


batch_size = 1024
input_size = 11  # size of input vectors
seq_len = 17  # iterations inside RNN
hidden_size = 20
num_classes = 5
ecg_dataset = EcgSequentialLoader(train, seq_len, input_size, num_classes)
dataloader = get_train_loader(ecg_dataset, batch_size)
# GA parameters
population_size = 50
mutation_rate = 3e-2  # mutation probability
n_generations = 50
fitness_function = nn.CrossEntropyLoss()
vanilla_rnn_params = (input_size, hidden_size, num_classes)
gen_alg_1 = GeneticAlgorithm(
    VanillaRNN, vanilla_rnn_params, population_size, mutation_rate
)
gen_alg_1.evolve(dataloader, n_generations, fitness_function, "min")
vrnn.state_dict()


# # RNN Training using Genetic Algorithms (GA)
# Questions:
# * How to update weights using GA?
# * What to modify in already existing GA implementations?
# * What should i use as chromosomes in GA?
# Genetic Algorithm:
# 1. Initialize population
# 2. Measure each individual's cost in environment(some error function)
# 3. Select best individuals
# 4. Generate new population from the best genes adding some mutations
# 5. Repeat steps 2-4 until the last epoch (generation) and select the best one of them
# GA parameters
population_sz = 20
mutation_rate = 1e-1  # mutation probability
n_generations = 10


def simple_genetic_algorithm(data):
    # initialize population
    # run loop for generations
    # evaluate population
    # select best
    # crossing-over parents to create new population with some mutations
    return
<|endoftext|>
[{"Id": 121651668, "ScriptId": 35750855, "ParentScriptVersionId": NaN, "ScriptLanguageId": 9, "AuthorUserId": 6614265, "CreationDate": "03/10/2023 09:01:12", "VersionNumber": 1.0, "Title": "ecg_rnn_genetic_algorithm_classification", "EvaluationDate": "03/10/2023", "IsChange": true, "TotalLines": 460.0, "LinesInsertedFromPrevious": 460.0, "LinesChangedFromPrevious": 0.0, "LinesUnchangedFromPrevious": 0.0, "LinesInsertedFromFork": NaN, "LinesDeletedFromFork": NaN, "LinesChangedFromFork": NaN, "LinesUnchangedFromFork": NaN, "TotalVotes": 0}]
[{"Id": 174018505, "KernelVersionId": 121651668, "SourceDatasetVersionId": 37484}]
[{"Id": 37484, "DatasetId": 29414, "DatasourceVersionId": 39061, "CreatorUserId": 1700398, "LicenseName": "Unknown", "CreationDate": "05/31/2018 18:47:34", "VersionNumber": 1.0, "Title": "ECG Heartbeat Categorization Dataset", "Slug": "heartbeat", "Subtitle": "Segmented and Preprocessed ECG Signals for Heartbeat Classification", "Description": "# Context\n\n# ECG Heartbeat Categorization Dataset\n\n## Abstract\n\nThis dataset is composed of two collections of heartbeat signals derived from two famous datasets in heartbeat classification, [the MIT-BIH Arrhythmia Dataset](https://www.physionet.org/physiobank/database/mitdb/) and [The PTB Diagnostic ECG Database](https://www.physionet.org/physiobank/database/ptbdb/). The number of samples in both collections is large enough for training a deep neural network. \n\nThis dataset has been used in exploring heartbeat classification using deep neural network architectures, and observing some of the capabilities of transfer learning on it. The signals correspond to electrocardiogram (ECG) shapes of heartbeats for the normal case and the cases affected by different arrhythmias and myocardial infarction. These signals are preprocessed and segmented, with each segment corresponding to a heartbeat.\n\n## Content\n\n### Arrhythmia Dataset\n<ul>\n    <li> Number of Samples: 109446</li>\n    <li> Number of Categories: 5</li>\n    <li> Sampling Frequency: 125Hz</li>\n    <li> Data Source: Physionet's MIT-BIH Arrhythmia Dataset</li>\n    <li> Classes: ['N': 0, 'S': 1, 'V': 2, 'F': 3, 'Q': 4]</li>\n</ul>\n\n### The PTB Diagnostic ECG Database\n<ul>\n    <li> Number of Samples: 14552</li>\n    <li> Number of Categories: 2</li>\n    <li> Sampling Frequency: 125Hz</li>\n    <li> Data Source: Physionet's PTB Diagnostic Database</li>\n</ul>\n\n\n**Remark**: *All the samples are cropped, downsampled and padded with zeroes if necessary to the fixed dimension of 188*.\n\n## Data Files\n\nThis dataset consists of a series of CSV files. Each of these CSV files contain a matrix, with each row representing an example in that portion of the dataset. The final element of each row denotes the class to which that example belongs.\n\n## Acknowledgements\n\nMohammad Kachuee, Shayan Fazeli, and Majid Sarrafzadeh. \"ECG Heartbeat Classification: A Deep Transferable Representation.\" [*arXiv preprint arXiv:1805.00794 (2018)*](https://arxiv.org/abs/1805.00794).\n\n# Inspiration\n\nCan you identify myocardial infarction?", "VersionNotes": "Initial release", "TotalCompressedBytes": 582790600.0, "TotalUncompressedBytes": 103633674.0}]
[{"Id": 29414, "CreatorUserId": 1700398, "OwnerUserId": 1700398.0, "OwnerOrganizationId": NaN, "CurrentDatasetVersionId": 37484.0, "CurrentDatasourceVersionId": 39061.0, "ForumId": 37684, "Type": 2, "CreationDate": "05/31/2018 18:47:34", "LastActivityDate": "05/31/2018", "TotalViews": 479385, "TotalDownloads": 61615, "TotalVotes": 739, "TotalKernels": 204}]
# # TODO-List
# * Read train and test data
# * Data preprocessing (how data should look like to be passed to RNN model)
# * Create simple(for the first time) RNN class in PyTorch
# * Implement Genetic Algorithm for weights update
import numpy as np  # linear algebra
import pandas as pd  # data processing, CSV file I/O (e.g. pd.read_csv)

# Input data files are available in the read-only "../input/" directory
# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory
import os

for dirname, _, filenames in os.walk("/kaggle/input"):
    for filename in filenames:
        print(os.path.join(dirname, filename))
# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using "Save & Run All"
# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session
# '/kaggle/input/heartbeat/ptbdb_abnormal.csv'
# '/kaggle/input/heartbeat/ptbdb_normal.csv'
path_test = "/kaggle/input/heartbeat/mitbih_test.csv"
path_train = "/kaggle/input/heartbeat/mitbih_train.csv"
df_train = pd.read_csv(path_train, header=None)
df_test = pd.read_csv(path_test, header=None)
df_train.head()

# # Data Preprocessing
# * Normalizing (to converge faster)
# * Resampling (each category appears equally in training)
# * Transforming? (Fourier/Spectral analysis)
from sklearn.utils import resample
import torch.utils.data as data_utils

df_train[187] = df_train[187].astype(int)
df_test[187] = df_test[187].astype(int)
df_normal = df_train[df_train[187] == 0]
df_supraventricular = df_train[df_train[187] == 1]
df_ventricular = df_train[df_train[187] == 2]
df_fusion = df_train[df_train[187] == 3]
df_unknown = df_train[df_train[187] == 4]
n = 10000
normal_train = resample(df_normal, replace=True, n_samples=n)
supraventricular_train = resample(df_supraventricular, replace=True, n_samples=n)
ventricular_train = resample(df_ventricular, replace=True, n_samples=n)
fusion_train = resample(df_fusion, replace=True, n_samples=n)
unknown_train = resample(df_unknown, replace=True, n_samples=n)
train = pd.concat(
    (
        normal_train,
        supraventricular_train,
        ventricular_train,
        fusion_train,
        unknown_train,
    )
)
X_train = train.iloc[:, 0:-1]
y_train = train.iloc[:, -1]
print(train[187].value_counts())
print(train.shape)
# ecg values vary from 0.0 to 1.0, so normalizing is not necessary
print(train.min(), train.max())

# # Classification using RNNs
# * Simple Vanilla RNN
# * LSTM
# * GRU
# * ConvRNN
# * Own architecture
# importing necessary modules
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader


class EcgSequentialLoader(Dataset):
    def __init__(self, data, seq_len, input_size, num_classes):
        device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.data = torch.tensor(data.to_numpy(), dtype=torch.float32).to(device=device)
        self.seq_len = seq_len
        self.input_size = input_size
        self.num_classes = num_classes

    def __len__(self):
        #         return len(self.data) // self.seq_len
        return len(self.data)

    def __getitem__(self, index):
        x = self.data[index, :-1]
        y = self.data[index, -1].floor().long()
        x = x.reshape(-1, self.seq_len, self.input_size)
        # OH-encoded ECG classification categories
        #         y = torch.nn.functional.one_hot(y.to(torch.int64), num_classes=self.num_classes)
        # print(x.shape, y.shape)
        return x, y


class VanillaRNN(nn.Module):
    def __init__(self, input_size, hidden_size, num_classes):
        super(VanillaRNN, self).__init__()
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.hidden_size = hidden_size  # hidden state size
        # rnn
        self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
        # fully connected layer (goes after rnn computations)
        # for inputs is the last hidden state of the rnn, output logits to softmax
        self.fc_layer = nn.Linear(hidden_size, num_classes)

    #         self.sm_layer = nn.Softmax(dim=1)
    def forward(self, x):
        # because dataloader adds new axis=1 and we get 4D tensor
        x = x.squeeze(1)
        # initial state (null-state)
        h0 = self.init_hidden(x.size(0)).to(self.device)
        out, hidden = self.rnn(x, h0)
        logits = self.fc_layer(hidden[-1, :, :])
        #         out = self.sm_layer(logit)
        return logits

    def init_hidden(self, batch_size):
        hidden = torch.zeros(1, batch_size, self.hidden_size)
        return hidden


class ClassifierLSTM:
    def __init__():
        pass


class ClassifierGRU:
    def __init__():
        pass


class ClassifierConvRNN:
    def __init__():
        pass


class OwnArchitectureRNN:
    def __init__(self):
        pass


def get_train_loader(dataset, batch_size):
    return DataLoader(dataset=dataset, batch_size=batch_size, shuffle=True)


batch_size = 32
input_size = 11  # input size of input vectors
seq_len = 17  # iterations inside RNN
hidden_size = 10
num_classes = 5
ecg_dataset = EcgSequentialLoader(train, seq_len, input_size, num_classes)
dataloader = get_train_loader(ecg_dataset, batch_size)
vrnn = VanillaRNN(input_size, hidden_size, num_classes)
# vrnn.forward(few_signals)
for batch_idx, (x, y) in enumerate(dataloader):
    print(x.shape, y.shape)
    print(y)
    print(vrnn.forward(x))
    break
vrnn.state_dict()


# # Implementation of Genetic Algorithm Helper functions
# * Create initial population
# * Individual evaluation using environmental function (CrossEntropy or LogSoftmax?)
# * Selection (choose parents of the next generation)
# * Crossing-over (biology) of chromosomes
# * Mutations
# imports
import numpy as np
import random
import itertools
import functools
from typing import Callable, Iterable, Tuple, List, Union


def selection():
    pass


def crossover():
    pass


def mutation():
    pass


class GeneticAlgorithm:
    def __init__(
        self,
        model_class,
        model_params: Union[Tuple, List],
        population_size: int,
        mutation_rate: float,
        selection_method=None,
        crossover_method=None,
        mutation_method=None,
    ):
        """
        Genetic Algorithm parameters description
        population_size:
        mutation_rate:
        generations:
        environment_func:
        selection_method:
        crossover_method:
        mutation_method:
        """
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.model_class = model_class
        self.model_params = model_params
        self.population_size = population_size
        self.mutation_rate = mutation_rate
        self.selection_method = selection_method
        self.crossover_method = crossover_method
        self.mutation_method = mutation_method
        # ======
        self.population = []  # models
        self.fitness_values = (
            []
        )  # corresponding (by index) fitness values for each individual
        self.parent_pairs = []

    # ==========================================
    def init_population(self):
        self.population = [
            self.model_class(*self.model_params).to(device=self.device)
            for _ in range(self.population_size)
        ]

    def fitness_evaluation(self, train_loader, individual, fitness_function):
        # fitness_function (e.g. torch.nn.CrossEntropyLoss)
        total_loss = 0
        for x, y in train_loader:
            out = individual.forward(x)
            #             print(out.shape, y.shape)
            #             print(type(out), type(y))
            loss = fitness_function(out, y)
            total_loss += loss.item()
        # Compute the average loss and convert to fitness score
        avg_loss = total_loss / len(train_loader)
        # fitness_score = 1 / (1 + avg_loss)
        # return fitness_score
        return avg_loss

    def selection(self):
        # Implement selection method here
        pass

    def crossover(self):
        # Implement crossover method here
        pass

    def mutation(self):
        # Implement mutation method here
        pass

    def evolve(self, train_loader, n_generations, fitness_function, strategy):
        # Implement genetic algorithm methods here
        # returns best model after all passed generations
        self.init_population()
        for i in range(n_generations):
            # evaluation (compute fitness value for each individual)
            self.fitness_values = list(
                map(
                    lambda individual: self.fitness_evaluation(
                        train_loader, individual, fitness_function
                    ),
                    self.population,
                )
            )
            # print(self.fitness_values)
            print(f"gen {i}.....best fitness value", min(self.fitness_values))
            # selection (form parent pairs)
            parent_pairs = self.selection_tournament(strategy)
            # crossover (for each parent pair compute children)
            offspring = list(
                map(lambda ps: self.crossover_weights_exchange(*ps), parent_pairs)
            )
            # print(type(offspring), type(offspring[0]))
            offspring = list(itertools.chain(*offspring))
            # print(type(offspring), type(offspring[0]))
            # mutation
            offspring = list(
                map(lambda child: self.mutation_gaussian(child), offspring)
            )
            # replace existing population with a new one
            self.population = offspring.copy()
        # return the fittest
        return

    # ==========================================
    def mutation_gaussian(self, model, mean=0.0, std=0.033):
        weights = self.model_get_weights(model)
        keys_ = model.state_dict().keys()
        # 0 - no mutation, 1 - mutate
        mutate = [
            (torch.rand(w.shape).to(device=self.device) < self.mutation_rate).int()
            for w in weights
        ]
        gaussian = [
            torch.normal(
                mean=torch.ones(w.shape) * mean, std=torch.ones(w.shape) * std
            ).to(device=self.device)
            for w in weights
        ]
        # Hadamard product of gaussian values and mutations
        mutation_values = [mut * gauss for mut, gauss in zip(mutate, gaussian)]
        # add small mutation values to child's weights
        new_weights = [w + m for w, m in zip(weights, mutation_values)]
        model = self.model_update_weights(model, new_weights)
        return model

    def mutation_uniform(self, model, lo, hi):
        weights = self.model_get_weights(model)
        keys_ = model.state_dict().keys()
        # 0 - no mutation, 1 - mutate
        mutate = [(torch.rand(w.shape) < self.mutation_rate).int() for w in weights]
        uniform = [(hi - lo) * (torch.rand(w.shape)) + lo for w in weights]
        # Hadamard product of gaussian values and mutations
        mutation_values = [mut * uni for mut, uni in zip(mutate, uniform)]
        # add small mutation values to child weights
        new_weights = [w + m for w, m in zip(weights, mutation_values)]
        model = self.model_update_weights(model, new_weights)
        return model

    def mutation_bit_flip(self):
        pass

    # =============================
    def crossover_weights_exchange(self, parent1, parent2):
        p1_weights = self.model_get_weights(parent1)
        p2_weights = self.model_get_weights(parent2)
        # print(type(p1_weights))
        assert len(p1_weights) == len(p2_weights)
        # p1 = [w1, w2, ..., wn]
        # p2 = [v1, v2, ..., vn]
        # initialize children models
        child1 = self.model_class(*self.model_params).to(device=self.device)
        child2 = self.model_class(*self.model_params).to(device=self.device)
        c1_new_weights = []
        c2_new_weights = []
        for i in range(len(p1_weights)):
            # probabilities for each child to inherit some gene (weight)
            rand1 = torch.rand(1)
            rand2 = torch.rand(1)
            if rand1 < 0.5:
                c1_new_weights.append(p1_weights[i])
            else:
                c1_new_weights.append(p2_weights[i])
            if rand2 < 0.5:
                c2_new_weights.append(p1_weights[i])
            else:
                c2_new_weights.append(p2_weights[i])
        # assign new weights to children
        child1 = self.model_update_weights(child1, c1_new_weights)
        child2 = self.model_update_weights(child2, c2_new_weights)
        # print(type(child1), type(child2))
        return child1, child2

    # ============================
    @staticmethod
    def selection_random():
        pass

    def selection_roulette(self):
        pass

    def selection_tournament(self, strategy):
        tournament_pool = self.population.copy()
        fit_values = self.fitness_values.copy()
        zipped = zip(tournament_pool, fit_values)
        sorted_zipped = sorted(
            zipped, key=lambda x: x[1], reverse=False if strategy == "min" else True
        )
        # sort tournament_pool by fitness value (in asc order)
        # sorted_ = sorted(tournament_pool, key=lambda k: fit_values[k], reverse=False if strategy=='min' else True)
        # generate pairs of the fittest individuals
        sorted_ = [individual for individual, _ in sorted_zipped]
        pairs = [(sorted_[2 * i], sorted_[2 * i + 1]) for i in range(len(sorted_) // 2)]
        return pairs

    # ====== HELPER METHODS =======
    @staticmethod
    def model_get_weights(model) -> List[torch.tensor]:
        # get state dictionary
        return list(model.state_dict().values())

    @staticmethod
    def model_update_weights(model, new_weights):  # -> nn.Module inherited obj
        state = model.state_dict()
        keys_ = state.keys()
        assert len(keys_) == len(new_weights)
        new_state = dict(zip(keys_, new_weights))
        model.load_state_dict(new_state)
        return model

    # ======== additional ideas
    # use when some fuzzy rule (e.g. population stuck at local extrema)
    def genocide(self):
        # reinitialize some population
        pass

    # ????
    def regulizer(self):
        pass


batch_size = 1024
input_size = 11  # size of input vectors
seq_len = 17  # iterations inside RNN
hidden_size = 20
num_classes = 5
ecg_dataset = EcgSequentialLoader(train, seq_len, input_size, num_classes)
dataloader = get_train_loader(ecg_dataset, batch_size)
# GA parameters
population_size = 50
mutation_rate = 3e-2  # mutation probability
n_generations = 50
fitness_function = nn.CrossEntropyLoss()
vanilla_rnn_params = (input_size, hidden_size, num_classes)
gen_alg_1 = GeneticAlgorithm(
    VanillaRNN, vanilla_rnn_params, population_size, mutation_rate
)
gen_alg_1.evolve(dataloader, n_generations, fitness_function, "min")
vrnn.state_dict()


# # RNN Training using Genetic Algorithms (GA)
# Questions:
# * How to update weights using GA?
# * What to modify in already existing GA implementations?
# * What should i use as chromosomes in GA?
# Genetic Algorithm:
# 1. Initialize population
# 2. Measure each individual's cost in environment(some error function)
# 3. Select best individuals
# 4. Generate new population from the best genes adding some mutations
# 5. Repeat steps 2-4 until the last epoch (generation) and select the best one of them
# GA parameters
population_sz = 20
mutation_rate = 1e-1  # mutation probability
n_generations = 10


def simple_genetic_algorithm(data):
    # initialize population
    # run loop for generations
    # evaluate population
    # select best
    # crossing-over parents to create new population with some mutations
    return
