Campeonato Mundial de la Formula 1 (1950 - 2023)

Visualización Científica

La Fórmula 1, también conocida como F1, representa la cúspide de las carreras internacionales de monoplazas de ruedas abiertas, bajo la supervisión de la Federación Internacional del Automóvil (FIA). Desde su primera temporada en 1950, el Campeonato Mundial de Pilotos, rebautizado como el Campeonato Mundial de Fórmula 1 de la FIA en 1981, ha destacado como una de las principales competiciones a nivel global. La palabra “fórmula” en su nombre alude al conjunto de reglas que guían a todos los participantes en cuanto a la construcción y funcionamiento de los vehículos.

Definición

El Análisis de Componentes Principales (PCA) es una técnica que permite resumir y simplificar conjuntos de datos complejos y multidimensionales. Consolida variables que están correlacionadas entre sí en nuevas variables, combinando linealmente las originales de manera que se conserve la mayor cantidad posible de información observada.

Mediante PCA, es posible extraer la esencia de la información contenida en los datos al agrupar múltiples variables que describen a los individuos. Además de su capacidad para resumir datos, PCA también se utiliza como herramienta visual para comprender las estructuras de los datos. Logra esto al reducir la dimensionalidad de los datos, proyectándolos en un espacio de menor dimensión, como una línea, un plano o un espacio tridimensional. Esta reducción de dimensión facilita la interpretación y el análisis de conjuntos de datos complejos (Rubio 2022).

En el Análisis de Componentes Principales (PCA), seguimos una secuencia de ejes de proyección con un propósito específico. Primero, identificamos el eje de proyección que maximiza la varianza global. Este eje se conoce como el primer componente principal. Su objetivo es capturar la mayor cantidad posible de variabilidad presente en los datos.

Luego, buscamos el segundo eje de proyección que maximiza la varianza, pero bajo la restricción de ser ortogonal al primer componente principal. Este segundo componente principal ayuda a capturar la variabilidad restante que no fue explicada por el primer componente. La ortogonalidad entre las componentes principales garantiza que cada una capture diferentes aspectos de la variabilidad de los datos. Esta representación óptima facilita la interpretación y el análisis de los datos al proporcionar una visión clara de las relaciones entre las variables originales.

Planteamiento del problema

El análisis de componentes principales (PCA) y la técnica de clustering se utilizarán para clasificar y agrupar los distintos equipos (constructores) que han participado en la Fórmula 1 desde su inicio en 1950 hasta el año 2023. Este análisis se basará en una variedad de características relacionadas con el desempeño de los equipos en las carreras de F1. Las características incluirán la cantidad de puntos promedio obtenidos, la posición inicial en parrilla promedio, la posición final promedio, el número de vueltas promedio, la velocidad más alta promedio conseguida en la vuelta más rápida, el promedio de victorias, las paradas en pits promedio y el promedio de abandonos (no finalizaron la carrera).

Variables utilizadas * Cantidad de Puntos Promedio (avg_points): Representa la cantidad media de puntos obtenidos por el equipo en una temporada. * Posición Inicial en Parrilla Promedio (avg_grid): La posición media en la que el equipo comenzó las carreras en una temporada. * Posición Final Promedio (avg_positionOrder): La posición media en la que el equipo terminó las carreras en una temporada. * Número de Vueltas Promedio (avg_laps): La cantidad media de vueltas completadas por el equipo en una carrera. * Velocidad Más Alta Promedio en la Vuelta Más Rápida (avg_fastestlapspeed): La velocidad media más alta alcanzada por el equipo en la vuelta más rápida durante una carrera. * Promedio de Victorias (avg_wins): El número medio de victorias obtenidas por el equipo en una temporada. * Paradas en Pits Promedio (avg_stop): La cantidad media de paradas en pits realizadas por el equipo en una carrera. * Promedio de Abandonos (avg_retirements): La cantidad media de abandonos experimentados por el equipo en una temporada.

El objetivo principal será proporcionar una comprensión más profunda de la evolución y diversidad en el desempeño de los equipos de F1 a lo largo de la historia. Además, busca facilitar análisis comparativos y estratégicos para equipos, aficionados y analistas de la Fórmula 1.

Obtención de los datos

Siguiendo el mismo enfoque utilizado en la sección anterior para llevar a cabo los análisis exploratorios, emplearemos una función para establecer la conexión con la base de datos.

Primero, importamos las bibliotecas necesarias:

import pandas as pd
import psycopg2 as psy
from psycopg2 import Error

import plotly.graph_objects as go
import plotly.express as px

import numpy as np

A continuación, creamos la función que facilita las conexiones:

def connection_db() -> psy.extensions.connection:
    try:
        conn = psy.connect(DATABASE_URL)
        return conn
    except (Exception, Error) as e:
        print('Error while connecting to PostgreSQL', e)

Es importante destacar que esta función utiliza una variable de entorno para almacenar los datos de conexión a la base de datos. En este caso, estamos utilizando Neon, que nos permite crear un servidor de bases de datos con PostgreSQL.

Como mencionamos previamente, las tablas y sus respectivas columnas que utilizaremos para el desarrollo de este modelo son las siguientes:

  • Results: points (determinará si finalizó la carrera o no), grid (posición inicial), milliseconds (duración de la carrera en milisegundos), fastestlapspeed (velocidad más alta alcanzada en la vuelta más rápida) y constructorid (identificador del equipo).
  • Races: date (fecha en que se celebró la carrera).
  • Drivers: dob (fecha de nacimiento del piloto).
  • Pit_stops: stop (número de paradas en boxes).

Luego, ejecutamos la consulta SQL para obtener los datos relevantes:

try:
    connection = connection_db()
    cursor = connection.cursor()

    cursor.execute(
        """
            SELECT 
                c.constructorId, c.name,
                ROUND(AVG(res.points), 4) AS avg_points, ROUND(AVG(res.grid), 4) AS avg_grid, 
                ROUND(AVG(res.positionOrder), 4) AS avg_positionOrder, ROUND(AVG(res.laps), 4) AS avg_laps, 
                ROUND(AVG(res.fastestLapSpeed), 4) AS avg_fastestLapSpeed,
                ROUND(AVG(CASE WHEN res.positionOrder = 1 THEN 1 ELSE 0 END), 4) AS avg_wins,
                ROUND(AVG(p.stop), 4) AS avg_stops,
                ROUND(AVG(CASE WHEN sta.status != 'Finished' THEN 1 ELSE 0 END), 4) AS avg_abandonos
            FROM Constructors c
            JOIN Results res ON c.constructorId = res.constructorId
            LEFT JOIN Pit_Stops p ON res.raceId = p.raceId AND res.driverId = p.driverId
            LEFT JOIN Status sta ON res.statusId = sta.statusId
            GROUP BY c.constructorId, c.name;
        """
    )

    records = cursor.fetchall()
    constructor_data = pd.DataFrame(records)

    columns = []
    for column in cursor.description:
        columns.append(column[0])

    constructor_data.columns = columns

    display(constructor_data)
except (Exception, Error) as e:
    print('Error while executing the query', e)
finally:
    if(connection):
        cursor.close()
        connection.close()
constructorid name avg_points avg_grid avg_positionorder avg_laps avg_fastestlapspeed avg_wins avg_stops avg_abandonos
0 157 Langley 0.0000 31.0000 16.0000 128.0000 140.3620 0.0000 1.0000 1.0000
1 53 Toleman 0.1985 9.9771 19.7099 22.0763 217.7816 0.0000 1.0000 0.9237
2 32 Team Lotus 1.1424 11.0436 13.0563 42.3651 210.1351 0.0517 1.0034 0.7956
3 7 Toyota 0.9964 10.5357 11.4500 51.2964 207.5047 0.0000 1.0071 0.6500
4 100 ENB 0.0000 25.0000 16.0000 14.0000 230.0360 0.0000 1.0000 1.0000
... ... ... ... ... ... ... ... ... ... ...
205 142 Turner 0.0000 23.0000 21.0000 146.0000 123.2220 0.0000 1.0000 1.0000
206 152 Aston Butterworth 0.0000 9.0000 25.2500 4.7500 223.6188 0.0000 1.0000 1.0000
207 41 Leyton House 0.1250 13.7188 17.4531 33.3438 212.6000 0.0000 1.0000 0.9531
208 206 Marussia 0.0105 19.8653 17.6882 53.5263 194.6222 0.0000 1.7885 0.8950
209 46 Onyx 0.1154 9.9038 23.2115 20.7308 213.7659 0.0000 1.0000 0.9808

210 rows × 10 columns


Con este procedimiento, hemos obtenido los datos necesarios para nuestro análisis.

Componentes principales (PCA)

Convertiremos los datos a numeric, particularmente para manejar datos de tipo int64, además rellenaremos los datos NA y trataremos con los valores inf

constructor_data = constructor_data.apply(pd.to_numeric, errors='ignore')

constructor_data = constructor_data.fillna(0)
constructor_data = constructor_data.apply(lambda x: x.replace([float('inf'), float('-inf')], 0) if x.dtype.kind in 'biufc' else x)

Ahora, normalizaremos las características para asegurar que todas tengan la misma escala y contribución al análisis.

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
constructor_data.iloc[:, 2:10] = scaler.fit_transform(constructor_data.iloc[:, 2:10])

Realicemos ahora el análisis de componentes principales

from sklearn.decomposition import PCA

pca = PCA(n_components=4)
pca_results = pca.fit_transform(constructor_data.iloc[:, 2:10])
Eigenvalues
                       Dim.1     Dim.2     Dim.3     Dim.4     
Variance               3.266     2.177     0.922     0.778     
% of var.              40.635    27.081    11.466    9.679     
Cumulative % of var.   40.635    67.716    79.181    88.860    

Individuals (the 10 first)
 Dist |  Dim.1   |  Dim.2   |  Dim.3   |  Dim.4   |   ctr    |   cos2   |   ctr    |   cos2   |   ctr    |   cos2   | 
    1 |3.708    | 0.259    | 3.606    | -0.346   | -0.746   | 0.070    | 0.973    | -0.093   | -0.201   | 
    2 |1.147    | -0.856   | -0.707   | 0.243    | 0.150    | -0.747   | -0.617   | 0.212    | 0.131    | 
    3 |1.576    | 0.940    | -0.740   | 0.728    | -0.723   | 0.596    | -0.469   | 0.462    | -0.459   | 
    4 |1.069    | 0.909    | -0.318   | 0.122    | -0.448   | 0.850    | -0.297   | 0.114    | -0.419   | 
    5 |2.045    | -1.110   | 0.021    | -0.992   | -1.402   | -0.543   | 0.010    | -0.485   | -0.686   | 
    6 |1.058    | 0.648    | -0.477   | 0.282    | -0.626   | 0.613    | -0.451   | 0.266    | -0.592   | 
    7 |2.517    | -0.445   | 1.736    | 1.094    | 1.388    | -0.177   | 0.690    | 0.435    | 0.551    | 
    8 |3.126    | -1.879   | -1.479   | 1.026    | 1.732    | -0.601   | -0.473   | 0.328    | 0.554    | 
    9 |1.726    | 0.341    | 1.416    | 0.797    | 0.473    | 0.198    | 0.820    | 0.461    | 0.274    | 
   10 |2.035    | -1.052   | -0.468   | -0.857   | -1.442   | -0.517   | -0.230   | -0.421   | -0.709   | 

Variables
avg_points           | 0.842    | -0.370   | 0.004    | 0.054    | 
avg_grid             | -0.066   | 0.695    | -0.437   | -0.387   | 
avg_positionorder    | -0.677   | 0.052    | 0.180    | 0.597    | 
avg_laps             | 0.515    | 0.831    | 0.137    | 0.090    | 
avg_fastestlapspeed  | -0.396   | -0.855   | -0.224   | -0.242   | 
avg_wins             | 0.706    | -0.310   | 0.421    | -0.177   | 
avg_stops            | 0.562    | -0.181   | -0.672   | 0.402    | 
avg_abandonos        | -0.926   | 0.059    | -0.010   | -0.097   | 
labels = {
    str(i): f"PC {i+1} ({var:.1f}%)"
    for i, var in enumerate(pca.explained_variance_ratio_ * 100)
}

labels['color'] = 'Equipos'

fig = px.scatter(
    pca_results, 
    x=0, 
    y=1, 
    color=constructor_data["name"],
    hover_name=constructor_data["name"],
    hover_data={
        'Avg Wins: ': round(constructor_data['avg_wins'], 2),
        'Avg Points: ': round(constructor_data['avg_points'], 2),
        'Avg Abandonos: ': round(constructor_data['avg_abandonos'], 2),
    }
)

fig.update_layout(
    margin={'b': 0, 'r': 30, 'l': 30, 't': 30},
    xaxis={'title': 'Primera Dimensión', 'gridcolor': 'rgba(0, 0, 0, 0.0)', 'tickfont': {'color': 'white'}},
    yaxis={'title': 'Segunda Dimensión', 'gridcolor': 'rgba(0, 0, 0, 0.0)', 'tickfont': {'color': 'white'}},
    plot_bgcolor='rgba(0, 0, 0, 0.0)',
    paper_bgcolor='rgba(0, 0, 0, 0.0)',
    font_color="white",
    hoverlabel=dict(
        bgcolor="#111"
    ),
    showlegend = False
)
fig.show()

Clustericemos ahora estos equipos mediante el método de KMeans.

from sklearn.cluster import KMeans

kmeans = KMeans(n_clusters=4, random_state=123)
pca_results_df = pd.DataFrame(pca_results, columns=["Dim.1", "Dim.2", "Dim.3", "Dim.4"])

kmeans_result = kmeans.fit_predict(pca_results_df.iloc[:, :2])

pca_ind = pd.DataFrame(pca_results_df.iloc[:, :2], columns=["Dim.1", "Dim.2"])
pca_ind['constructorName'] = constructor_data['name']
pca_ind['avgWins'] = constructor_data['avg_wins']
pca_ind['avgPoints'] = constructor_data['avg_points']
pca_ind['avgAbandonos'] = constructor_data['avg_abandonos']
pca_ind['cluster'] = kmeans_result

fig = px.scatter(
    pca_ind, x='Dim.1', y='Dim.2', color='cluster', hover_name='constructorName',
    hover_data={'avgWins': True, 'avgPoints': True, 'avgAbandonos': True, 'cluster': True},
    labels={'Dim.1': 'Primera Dimensión', 'Dim.2': 'Segunda Dimensión'},
    title=f"PCA de Constructores de F1",
    color_continuous_scale='Viridis', size_max=10
)
fig.update(layout_coloraxis_showscale=False)
fig.update_layout(
    margin={'b': 0, 'r': 30, 'l': 30, 't': 30},
    xaxis={'title': 'Primera Dimensión', 'gridcolor': 'rgba(0, 0, 0, 0.0)', 'tickfont': {'color': 'white'}},
    yaxis={'title': 'Segunda Dimensión', 'gridcolor': 'rgba(0, 0, 0, 0.0)', 'tickfont': {'color': 'white'}},
    plot_bgcolor='rgba(0, 0, 0, 0.0)',
    paper_bgcolor='rgba(0, 0, 0, 0.0)',
    font_color="white",
    hoverlabel=dict(
        bgcolor="#111"
    ),
    showlegend = False
)

for i, feature in enumerate(constructor_data.iloc[:, 2:10]):
    fig.add_annotation(
        ax=0, ay=0,
        axref="x", ayref="y",
        x=variables_contributions[i, 0],
        y=variables_contributions[i, 1],
        showarrow=True,
        arrowsize=2,
        arrowhead=2,
        xanchor="right",
        yanchor="top",
        font=dict(color="white", size=10)
    )
    fig.add_annotation(
        x=variables_contributions[i, 0],
        y=variables_contributions[i, 1],
        ax=0, ay=0,
        xanchor="center",
        yanchor="bottom",
        text=feature,
        yshift=5,
    )

fig.show()
kmeans_result = kmeans.fit_predict(pca_results_df.iloc[:, :3])

pca_ind = pd.DataFrame(pca_results_df.iloc[:, :3], columns=["Dim.1", "Dim.2", "Dim.3"])
pca_ind['constructorName'] = constructor_data['name']
pca_ind['avgWins'] = constructor_data['avg_wins']
pca_ind['avgPoints'] = constructor_data['avg_points']
pca_ind['avgAbandonos'] = constructor_data['avg_abandonos']
pca_ind['cluster'] = kmeans_result

fig = px.scatter_3d(
    pca_ind, x='Dim.1', y='Dim.2', z='Dim.3', color='cluster',
    hover_name='constructorName',
    hover_data={'avgWins': True, 'avgPoints': True, 'cluster': False},
    labels={'Dim.1': 'Primera Dimensión', 'Dim.2': 'Segunda Dimensión', 'Dim.3': 'Tercera Dimensión'},
    title=f"3D PCA de Constructores de F1",
    color_continuous_scale='Viridis', size_max=5
)

fig.update(layout_coloraxis_showscale=False)
fig.update_layout(
    margin={'b': 0, 'r': 30, 'l': 30, 't': 30},
    xaxis={'title': 'Primera Dimensión', 'gridcolor': 'rgba(0, 0, 0, 0.0)', 'tickfont': {'color': 'white'}},
    yaxis={'title': 'Segunda Dimensión', 'gridcolor': 'rgba(0, 0, 0, 0.0)', 'tickfont': {'color': 'white'}},
    plot_bgcolor='rgba(0, 0, 0, 0.0)',
    paper_bgcolor='rgba(0, 0, 0, 0.0)',
    font_color="white",
    hoverlabel=dict(
        bgcolor="#111"
    ),
    scene=dict(
        xaxis=dict(backgroundcolor='rgba(0, 0, 0, 0)', gridcolor= '#333'),
        yaxis=dict(backgroundcolor='rgba(0, 0, 0, 0)', gridcolor= '#333'),
        zaxis=dict(backgroundcolor='rgba(0, 0, 0, 0)', gridcolor= '#333'),
        bgcolor='rgba(0, 0, 0, 0)',
    ),
    showlegend = False
)

fig.show()

Referencias

Rubio, Lihki. 2022. «Principal Component Analysis (PCA) Tutorial». 2022. https://lihkir.github.io/MachineLearningUninorte/practical_pca.html.