# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# + [markdown] id="EfOJXfvdviTz"
# # Objective
# Build a binary classifier that given a sequence of lap times will predict if a pit-stop will happen or not the next lap .. in other words I call this project End-of-Stint-or-NOT
#
# Data Source:
# - Ergast Developer API: https://ergast.com/mrd/
#
# ## Table of Content:
# * [Data Preparation](#Section1)
#     * [Import data](#section_1_1)
#     * [Pit Stop Table Transformation](#section_1_2)
#     * [Lap Times Table Transformation](#section_1_3)
#     * [Left Join New Pit-Stop with New Lap-Times](#section_1_4)
#     * [TBC](#section_1_5)
#     * [TBC](#section_1_6)

# + [markdown] id="2EgBebpnviT_"
# ## Data Preparation <a class="anchor" id="Section1"></a>

# + id="M7Bp4BmnviUB"
import pandas as pd
import numpy as np

# + [markdown] id="56SAjSrzviUJ"
# ### Import Data <a class="anchor" id="section_1_1"></a>

# + id="cWfLQEkQviUK"
laps_master = pd.read_csv('data/lap_times.csv')
races_master = pd.read_csv('data/races.csv')
quali_master = pd.read_csv('data/qualifying.csv')
drivers_master = pd.read_csv('data/drivers.csv')
constructors_master = pd.read_csv('data/constructors.csv')
results_master = pd.read_csv('data/results.csv')
circuits_master = pd.read_csv('data/circuits.csv')
pits_master = pd.read_csv('data/pit_stops.csv')

# + id="V-AtbRc4viUO"
pits_master

# + [markdown] id="KMTHQZGKviUW"
# ### Pit Stop Table Transformation <a class="anchor" id="section_1_2"></a>

# + [markdown] id="R501PuwUviUa"
# Create new data frame with a list of laps when a pit stop was occuring for each driver, for each race

# + id="6oTsyhkTviUb"
pits_df_new = pits_master.groupby(['raceId', 'driverId'])['lap'].apply(list).reset_index(name='laps_when_pitstop')
pits_df_new

# + [markdown] id="F1NWU8NUviUe"
# #### Preview the lap times table
# Let's take a look at a random race, and random driver, and see how the lap times look .. just to better understand what transformation needs to be done on the data

# + id="nuG-MyirviUf"
laps_master[laps_master.raceId == 841][laps_master.driverId == 17]

# + [markdown] id="N8UwGIRQviUq"
# ### Lap Times Table Transformation <a class="anchor" id="section_1_3"></a>

# + [markdown] id="qC8NZj65viUs"
# Create a new data frame containing a list of all the lap times in one row, for an entire race, for each driver

# + id="u0Ne8vybviUt"
laps_df_new = laps_master.groupby(['raceId', 'driverId'])['milliseconds'].apply(list).reset_index(name='race_lap_times')
laps_df_new

# + [markdown] id="yYYgZ9gaviU2"
# ### Left Join New Pit-Stop Table with New Lap-Times Table <a class="anchor" id="section_1_4"></a>

# + id="MxYgHkkIviU3"
merged = pd.merge(pits_df_new, laps_df_new, on=['raceId', 'driverId'], how='left')
merged


# + [markdown] id="lQZZA682viU-"
# ### Lap Times Before Pit-Stop Sequence Partitioning <a class="anchor" id="section_1_5"></a>

# + id="u0GUFzdEviU_"
def partition_lapTime_into_sequences(pitStop_laps, race_lapTimes):
    # NOTE: no need to return the last stint, since it is not followed by a pit stop... 
    #       only return sequence of lap times that are followed by a pit stop

    # returns: list of lap time sequences (which as lists) ... so list of lists
    
    # remove pit stops from first lap... those occur because of a collision, so they should not be looked at when predicting the end of the stint
    if 1 in pitStop_laps:
        pitStop_laps = pitStop_laps[1:]                        # remove first lap pit stop, as it was not a regular, planned one
        race_lapTimes = race_lapTimes[1:]                      # remove the first lap time, since the stint was "corrupted" by the emergency pitstop 
        pitStop_laps[:] = [x - 1 for x in pitStop_laps]        # subtract one lap from the pit-stop lap count, to account for the first lap being removed
    
    if len(pitStop_laps) < 1:
        return np.nan    # no real stints have occured. Pitted on lap 1, then never pitted again during the race.
    
    sequences = []
    prev_pit = pitStop_laps[0]
    
    if len(pitStop_laps) == 1:   # if the race is a one-stop race 
        sequences.append(race_lapTimes[:prev_pit-1])   # the off-by-one accounts for not taking into consideration the lap with the pit-stop as part of the sequence
    else:                      # multi-stop race as
        
        for current_pit in pitStop_laps:
            if current_pit == prev_pit:           # this is only true when prev_pit = pitStop_laps[0]
                sequences.append(race_lapTimes[:current_pit-1])         # create first stint
                # the off-by-one accounts for not taking into consideration the lap with the pit-stop as part of the sequence
            else:
                sequences.append(race_lapTimes[prev_pit:current_pit-1]) # create next sequence from (prev-pit-lap, current_pit-lap)
                prev_pit = current_pit                             # update pointer to previous pit ... this will be needed for the next pit
    return sequences


# + [markdown] id="7NoZX7a-viVD"
# ### Sequencing Function Test cases

# + id="lXJDjK99viVL"
sample_input_pits = merged.iloc[13, :].laps_when_pitstop
sample_input_lapTimes = merged.iloc[13, :].race_lap_times

print("input pits: ", sample_input_pits)
print("input laps: ", sample_input_lapTimes)

print("output: ", partition_lapTime_into_sequences(sample_input_pits, sample_input_lapTimes))


# + [markdown] id="QKkVAqmXviVb"
# To DO: write test cases

# + [markdown] id="cvJZ-v77B9Ej"
# ### Get Lap Times of Final Stint (as a non-pit-stint)

# + id="dK-CTMKcB8aO"
def get_last_stint_lap_times(pitStop_laps, race_lapTimes):
    # returns the last stint's lap times, since it is not followed by a pit stop .. so it is non-pit-stop stint 

    last_pit = pitStop_laps[-1]
    return race_lapTimes[last_pit:]


# + [markdown] id="TMwKo-auEAzJ"
# ### Test get_last_stint_lap_times function 

# + id="iMT607f0D1Yt"
sample_input_pits = merged.iloc[13, :].laps_when_pitstop
sample_input_lapTimes = merged.iloc[13, :].race_lap_times

print("input pits: ", sample_input_pits)
print("input laps: ", sample_input_lapTimes)

print("output: ", get_last_stint_lap_times(sample_input_pits, sample_input_lapTimes))

# + id="NYvSnBM5EF2L"
sample_input_pits = merged.iloc[1, :].laps_when_pitstop
sample_input_lapTimes = merged.iloc[1, :].race_lap_times

print("input pits: ", sample_input_pits)
print("input laps: ", sample_input_lapTimes)

print("output: ", get_last_stint_lap_times(sample_input_pits, sample_input_lapTimes))

# + [markdown] id="5pyVIEUqviVb"
# ### Apply sequence partitioning function the merged data set

# + id="LKdX0sJ3viVc"
merged['stints'] = merged.apply(lambda x: partition_lapTime_into_sequences(x.laps_when_pitstop, x.race_lap_times), axis=1)
merged['last_stint'] = merged.apply(lambda x: get_last_stint_lap_times(x.laps_when_pitstop, x.race_lap_times), axis=1)
merged

# + [markdown] id="abv2v6MnviVt"
# Check if there are any missing stints

# + id="QGOJv_91viVu"
merged.isnull().sum()

# + [markdown] id="H_lzs4aBviV3"
# There are some missing values based on the sequence partitioning transformation that we have just applied. Let's see where they are.

# + id="-MysculrviV6"
merged[merged.isnull().any(axis=1)]

# + [markdown] id="0fDEVnC5viWD"
# As I have thought, all cases are just races when there was only one pit stop, on lap 1, so for the scope of this end-of-stint classifier we can safely remove theses cases, as they do not affect the task at hand

# + id="9AdzLnXcviWF"
merged = merged.dropna()
merged

# + id="CUJG9BtkviWL"
end_of_stint_sequences = merged['stints']
end_of_stint_sequences[0]

# + id="A4WRmxcQFk0L"
last_stint_sequences = merged['last_stint']
last_stint_sequences[0]

# + [markdown] id="hAj46HLHviWW"
# We need to flatten the structure of the data. We need a list of lists, not a Pandas Series of lists of lists

# + id="UpQYM2prviWX"
temp = end_of_stint_sequences.tolist()  # lists of lists of lists
print("Before:", temp[0:3])
print()

# + id="gDQRmBd4_SlR"
# Use list.extend() to convert a a 3D list to a 2D lists
end_of_stint_sequences = []
for elem in temp:
    end_of_stint_sequences.extend(elem)       # this will make it lists of lists

print("After:", end_of_stint_sequences[0:3])
print("Sample Size = ", len(end_of_stint_sequences))


# + [markdown] id="oKnQupWRviWc"
# ### Generate not end of stint sequences --- this method did not work

# + [markdown] id="8xnehNa6viWc"
# My logic here is the following: Don't generate random laptimes, nor stints with random length.
#
# What I propose is: remove the last n laps from a real stint, and label it as a 'not-end-of-stint' kind of a sequence.
#
# The parameter n needs to be experimented with: we need to figure out what kind of experiment setup works best for our binary classifier. 
#
#    - Initially what I am thinking is that I will remove the last lap, and create some fake samples... then remove the last 2 laps, and the last 4 laps, and create samples out of those too.
#    - What I want to make sure is to not create a very unbalanced data set. What I am aiming for is 20-25% end-of-stint data, with 75-80% not-end-of stint data to comprise my data set which I will use to train my binary classifier.
#

# + id="eyRtmfrRviWd"
def remove_lastN_elements(arr, N):
    return arr[:-N]


# + id="oOwtwBBw0gDK"
print(end_of_stint_sequences[0])
print()
print(remove_lastN_elements(end_of_stint_sequences[0], 2))

# + id="3P8s8uERviWu"
NOT_end_of_stint_sequences = []

# N needs to be experimented with. Initially I chose N=1, N=2, and N=4
for lst in end_of_stint_sequences:
    temp_list = remove_lastN_elements(lst, N = 1)        # remove last lap from each stint
    NOT_end_of_stint_sequences.append(temp_list)
    
    temp_list = remove_lastN_elements(lst, N = 2)        # remove last 2 laps from each stint
    NOT_end_of_stint_sequences.append(temp_list)
    
    temp_list = remove_lastN_elements(lst, N = 4)        # remove last 4 laps from each stint
    NOT_end_of_stint_sequences.append(temp_list)

# + id="n0Saff5tviWz"
#print(len(NOT_end_of_stint_sequences))
print(len(end_of_stint_sequences))

# + [markdown] id="ob9WKNuhviW3"
# RESULT = 3:1 ratio between not-end-of-stint and end-os-stint data
#
# Let's create the labels:

# + id="zjcbeuNJviW4"
end_of_stint_labels = [1] * len(end_of_stint_sequences)
NOT_end_of_stint_labels = [0] * len(NOT_end_of_stint_sequences)

# + [markdown] id="KchdnRq7viXA"
# ### Get NOT-end-of-stint sequences & Create final data set

# + id="YgjVmPo7HEkD"
NOT_end_of_stint_sequences = last_stint_sequences.tolist() 

NOT_end_of_stint_labels = [0] * len(NOT_end_of_stint_sequences)
end_of_stint_labels = [1] * len(end_of_stint_sequences)

print("Labels:")
print(len(NOT_end_of_stint_labels))
print(len(end_of_stint_labels))

print("\nSequences:")
print(len(NOT_end_of_stint_sequences))
print(len(end_of_stint_sequences))

# + id="H1AA3L8IviXD"
stint_sequences = end_of_stint_sequences + NOT_end_of_stint_sequences
stint_labels = end_of_stint_labels + NOT_end_of_stint_labels

print(len(stint_sequences))
print(len(stint_labels))

# + [markdown] id="EmFEM3MvviXL"
# ## Binary Classifier
#
# I view this task as a Sequence Classification task, where deep learning approaches have been widely used in practice for similar tasks such as: 
#
#  - DNA Sequence Classification: Given a DNA sequence of ACGT values, predict whether the sequence codes for a coding or non-coding region.
#  - Anomaly Detection: Given a sequence of observations, predict whether the sequence is anomalous or not.
#  - Sentiment Analysis: Given a sequence of text such as a review or a tweet, predict whether sentiment of the text is positive or negative.
#  
# Reference: https://machinelearningmastery.com/sequence-prediction/
#  
# I have done some research on the problem and the most common approaches seem to be using LSTM (Long-Short-Term-Memory) Recurrent Neural Networks. In the upcoming subsections I will test various architectures of different LSTM and maybe even non-LSTM Recurrent Neural Networks to see some results, then evaluate if we need to use some other binary classifier, or we actually just need more data, or better data, or we need to apply some techniques used when working with imbalaced data (undersampling, oversampling).

# + [markdown] id="Wlbg1Aes4DIp"
# ### Split data into train and test sets

# + id="CIHFVPByviXU"
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(stint_sequences, stint_labels, test_size=0.20, random_state=7)

print("Train set:", len(X_train))
print("Test set:", len(X_test))
print("Train labels:", len(y_train))
print("Test labels:", len(y_test))

# + [markdown] id="2464ZwIM4Mai"
# ### Pad Input Sequences 

# + id="MC_qyDohviXc"
# find out what's the longest stint in our data set
#max_stint_length = max(map(len, end_of_stint_sequences))
#print("Max stint-length =", max_stint_length)

max_stint_length = 30 

# + id="IGmgVkDXviXi"
from keras.preprocessing import sequence

X_train = sequence.pad_sequences(X_train, maxlen=max_stint_length, padding="pre", truncating='pre')
X_test = sequence.pad_sequences(X_test, maxlen=max_stint_length, padding="pre", truncating='pre')

# + [markdown] id="K7oeyVm3BrHY"
# Wrap every list into numpy arrays, so Keras can process the input

# + id="XH8dUFRqBpj7"
X_train = np.array(X_train)
X_train = X_train.reshape(X_train.shape[0], X_train.shape[1], 1)

X_test = np.array(X_test)
X_test = X_test.reshape(X_test.shape[0], X_test.shape[1], 1)

y_train = np.array(y_train)
y_train = y_train.reshape(y_train.shape[0], 1)

y_test = np.array(y_test)
y_test = y_test.reshape(y_test.shape[0], 1)

# + [markdown] id="AhkuWQKqI1qk"
# testing the ratio of 0 to 1 in the train and test set

# + id="1ROhVej7k626"
unique, frequency = np.unique(y_test,  
                              return_counts = True) 
# print unique values array 
print("Unique Values:",  
      unique) 
  
# print frequency array 
print("Frequency Values:", 
      frequency)

# + id="SmQt3aRwC4Fx"
unique, frequency = np.unique(y_train,  
                              return_counts = True) 
# print unique values array 
print("Unique Values:",  
      unique) 
  
# print frequency array 
print("Frequency Values:", 
      frequency)

# + [markdown] id="lmIbguidviXL"
# ### Approach 1: Simple LSTM for Sequence Classification

# + id="NJBpgp9MviXN"
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM

# fix random seed for reproducibility
np.random.seed(7)

# + id="Alaw3JaaviXz"
model = Sequential()
model.add(LSTM(100, input_shape=(max_stint_length, 1)))
model.add(Dense(1, activation='sigmoid'))

model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
print(model.summary())

model.fit(X_train, y_train, validation_data=(X_test, y_test), epochs=10, batch_size=128)

# + id="74Ki7aT2M8A7"
model = Sequential()
model.add(LSTM(64, input_shape=(max_stint_length, 1), return_sequences=True))
model.add(LSTM(32))
model.add(Dense(1, activation='sigmoid'))

model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
print(model.summary())

model.fit(X_train, y_train, validation_data=(X_test, y_test), epochs=10, batch_size=128)

# + [markdown] id="GKVY_AR_i5P1"
# ### Approach 2: Time Distributed LSTM

# + id="6HkA0dexjWy1"
from keras.models import Sequential
from keras.layers import Dense, TimeDistributed, LSTM, Dropout

# fix random seed for reproducibility
np.random.seed(7)

# + id="ozfhF9pzicmn"
model = Sequential()
model.add(LSTM(512, input_shape=(max_stint_length, 1), return_sequences=True))
#model.add(Dropout(0.3))
model.add(LSTM(256, return_sequences=True))
model.add(LSTM(128, return_sequences=True))
model.add(LSTM(64, return_sequences=True))
model.add(TimeDistributed(Dense(1, activation='sigmoid')))


model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
print(model.summary())

model.fit(X_train, y_train, validation_data=(X_test, y_test), epochs=10, batch_size=64)

# + [markdown] id="JJK426bhmatW"
# ### Approach 3: Bidirectional LSTMs

# + id="TS8_N0jCmZvX"
from keras.layers import Bidirectional

# define LSTM model
model = Sequential()
model.add(Bidirectional(LSTM(128, return_sequences=True), input_shape=(max_stint_length, 1)))
#model.add(Dropout(0.2))
model.add(Bidirectional(LSTM(64, return_sequences=True)))
#model.add(Dropout(0.2))
#model.add(Bidirectional(LSTM(64, return_sequences=True)))
#model.add(Bidirectional(LSTM(64)))
#model.add(Dropout(0.2))
model.add(Dense(64, activation='relu'))
model.add(Dense(32, activation='relu'))
model.add(Dense(1, activation='sigmoid'))

model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])

print(model.summary())

model.fit(X_train, y_train, validation_data=(X_test, y_test), epochs=10, batch_size=64)

# + [markdown] id="szfxV_bGsotK"
# ### Approach 4: LSTM and CNNs combined

# + id="oLOcwPtGspLB"
from keras.layers.convolutional import Conv1D
from keras.layers.convolutional import MaxPooling1D

model = Sequential()
model.add(Conv1D(filters=256, kernel_size=3, padding='same', activation='relu', input_shape=(max_stint_length, 1)))
model.add(MaxPooling1D(pool_size=2))
model.add(Conv1D(filters=128, kernel_size=3, padding='same', activation='relu'))
model.add(MaxPooling1D(pool_size=2))
model.add(Bidirectional(LSTM(64, return_sequences=True)))
model.add(Dense(64, activation='relu'))
model.add(Dense(32, activation='relu'))
model.add(Dense(1, activation='sigmoid'))

model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])

print(model.summary())

model.fit(X_train, y_train, validation_data=(X_test, y_test), epochs=10, batch_size=128)

StarcoderdataJupyterScriptsDedupFiltered