<jupyter_start><jupyter_text>Indexación y selección de datos  La semana pasada vimos cómo manejar arrays de NumPy, lo cual incluía indexación (``arr[2, 1]``), slicing (``arr[:, 1:5]``), masking (e.g., ``arr[arr > 0]``), y combinaciones de ambos (``arr[:, [1, 5]]``).Ahora veremos su equivalente en los objetos de Pandas ``Series`` y ``DataFrames``, que será muy parecido a lo visto para los arrays de NumPy aunque con algún que otro cambio.Al igual que hemos hecho en el notebook anterior, comenzaremos por el caso unidimensional, los ``Series``, y seguiremos con el caso bidimensional, los ``DataFrames``.  Selección de datos en SeriesTal como hemos visto, los ``Series`` funcionan a veces como un arrau de 1 dimensión, y otras como un diccionario estándar de Python.Con estas dos ideas en mente, podremos entender mejor el concepto de ``Series`` y cómo funciona el indexado y la selección de datos en estos arrays.  Series como diccionarioComo un diccionario, los objetos ``Series`` ofrecen una serie de claves (los índices) a un conjunto de valores:<jupyter_code>import pandas as pd
import numpy as np

data = pd.Series([0.25, 0.5, 0.75, 1.0],
                 index=['a', 'b', 'c', 'd'])
data
data['b']<jupyter_output><empty_output><jupyter_text>También existe esa dualidad con los diccionarios en muchas situaciones, como examinar si un índice está en un dataframe, que será del mismo modo que lo hacíamos para ver si una clave estaba en un diccionario:<jupyter_code>'a' in data
data.keys()
list(data.items())<jupyter_output><empty_output><jupyter_text>Los objetos ``Series`` también pueden ser modificados como si de un diccionario se tratase, pues podemos especificar un nuevo valor para un nuevo índice (o sobreescribir uno ya existente), así como creábamos nuevos valores para nuevas claves:<jupyter_code>data
data['e'] = 1.25
data<jupyter_output><empty_output><jupyter_text>Series como array unidimensional Los ``Series`` nos ofrecen una interfaz parecida a los diccionarios con una selección de datos similar a la de los arrays, donde utilizamos mecanismos como slices, masking...Veamos unos ejemplos:<jupyter_code>data
# slicing por nombre de índice
data['a':'c']
# slicing por posición
data[0:2]
# Operaciones elemento a elemento:
(data > 0.3) & (data < 0.8)
data
# masking
data[(data > 0.3) & (data < 0.8)]
data[[True,False,False,True]]
(data > 0.3) & (data < 0.8)
data
data[['a', 'b']]<jupyter_output><empty_output><jupyter_text>Entre estos, el slicing puede ser la fuente de mayor confusión, ya que hay que tener en cuenta que al cortar con un índice explícito (es decir, `` data ['a': 'c'] ``), el índice final se incluye en el índice, mientras que cuando se divide con un índice implícito (es decir, `` data [0: 2] ``), el índice final se excluye del segmento.  Indexers: loc e ilocComo hemos comentado anteriormente, estas convenciones de indexación y slicing pueden ser una fuente de confusión.Por ejemplo, si una ``Serie`` tiene un índice entero explícito, una operación de indexación como ``data[1]`` usará índices explícitos, mientras que una operación de slicing como ``datos[1: 3]`` utilizará el índice implícito de estilo Python.<jupyter_code>data = pd.Series(['a', 'b', 'c'], index=[1, 3, 5])
data
# Índice explícito con indexación
data[1]
# Índice implícito con slicing
data[1:3]<jupyter_output><empty_output><jupyter_text>Debido a esto, Pandas ofrece unos atributos especiales de indexación para evitar esta confusión, exponiendo de manera explícita ciertos esquemas de indexación.Estos no son métodos funcionales, sino atributos que exponen una interfaz de slicing particular a los datos en el objeto ``Series``. A continuación, veremos como el atributo ``loc`` permite indexar y hacer slicing mediante el uso del índice explícito (siempre):<jupyter_code>data
data.loc[1]
data.loc[1:3]<jupyter_output><empty_output><jupyter_text>Por otra parte, tenemos el atributo ``iloc``, que permite indexar y hacer slicing siempre referenciando a posiciones implícitas, tal como se hace en otros elementos de Python, como los arrays:<jupyter_code>data.iloc[1]
data.iloc[1:3]<jupyter_output><empty_output><jupyter_text>Ejercicios1. Crea un Series con 100 números aleatorios entre 0 y 102. ¿Qué número de la posición 20?3. Imprime por pantalla los números entre las posiciones 24 y 324. Imprime por pantalla los números desde la posición 50 a la 70 que estén en posiciones pares5. ¿Cuál es la media de los números entre las posiciones 30 y 60?6. ¿Cuántos números hay mayores o iguales que 3? ¿Y con valor entre 5 y 7 (ambos inclusive)?7. Calcula los números menores o iguales que 1 o mayores o iguales que 9 en un nuevo Series. Imprime por pantalla, en sentido contrario, aquellos con posiciones pares:<jupyter_code>series1 = pd.Series(np.random.rand(100))

np.random.seed(10)
# 1.
series1 = pd.Series(np.random.randint(0, 10, size=100))
# print(series1)

# # 2.
# print(series1.iloc[20])

# # 3.
# print(series1.iloc[24:33])

# # 4.
# print(series1.iloc[50:71:2])

# # 5.
# print(series1.iloc[30:60].mean())
# print(np.mean(series1.iloc[30:60]))

# 6.
mask_1 = series1 >= 3
mask_2 = (series1 >= 5) & (series1 <= 7)

print('VI_Mayor o igual que 3:', series1[mask_1].count())
print('VI_Entre 5 y 7 (Ambos Inclusive):', series1[mask_2].count())


# 7.

nuevo_series = series1.iloc[0:101:2]
nuevo_series

mask = (series1 >= 9) | (series1 <= 1)
# print(nuevo_series[mask].sort_index(ascending=False))

nuevo_series = series1.iloc[98:0:-2]
mask = (nuevo_series >= 9) | (nuevo_series <= 1)
nuevo_series[mask]<jupyter_output>VI_Mayor o igual que 3: 63
VI_Entre 5 y 7 (Ambos Inclusive): 21<jupyter_text>Selección de datos en DataFramesComo hemos visto, un ``DataFrame`` actúa en algunos casos como una matriz bidimensional (o estructurada), y en otras, como un diccionario de ``Series`` que comparten el mismo índice.Será bueno tener esta analogía en la cabeza para entender mejor la selección de datos de los DataFrames:  DataFrames como diccionariosLa primera de nuestras analogías consideraba que un ``DataFrame`` es un diccionario de ``Series``.Probemos esta teoría con algunos ejemplos:<jupyter_code>area = pd.Series({'California': 423967, 'Texas': 695662,
                  'New York': 141297, 'Florida': 170312,
                  'Illinois': 149995})
pop = pd.Series({'California': 38332521, 'Texas': 26448193,
                 'New York': 19651127, 'Florida': 19552860,
                 'Illinois': 12882135})
data = pd.DataFrame({'area':area, 'pop':pop})
data<jupyter_output><empty_output><jupyter_text>Se puede acceder a cada uno de los ``Series`` que componen las columnas del ``DataFrame`` mediante la indexación del nombre de la columna como si de un diccionario se tratase:<jupyter_code>data['area']<jupyter_output><empty_output><jupyter_text>Del mismo modo, podemos usar el acceso mediante atributo con el nombre de la columna, siempre y cuando sea string:<jupyter_code>data.area<jupyter_output><empty_output><jupyter_text>Este acceso a la columna mediante atributo, lo que hace es acceder exactamente al mismo objeto que en el caso del diccionario:<jupyter_code>data.area is data['area']<jupyter_output><empty_output><jupyter_text>Aunque esta es una abreviatura útil, hay que tener en cuenta sus limitaciones, ya que no funciona si los nombres de las columnas no son cadenas, o si los nombres de las columnas entran en conflicto con los métodos del ``DataFrame``. En estos casos, el acceso mediante atributo no será posible.Por ejemplo, los ``DataFrame`` tienen un método ``pop ()`` (como el visto en las listas), por lo que ``data.pop`` apuntará a este método en lugar de a la columna ``pop``, por mucho que tengamos una columna así. Prevalecerán siempre los atributos/métodos definidos por defecto:<jupyter_code>data.pop is data['pop']<jupyter_output><empty_output><jupyter_text>En particular, se debe evitar la tentación de hacer la asignación de columnas a través de un atributo (es decir, debemos usar ``data ['pop'] = z`` en lugar de ``data.pop = z``).Al igual que con los objetos ``Serie``, esta sintaxis de estilo diccionario también se puede usar para modificar el objeto, en este caso agregando una nueva columna:<jupyter_code>data['density'] = data['pop'] / data['area']
data<jupyter_output><empty_output><jupyter_text>DataFrames como arrays bidimensionalesComo hemos mencionado anteriormente, también podemos interpretar los ``DataFrames`` como arrays bidimensionales mejorados.Podemos examinar los valores en bruto del array de datos mediante el atributo ``values``:<jupyter_code>data.values<jupyter_output><empty_output><jupyter_text>Con esta imagen en mente, se pueden hacer muchas observaciones similares a las que tenemos para las matrices en el propio ``DataFrame``.Por ejemplo, podemos transponer el ``DataFrame`` completo para intercambiar filas y columnas:<jupyter_code>data.T
data<jupyter_output><empty_output><jupyter_text>Sin embargo, cuando se trata de indexar ``DataFrames``, está claro que la indexación de columnas como si fueran diccionarios, excluye nuestra capacidad para tratarlos simplemente como una matriz de NumPy.En particular, pasar un único índice a una matriz accede a una fila:<jupyter_code>data.values[0]<jupyter_output><empty_output><jupyter_text>y pasar un solo índice a un ``DataFrame`` accede a una columna:<jupyter_code>data['area']<jupyter_output><empty_output><jupyter_text>Por lo tanto, para la indexación de estilo de matriz, necesitamos otra convención, como la vista para ``Series``.En este caso, Pandas usa nuevamente los indexadores ``loc`` e ``iloc`` mencionados anteriormente. Mediante el indexador ``iloc``, podemos indexar la matriz subyacente como si fuera una matriz NumPy simple (usando el índice implícito al estilo Python), pero el índice del ``DataFrame`` y las etiquetas de las columnas se mantienen en el resultado:<jupyter_code>data.iloc[:3, :2]<jupyter_output><empty_output><jupyter_text>De manera similar, usando el indexador ``loc`` podemos indexar los datos subyacentes en un estilo de matriz, pero usando el índice explícito y los nombres de columna:<jupyter_code>data.loc[:'Illinois', :'pop']<jupyter_output><empty_output><jupyter_text>Cualquiera de las formas de acceso a datos que utiliza NumPy se puede utilizar dentro de estos indexadores.Por ejemplo, en el indexador ``loc`` se puede combinar enmascaramiento e indexación como se muestra a continuación:<jupyter_code>data
data.loc[data.density > 100, ['area', 'pop']]
data['area']/data['pop']
data.density > 100
data.density > 100<jupyter_output><empty_output><jupyter_text>Cualquiera de estas convenciones también se pueden usar para seleccionar o modificar valores, lo que se consigue de la forma a la que estamos acostumbrados trabajando con NumPy:<jupyter_code>data.iloc[0, 2] = 90
data<jupyter_output><empty_output><jupyter_text>Si queremos desarrollar nuestra destreza con la manipulación de datos de Pandas, será bueno practicar cogiendo un ``DataFrame`` y explorando los tipos de indexación, slicing y enmascaramiento que permiten estos diversos enfoques de indexación.  Otras convenciones de indexaciónHay un par de convenciones de indexación adicionales que pueden parecer contrarias a lo que acabamos de ver, pero que pueden resultar muy útiles en la práctica.La primera es que, mientras que indexar se refiere a columnas, slicing se refiere a filas:<jupyter_code>data
data['area']
data['Florida':'Illinois']<jupyter_output><empty_output><jupyter_text>Dichos sectores también pueden referirse a filas por número en lugar de por índice:<jupyter_code>data[0:3]<jupyter_output><empty_output><jupyter_text>De manera similar, las operaciones de enmascaramiento directo también se interpretan por filas en lugar de por columnas:<jupyter_code>data[data.density > 100]<jupyter_output><empty_output><jupyter_text>Estas dos convenciones son sintácticamente similares a las de una matriz NumPy y, si bien es posible que no se ajusten con precisión a las convenciones de Pandas, son bastante útiles en la práctica.  Comprobando los tipos de las columnasPara comprobar los tipos de datos que componen un DataFrame utilizaremos el atributo ``dtypes``, mientras que para una columna en concreto (al igual que con los tipo Series) será el atributo ``dtype``.Veamos un par de ejemplos:<jupyter_code>data.dtypes<jupyter_output><empty_output><jupyter_text>Aquí observamos que el DataFrame ``data`` tiene 3 columnas, de las cuales 'area' y 'pop' son enteros, mientras que 'density' es decimal.En cuanto a los Series:<jupyter_code>serie = pd.Series([0.25, 0.5, 0.75, 1.0], index=['a', 'b', 'c', 'd'])

serie.dtype<jupyter_output><empty_output><jupyter_text>Como una serie solo puede tener un tipo (recordad que se fuerza a que todos los elementos de la serie tengan el mismo tipo, que será el menos restrictivo), solo podemos acceder a un valor, por eso se usa ``dtype`` y no ``dtypes``, ya que el DataFrame, al tener varias columnas, devolverá varios valores.Ejemplo con datos variados (conversión al menos restrictivo):<jupyter_code>array_raro = np.array([1, 2])
array_raro.dtype
serie2 = pd.Series([1, 0.5, 'a', [1, 2, 3], {'a': 1, 'b': 3}, sum], index=['a', 'b', 'c', 'd', 'e', 'f'])

serie2.dtype<jupyter_output><empty_output><jupyter_text>Ese tipo 'O' hace referencia a que son objetos, que será lo que se asigne a datos que no son ni numéricos ni booleanos. En este caso, si solo tuviéramos los 2 primeros valores sí sería numérica, pero al añadir otros como listas, diccionarios o strings, se cambia a ``object``.Análogamente a lo visto para las Series, ocurre lo mismo en las columnas de un DataFrame, pues son del tipo Series:<jupyter_code>data['area'].dtype<jupyter_output><empty_output><jupyter_text>Cambiando ejes: índices de fila y nombres de columnasHemos visto cómo acceder a los elementos de un DataFrame, tanto por filas como por columnas, de diferentes formas. También hemos visto que al crear un Series o un DataFrame podemos establecer los índices y las etiquetas de las columnas, a no ser que vengan ya impuestos, en cuyo caso estaríamos filtrando sobre los índices ya impuestos.Esto ocurre, principalmente, cuando creamos Series a partir de diccionarios, donde no podemos imponer explícitamente el índice de las filas:<jupyter_code>dic = {'a': 1, 'b': 2, 'c': 19.1}
s = pd.Series(dic, index = ['a', 'b'])
s<jupyter_output><empty_output><jupyter_text>Como habíamos visto, al imponer explícitamente, el Series resultante tendrá los valores de diccionario filtrados por el índice que le digamos. En el caso de que le pongamos índice con elementos que no tiene en las claves del diccionario, rellenará con NaN:<jupyter_code>s = pd.Series(dic, index = ['a', 'x', 'z'])
s<jupyter_output><empty_output><jupyter_text>En el caso de los DataFrame también ocurre esto, por ejemplo, al crearlo mediante un diccionario de Series, donde ocurrirá algo equivalente al establecer de manera explícita el índice:<jupyter_code># Nos creamos 2 series:
s1 = pd.Series([1, 2, 3, 4], index = ['a', 'b', 'c', 'd'])
s2 = pd.Series([5, 1, 1, 23], index = ['a', 'b', 'c', 'd'])

df = pd.DataFrame({'columna1': s1, 'supercolumna2': s2}, index = ['a', 'z'])
df<jupyter_output><empty_output><jupyter_text>O las columnas:<jupyter_code>df = pd.DataFrame({'columna1': s1, 'supercolumna2': s2}, columns = ['columna1', 'z'])
df<jupyter_output><empty_output><jupyter_text>Entonces... ¿cómo puedo hacer para renombrar las columnas o las filas?Muy sencillo, podemos acceder a los atributos (del DataFrame o del Series) y cambiarlo a mano:<jupyter_code>df = pd.DataFrame({'columna1': s1, 'supercolumna2': s2})
print("DataFrame original:")
print(df)

df.columns = ['col1_cambiada_de_nombre', 'megacolumna2']
print("\nDataFrame cambiando columnas:")
print(df)

df.index = [1, 2, 3, 49]
print("\nDataFrame cambiando columnas e índices:")
print(df)<jupyter_output>DataFrame original:
   columna1  supercolumna2
a         1              5
b         2              1
c         3              1
d         4             23

DataFrame cambiando columnas:
   col1_cambiada_de_nombre  megacolumna2
a                        1             5
b                        2             1
c                        3             1
d                        4            23

DataFrame cambiando columnas e índices:
    col1_cambiada_de_nombre  megacolumna2
1                         1             5
2                         2             1
3                         3             1
49                        4            23<jupyter_text>Sencillo, ¿verdad? Lo único que tendremos que tener en cuenta es el tamaño del eje que estemos renombrando:<jupyter_code>df.columns = ['a', 'b', 'c']
df.index = [1, 2]<jupyter_output><empty_output><jupyter_text>Al igual que con los DataFrames, también lo podemos aplicar a los Series (salvo las columnas, que no tiene):<jupyter_code>s1 = pd.Series([1, 2, 3, 4], index = ['a', 'b', 'c', 'd'])

print(s1.index)

s1.index = ['w', 'x', 'y', 'z']
print(s1.index)
s1<jupyter_output>Index(['a', 'b', 'c', 'd'], dtype='object')
Index(['w', 'x', 'y', 'z'], dtype='object')<jupyter_text>Otra forma, para cambiar tanto columnas como índices, sería el método que implementa pandas ``.rename``, que damite como parámetro un diccionario que renombrará la columna clave con el valor asociado del diccionario. Este diccionario se le puede pasar tanto para columnas (``columns``) como para filas (``ìndex``)<jupyter_code>df = df.rename(columns={"otra_columna": "z"})<jupyter_output><empty_output><jupyter_text>Ejercicios1. Crea un Series con 100 números aleatorios (decimales o enteros, como tú quieras) entre 0 y 100, y otro con números también aleatorios entre 0 y 12. Crea otro Series del mismo tamaño con booleanos aleatorios3. Crea un DataFrame con estas Series, cuyos nombres de columnas (por orden de creación) serán 'masa', 'densidad' y 'comestible'3. Cambia el nombre de la columna 'comestible' por 'otra_columna'4. Añade una nueva columna ('volumen') al DataFrame que sea la división de 'masa' entre 'densidad'5. Calcula otra columna ('volasa') que sea la suma del volumen y la masa6. Muestra por pantalla las filas pares de las columnas 'volumen' y 'masa'7. Imprime por pantalla los registros que tengan True la columna 'otra_columna' que tengan un volumen superior a 508. Modifica todos los valores de la columna 'densidad' que sean menores que 0.5 para que no lo sean, sumándoles 0.59. Imprime por pantalla los valores de las masas que se correspondan con volúmenes superiores a 100<jupyter_code>np.random.seed(10)

series1 = pd.Series(np.random.randint(0, 101, size = 100))
series2 = pd.Series(np.random.rand(100))
series3 = pd.Series(np.random.choice([True, False], 100))

df = pd.DataFrame({'masa': series1,
                  'densidad': series2,
                  'comestible': series3,
                  })

df = df.rename(columns={"comestible": "otra_columna"})
df

df['volumen'] = df['masa'] / df['densidad']
df

df['volasa'] = df['volumen'] + df['masa']
df

# df.loc[::2, ['masa', 'volumen']]
df.iloc[::2, [0, 3]]


df.loc[(df.otra_columna == True) & (df.volumen > 50)]

df['mask'] = df.densidad < 0.5
df.loc[df.densidad < 0.5, ['densidad']] = df.loc[df.densidad < 0.5, ['densidad']] + 0.5
df

# mask = df['densidad'] < 0.5
# a = df[mask]['densidad'] + 0.5
# df.loc[df[df['densidad'] < 0.5].index]['densidad'] = 0.5
# df

d1 = []
for i in df['densidad']:
    if i < 0.5:
        d1.append(i + 0.5)
    else:
        d1.append(i)

d2 = pd.Series(d1)

df['densidad'] = d2
df


mask = df['volumen']>100
df.masa[mask]

df.loc[df.volumen>100]["masa"]

a = [1, 2, 3]
b = a.copy()
a[0] = 10

print(b)<jupyter_output>[1, 2, 3]
{'fraction_non_alphanumeric': 0.10438071870367531, 'fraction_numerical': 0.023769360527526453, 'mean_word_length': 4.941087154570301, 'pattern_counts': {'":': 2, '<': 161, '<?xml version=': 0, '>': 164, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 8, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '38', 'dir': 'jupyter-structured-clean-dedup', 'id': '433955', 'license': 'MIT', 'n_tokens_mistral': 7170, 'n_tokens_neox': 6824, 'n_words': 2359, 'path': 'semana_6/dia_1/2_BasicPandas.ipynb', 'repo_name': 'ClaraLL7/DS-part-time-sep2020', 'size': '19563'}