Abrir en Google Colab Abrir en Binder Descargar notebook

Creando rutinas de preprocesamiento con Scikit-Learn

Introducción

Las tareas de preprocesamiento de datos pueden ser largas y tediosas. Sin embargo, esto no es el único problema. Si no se realizan de la forma correcta, en muchos casos se pueden introducir problemas de modelado que son dificiles de detectar y que invalidan cualquier técnica de aprendizaje automático que utilicemos luego.

En esta sección aprenderemos varias formas de realizar preprocesamiento y finalmente una forma de empaquetar estas transformaciones de forma que luego sean reproducibles.

Instalación

Necesitaremos instalar las librerias:

[1]:
!pip install ydata_profiling cloudpickle scikit-learn pandas numpy --quiet

Sobre el conjunto de datos del censo UCI

El conjunto de datos del censo de la UCI es un conjunto de datos en el que cada registro representa a una persona. Cada registro contiene 14 columnas que describen a una una sola persona, de la base de datos del censo de Estados Unidos de 1994. Esto incluye información como la edad, el estado civil y el nivel educativo. La tarea es determinar si una persona tiene un ingreso alto (definido como ganar más de $50 mil al año). Esta tarea, dado el tipo de datos que utiliza, se usa a menudo en el estudio de equidad, en parte debido a los atributos comprensibles del conjunto de datos, incluidos algunos que contienen tipos sensibles como la edad y el género, y en parte también porque comprende una tarea claramente del mundo real.

Descargamos el conjunto de datos

[4]:
!wget https://santiagxf.blob.core.windows.net/public/datasets/uci_census.zip \
    --quiet --no-clobber
!mkdir -p datasets/uci_census
!unzip -qq uci_census.zip -d datasets/uci_census

Lo importamos

[5]:
import pandas as pd
import numpy as np

train = pd.read_csv('datasets/uci_census/data/adult-train.csv')
test = pd.read_csv('datasets/uci_census/data/adult-test.csv')
[6]:
train
[6]:
income age workclass fnlwgt education education-num marital-status occupation relationship race gender capital-gain capital-loss hours-per-week native-country
0 <=50K 39 State-gov 77516 Bachelors 13 Never-married Adm-clerical Not-in-family White Male 2174 0 40 United-States
1 <=50K 50 Self-emp-not-inc 83311 Bachelors 13 Married-civ-spouse Exec-managerial Husband White Male 0 0 13 United-States
2 <=50K 38 Private 215646 HS-grad 9 Divorced Handlers-cleaners Not-in-family White Male 0 0 40 United-States
3 <=50K 53 Private 234721 11th 7 Married-civ-spouse Handlers-cleaners Husband Black Male 0 0 40 United-States
4 <=50K 28 Private 338409 Bachelors 13 Married-civ-spouse Prof-specialty Wife Black Female 0 0 40 Cuba
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
32556 <=50K 27 Private 257302 Assoc-acdm 12 Married-civ-spouse Tech-support Wife White Female 0 0 38 United-States
32557 >50K 40 Private 154374 HS-grad 9 Married-civ-spouse Machine-op-inspct Husband White Male 0 0 40 United-States
32558 <=50K 58 Private 151910 HS-grad 9 Widowed Adm-clerical Unmarried White Female 0 0 40 United-States
32559 <=50K 22 Private 201490 HS-grad 9 Never-married Adm-clerical Own-child White Male 0 0 20 United-States
32560 >50K 52 Self-emp-inc 287927 HS-grad 9 Married-civ-spouse Exec-managerial Wife White Female 15024 0 40 United-States

32561 rows × 15 columns

Análisis exploratorio de datos

El análisis exploratorio de datos en la actualidad suele realizarce en una primera instancia de format automátizada, y luego mas específico.

Utilizando librerias de EDA

Existen multiples librerías que automatizan el análisis exploratorio de datos. En esta sección veremos 2:

YData

[8]:
from ydata_profiling import ProfileReport

profile = ProfileReport(train, title="Perfil automático de los datos")
Upgrade to ydata-sdk

Improve your data and profiling with ydata-sdk, featuring data quality scoring, redundancy detection, outlier identification, text validation, and synthetic data generation.

Hay dos formas de consumir el reporte de esta libreria: Utilizando widgets en notebooks de Jupyter o como un reporte HTML. El siguiente ejemplo muestra como mostrarlo dentro del notebook:

profile.to_widgets()

Google Colab no soporta el uso de este tipo de widgets actualmente. En este caso, es recomendable utilizar reportes en HTML, el cual puede generar con el siguiente código:

[10]:
profile.to_notebook_iframe()

Una de las ventajas de tener el reporte como HTML es que puede exportarlo facilmente para compartirlo:

[ ]:
profile.to_file("reporte.html")

La exploración automática puede ser costosa computacionalmente. Dependiendo del escenario o la cantidad de datos, puede ser que necesite configurar la exploración automática de una forma específica. La librería ofrece detallada documentación para configurar el proceso. El siguiente ejemplo muestra por ejemplo como generar el reporte sin computar la correlación entre todas las variables.

[11]:
profile = train.profile_report(
    title="Report without correlations",
    correlations=None,
)

Lux

Lux es una biblioteca de Python que facilita la exploración de datos rápida y fácil al automatizar el proceso de visualización y análisis de datos. Al imprimir simplemente un dataframe en un cuaderno de Jupyter, Lux recomienda un conjunto de visualizaciones que destacan tendencias y patrones interesantes en el conjunto de datos.

[13]:
%pip install lux --quiet

Cuando se imprime el dataframe, Lux recomienda automáticamente un conjunto de visualizaciones que destacan tendencias y patrones interesantes en el conjunto de datos.

IMPORTANTE: Google Collab no soporta el uso de la librería LUX. Utilice Binder.

[ ]:
train

Utilizando código

Muchas veces es útil generar nuestras propias rutinas de descripción las cuales se focalizan en las calidades que estamos interesados en monitorear.

Utilizaremos la siguiente rutina para ir verificando los cambios que realizamos en el conjunto de datos. La misma muestra la cantidad de muestras, la cantidad de predictores y algunos valores interesantes en el caso que indiquemos alguna columna para mostrar. Este paso no es necesario en una rutina de preprocesamiento pero simplemente nos ayuda a verificar los cambios que realizamos.

[ ]:
from typing import List, Tuple

def print_stats(df: pd.DataFrame, detail_columns: List[str] = None):
  print(f"Muestras:", df.shape[0])
  print(f"Features:", df.shape[1])
  print(f"Faltantes:", df.isna().sum().sum())

  if detail_columns:
    print('Detalles:')
    for column in detail_columns:
      print(f"\tColumna: {column} ({df[column].dtype})")
      if df[column].dtype in ['O', 'Object', 'int64']:
        print(f"\t\tValores únicos: {len(df[column].unique())}")
        print(f"\t\tFaltantes: {df[column].isna().sum()}")
      if df[column].dtype in ['float32', 'float64']:
        print(f"\t\tMedia: {df[column].mean()}")

Por ejemplo, podemos utilizarla como sigue:

[ ]:
print_stats(train)
Muestras: 32561
Features: 15
Faltantes: 0
[ ]:
import seaborn as sns
import matplotlib.pyplot as plt
[ ]:
sns.pairplot(train, hue="income")
<seaborn.axisgrid.PairGrid at 0x7f19f4bdecb0>
../../../_images/develop_prep_code_dataprep_32_1.png

Creando una rutina de preprocesamiento en el problema censo de la UCI

Veamos como crear una rutina de preprocesamiento para el conjunto de datos del problema de censo de la UCI.

Preprocesando los datos

Veamos a modo de ejemplo multiple preprocesamientos que podemos realizar:

Procesamos valores faltantes

En este conjunto de datos, los valores faltantes estan codifícados muchas veces como «?». Podemos reemplazar este valor por el correspondiente valor faltante para identificarlos mejor:

[ ]:
train.replace(' ?', np.nan, inplace=True)
[ ]:
print_stats(train)
Muestras: 32561
Features: 15
Faltantes: 4262

Corrección calidad de datos

Muchas variables, adicionalmente, tienen el problema que sus valores poseen «espacios» en blanco que son dificiles de divisar. Por ejemplo el valor « married» (note el espacio al principio del texto). Eliminaremos estos espacios en blanco con la función lstrip().

[ ]:
for col in train.dtypes[train.dtypes == 'object' ].keys():
  train[col] = train[col].str.lstrip()

Tip: En Numpy, strings son almacenadas como tipo object.

Corrección de tipos de datos

Algunas variables podríamos necesitarlas en otro tipo de dato. Por ejemplo, podríamos necesitar la variable edad que esté codificada como entera en lugar de como punto flotante:

[ ]:
train["age"] = train["age"].astype(int)
[ ]:
print_stats(train, detail_columns=['age'])
Muestras: 32561
Features: 15
Faltantes: 4262
Detalles:
        Columna: age (int64)
                Valores únicos: 73
                Faltantes: 0

Eliminando registros duplicados

En algunos casos, puede ser util eliminar regitros que son duplicados. En general, la mayoria de los algoritmos de aprendizaje suelen ser robustos a este tipo de características.

[ ]:
train.drop_duplicates(inplace=True)
[ ]:
print_stats(train)
Muestras: 32537
Features: 15
Faltantes: 4261

Reagrupando variables categoricas

En algunas ocaciones, algunas variables categóricas que tienen muchos valores pueden introducir un problema de dimensionalidad al codificarlas, especialmente si utilizamos one-hot encoding. Podemos explorar la distribución y identificar si podemos reagruparlas:

[ ]:
sns.displot(train, x="income", stat="probability", col="marital-status", height=3)
<seaborn.axisgrid.FacetGrid at 0x7b8781c934c0>
../../../_images/develop_prep_code_dataprep_49_1.png

Agrupamos entonces las diferentes categorías de la siguiente forma:

[ ]:
mapping = {
  'Divorced': 'Single',
  'Married-spouse-absent': 'Single',
  'Never-married': 'Single',
  'Separated': 'Single',
  'Widowed': 'Single',
  'Married-AF-spouse': 'Couple',
  'Married-civ-spouse': 'Couple',
}

La función map() nos permite mapear los valores a otro conjunto de valores utilizando un diccionario como entrada:

[ ]:
train['marital-status'] = train['marital-status'].map(mapping)

Verifiquemos la nueva distribución:

[ ]:
sns.displot(train, x="income", stat="probability", col="marital-status", height=3)
<seaborn.axisgrid.FacetGrid at 0x7b87a7ed6020>
../../../_images/develop_prep_code_dataprep_55_1.png

Tranformamos columnas a una escala logaritmica

En general, las variables que involugran precios suelen (en algunas ocaciones, mostrar distribuciones exponenciales.

[ ]:
sns.displot(train, x="capital-gain", col="income", height=3)
sns.displot(train, x="capital-loss", col="income", height=3)
<seaborn.axisgrid.FacetGrid at 0x7b8776ed1210>
../../../_images/develop_prep_code_dataprep_57_1.png
../../../_images/develop_prep_code_dataprep_57_2.png

Note que existe una gran cantidad de instancias con valor cero en estas columnas. Podríamos generar una columna indicadora para capturar este hecho si es que el algoritmo de aprendizaje no puede distinguirlo.

Verifiquemos que sucede si aplicamos el logarítmo natural:

[ ]:
train['capital-gain'] = np.log(train['capital-gain'] + 1)
train['capital-loss'] = np.log(train['capital-loss'] + 1)
[ ]:
sns.displot(train[train['capital-gain'] > 0], x="capital-gain", col="income", height=3)
sns.displot(train[train['capital-loss'] > 0], x="capital-loss", col="income", height=3)
<seaborn.axisgrid.FacetGrid at 0x7b8776e12e30>
../../../_images/develop_prep_code_dataprep_61_1.png
../../../_images/develop_prep_code_dataprep_61_2.png

Veamos que tenemos distribuciones que resultan mucho más informativas.

Eliminamos valores faltantes en la columna a predecir

Los valores faltantes en la columna a predecir no pueden ser inputados, y por lo tanto suelen ser eliminados directamente del conjunto de datos:

[ ]:
train.dropna(axis='index', subset=['income'], inplace=True)
[ ]:
print_stats(train)
Muestras: 32537
Features: 15
Faltantes: 4261

Imputar valores faltantes en los predictores

En los predictores, generalmente no queremos eliminar las muestras que continen valores faltantes - a menos que la cantidad de valores faltantes sea alta - ya sea en la fila o en la columna. Por lo tanto, intentaremos imputarlos.

La libraría scikit-learn provee una clase llamada SimpleImputer que nos permite realizar esta operación:

[ ]:
from sklearn.impute import SimpleImputer

Para imputar valores faltantes, en general debemos distinguir si las variables son discretas o continuas:

[ ]:
discrete_feat = train.dtypes[train.dtypes == 'object' ].keys().drop("income")
continuous_feat = train.dtypes[train.dtypes != 'object'].keys()

Tip: Note como las variables discretas no incluyen la columna a predecir.

Variables continuas

Para las variables continuas o numéricas, tenemos varias operaciones con las que podemos computar los valores faltantes. La mejor opción dependerá del problema puntual. En este caso, utilizaremos la media de cada predictor.

[ ]:
for feat in continuous_feat:
  imputer = SimpleImputer(strategy='median')
  train[feat] = imputer.fit_transform(train[feat].values.reshape(-1,1)).reshape(-1)

Tip: El método fit_transform requiere de una matríz como entrada. Al utilizar reshape(-1,1) estamos transformando un vector unidemnsional en una matriz. Luego, el método reshape(-1) realiza la operación inversa.

[ ]:
print_stats(train)
Muestras: 32537
Features: 15
Faltantes: 4261
Variables discretas

En las variables discretas no podemos computar la media ya que no está definida. Sin embargo, podemos utilizar el valor más frecuente.

[ ]:
for feat in discrete_feat:
  imputer = SimpleImputer(strategy='most_frequent')
  train[feat] = imputer.fit_transform(train[feat].values.reshape(-1,1)).reshape(-1)
[ ]:
print_stats(train)
Muestras: 32537
Features: 15
Faltantes: 0

Codificación de variables categóricas

La mayoría de los algoritmos de aprendizaje automático no pueden trabajar con variables categóricas y necesitan que las mismas sean codificadas numericamente.

La libraría scikit-learn también ofrece multiples alternativas y estrategias para realizarlo. En nuestro caso utilizaremos one hot encoding.

[ ]:
from sklearn.preprocessing import OneHotEncoder

for feat in discrete_feat:
  encoder = OneHotEncoder(handle_unknown='ignore', sparse_output=False)
  transformed = encoder.fit_transform(train[feat].values.reshape(-1,1))
  train[encoder.get_feature_names_out([feat])] = transformed

Una vez que las variables han sido codificadas, podemos eliminar las columnas originales:

[ ]:
train.drop(columns=discrete_feat, inplace=True)
[ ]:
print_stats(train)
Muestras: 32537
Features: 101
Faltantes: 0

Normalización de variables numéricas

Muchos algoritmos de aprendizaje automático son sencibles a la escala de los predictores que utilizan. La normalización nos permite asgurar que todos los predictores utilicen la misma.

[ ]:
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
train[continuous_feat] = scaler.fit_transform(train[continuous_feat])

Verificando los resultados

Veamos como luce nuestro conjunto de datos:

[ ]:
train.head(5)
income age fnlwgt education-num capital-gain capital-loss hours-per-week workclass_Federal-gov workclass_Local-gov workclass_Never-worked ... native-country_Portugal native-country_Puerto-Rico native-country_Scotland native-country_South native-country_Taiwan native-country_Thailand native-country_Trinadad&Tobago native-country_United-States native-country_Vietnam native-country_Yugoslavia
0 <=50K 0.030390 -1.063569 1.134777 2.830199 -0.22116 -0.035664 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
1 <=50K 0.836973 -1.008668 1.134777 -0.299391 -0.22116 -2.222483 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
2 <=50K -0.042936 0.245040 -0.420679 -0.299391 -0.22116 -0.035664 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
3 <=50K 1.056950 0.425752 -1.198407 -0.299391 -0.22116 -0.035664 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
4 <=50K -0.776193 1.408066 1.134777 -0.299391 -0.22116 -0.035664 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

5 rows × 101 columns

Empaquetando las transformaciones

Todas las transformaciones que acabamos de realizar, deben luego aplicarse a los otros conjuntos de datos: testing, validacion, e incluso a sus datos en producción cuando el modelo se ejecute. ¿Cómo puede asegurar que las transformaciones son exactamente las mismas?¿Cómo puede asegurar sobre todo la utilización de los mismos parámetros, como ser las normalizaciones o la codificación de variables categóricas?.

Una forma de realizarlo es utilizando scikit-learn y empaquetar todas las transformaciones.

Crearemos entonces una función llamada preprocess_uci la cual contendra los pasos que mencionamos anteriormente en una única rutina:

[ ]:
def preprocess_uci(X, y=None):
    val = X.copy()
    val.replace(' ?', np.nan, inplace=True)

    for col in val.dtypes[X.dtypes == 'object' ].keys():
        val[col] = val[col].str.lstrip()

    if 'marital-status' in val.columns:
        mapping = {
          'Divorced': 'Single',
          'Married-spouse-absent': 'Single',
          'Never-married': 'Single',
          'Separated': 'Single',
          'Widowed': 'Single',
          'Married-AF-spouse': 'Couple',
          'Married-civ-spouse': 'Couple',
        }

        val['marital-status'] = val['marital-status'].map(mapping)

    if 'capital-gain' in val.columns:
        val['capital-gain'] = np.log(val['capital-gain'] + 1)
        val['capital-loss'] = np.log(val['capital-loss'] + 1)

    return val

Note que esta función no incluye:

  • Normalización de variables.

  • Imputación de valores faltantes.

  • Codificación de variables.

El motivo es que estos pasos requieren de aprender «parametros» para poder aplicarlos. La libraria nos permite realizar estas acciones en lo que se conoce como pipelines. Así entonces, esta función la utilizaremos como uno de los pasos de una rutina de preprocesamiento más grande que adicionalmente escalará los valores y realizará la codificación de variables categóricas. Esta rutina más grande la llamamos «pipeline de preprocesamiento».

Lucira de la siguiente forma:

image.png

[ ]:
from typing import Tuple, List

import sklearn
from sklearn.pipeline import Pipeline, FunctionTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer


def prepare(X: pd.DataFrame, transformations: sklearn.compose.ColumnTransformer = None) -> Tuple[pd.DataFrame, sklearn.compose.ColumnTransformer]:
    """
    Preprocesasa los deferentes valores de un conjunto de datos.

    Parameters
    ----------
    X: pd.DataFrame:
        Connto de datos a transformar
    transformations: sklearn.compose.ComlumnTransformer
        Transformaciones que se deben aplicar al conjunto de datos. Si no son indicadas, las mismas son aprendidas desde el conjunto de datos.

    Returns: Tuple[pd.DataFrame, sklearn.compose.ColumnTransformer]
        Una tupla donde el primer component es el conjunto de datos transformado y el segundo las transformaciones que se aplicaron.
    """
    features = {
        'discrete': X.dtypes[X.dtypes == 'object' ].index.tolist(),
        'continuous': X.dtypes[X.dtypes != 'object'].index.tolist(),
    }

    preprocessing_func = FunctionTransformer(func=preprocess_uci, feature_names_out='one-to-one')

    num_pipe = Pipeline([
        ('preprocess', preprocessing_func),
        ('imputer', SimpleImputer(strategy='median')),
        ('scaler', StandardScaler())
    ])

    cat_pipe = Pipeline([
        ('preprocess', preprocessing_func),
        ('imputer', SimpleImputer(strategy='most_frequent')),
        ('encoder', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
    ])

    if transformations is None:
        transformations = ColumnTransformer(
            [
              ('continuous_pipe', num_pipe, features['continuous']),
              ('discrete_pipe', cat_pipe, features['discrete']),
            ],
            remainder='passthrough')

        X = transformations.fit_transform(X)
    else:
        X = transformations.transform(X)

    # Las columnas tiene el nombre "<paso del pipeline>__<columna>". Nos quedaremos con la segunda parte
    all_features = [feat.split('__')[1] for feat in transformations.get_feature_names_out()]

    return pd.DataFrame(X, columns=all_features), transformations

La ventaja de realizar las transformaciones de esta forma es que la función prepare no solo retorna los datos preprocesados sino que también un objeto que contiene cualquier valor que haya sido aprendido del proceso de transformación.

Note que si pasamos como argumento transformations entonces la rutina no aprende ningun coeficiente sino que utiliza los que nosotros le indicamos.

Veamos como esto termina siendo util:

Generando los conjuntos de datos

Generaremos 3 conjuntos de datos: entrenamiento, validación y testing.

[ ]:
train = pd.read_csv('datasets/uci_census/data/adult-train.csv')
test = pd.read_csv('datasets/uci_census/data/adult-test.csv')
[ ]:
from sklearn.model_selection import train_test_split

validation = test
test, _ = train_test_split(test, test_size=0.9, random_state=1234)

Separemos los predictores de la variable a predecir:

[ ]:
X_train = train.drop(['income'], axis=1)
y_train = train['income'].to_numpy()
X_test = test.drop(['income'], axis=1)
y_test = test['income'].to_numpy()
X_val = validation.drop(['income'], axis=1)
y_val = validation['income'].to_numpy()

Necesitamos preprocesar el conjunto de entrenamiento:

[ ]:
X_train_transformed, transformations = prepare(X_train)

Debemos ahora realizar las mismas transoformaciones sobre los otros dos conjuntos. Sin embargo, queremos aplicar los mismos coeficientes que aprendimos del conjunto de entrenamiento. Para ello simplemente pasamos como parametro las transformaciones:

[ ]:
X_test_transformed, _ = prepare(X_test, transformations)
X_val_transformed, _ = prepare(X_val, transformations)

Persisitiendo transformaciones

La ventaja de generar la rutina de preprocesamiento de esta forma es que ahora en transformations, tenemos todas las tranformaciones almacenadas y podemos guardarlas para luego aplicarlas:

[ ]:
import pickle
import cloudpickle

with open('transformations.pkl', 'wb') as f:
    cloudpickle.dump(transformations, f)

Podemos cargarlas y aplicarlas a un nuevo conjunto de datos:

[ ]:
with open('transformations.pkl', 'rb') as f:
  transoformations = pickle.load(f)
[ ]:
test = pd.read_csv('datasets/uci_census/data/adult-test.csv')
X_test = test.drop(['income'], axis=1)
y_test = test['income'].to_numpy()
[ ]:
X_test_transformed, _ = prepare(X_test, transformations)
[ ]:
X_test_transformed
age fnlwgt education-num capital-gain capital-loss hours-per-week workclass_Federal-gov workclass_Local-gov workclass_Never-worked workclass_Private ... native-country_Portugal native-country_Puerto-Rico native-country_Scotland native-country_South native-country_Taiwan native-country_Thailand native-country_Trinadad&Tobago native-country_United-States native-country_Vietnam native-country_Yugoslavia
0 -0.995706 0.350774 -1.197459 -0.299271 -0.221075 -0.035429 0.0 0.0 0.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
1 -0.042642 -0.947095 -0.420060 -0.299271 -0.221075 0.774468 0.0 0.0 0.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
2 -0.775768 1.394362 0.746039 -0.299271 -0.221075 -0.035429 0.0 1.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
3 0.397233 -0.279070 -0.031360 3.345796 -0.221075 -0.035429 0.0 0.0 0.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
4 -1.508894 -0.817458 -0.031360 -0.299271 -0.221075 -0.845327 0.0 0.0 0.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
16276 0.030671 0.242928 1.134739 -0.299271 -0.221075 -0.359389 0.0 0.0 0.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
16277 1.863485 1.247055 -0.420060 -0.299271 -0.221075 -0.035429 0.0 0.0 0.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
16278 -0.042642 1.754690 1.134739 -0.299271 -0.221075 0.774468 0.0 0.0 0.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
16279 0.397233 -1.003212 1.134739 3.206033 -0.221075 -0.035429 0.0 0.0 0.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
16280 -0.262580 -0.072293 1.134739 -0.299271 -0.221075 1.584366 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0

16281 rows × 100 columns