Skip to content

    Chapter 10, Basic

    Duplicates, types, and names

    The problems in this chapter do not throw errors: Pandas reads everything without complaint, and the calculations run. Cleaning is discovering what is silently wrong and correcting it with minimal loss.

    Duplicates

    There are two different questions: are there entirely repeated rows, and are there repeated identifiers? The duplicated() compares all columns by default, and the subset restricts the comparison:

    basico/cap10_limpeza_tipos_texto.pylinhas 10 a 19
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    df = pd.read_csv("dados/funcionarios_15.csv")
    
    print(int(df.duplicated().sum()), int(df.duplicated(subset=["id_funcionario"]).sum()))
    colunas = ["id_funcionario", "nome", "departamento", "experiencia", "data_admissao"]
    print(df.loc[df.duplicated("id_funcionario", keep=False), colunas])
    
    Output
    0 1
        id_funcionario       nome departamento  experiencia data_admissao
    0              101  Ana Souza   Engenharia            1    2021-01-10
    14             101  Ana Souza           RH            7    2023-06-24
    

    No row is entirely repeated (0), but one identifier appears twice (1). The same employee was registered twice, and with updated fields in the second: the department changed, and the experience as well. That's why the comparison of entire rows found nothing.

    The drop_duplicates needs to know which to keep, and the choice matters:

    basico/cap10_limpeza_tipos_texto.pylinhas 21 a 25
    primeiro = df.drop_duplicates(subset=["id_funcionario"], keep="first")
    ultimo = df.drop_duplicates(subset=["id_funcionario"], keep="last")
    print(len(primeiro), len(ultimo))
    print(primeiro.loc[primeiro["id_funcionario"] == 101, "departamento"].item())
    print(ultimo.loc[ultimo["id_funcionario"] == 101, "departamento"].item())
    
    Output
    14 14
    Engenharia
    RH
    

    The keep="first" keeps the first registration (Engineering), and the keep="last" keeps the most recent (HR). If the second record is an update, the right thing is to keep the newest, not the first that appeared. The original course keeps the first, and I follow this in the next chapters for the numbers to match the course, but in real data I sort by date first before removing, and keep the last.

    Types

    The column salario is str, because two rows have R$ 4650 and R$ 5550. There are two ways to convert it to a number. The to_numeric(errors="coerce") converts what it can and turns the rest into NaN, and this destroys the two salaries written with R$:

    basico/cap10_limpeza_tipos_texto.pylinhas 30 a 35
    direto = pd.to_numeric(df["salario"], errors="coerce")
    print(int(df["salario"].isna().sum()), int(direto.isna().sum()))
    
    sem_simbolo = df["salario"].str.replace("R$ ", "", regex=False)
    certo = pd.to_numeric(sem_simbolo)
    print(int(certo.isna().sum()), certo.dtype)
    
    Output
    2 4
    2 float64
    

    The truly absent were 2. With the coerce directly, they turned into 4: we lost 2 salaries that existed, just written in a different way. First remove the symbol and then converting (without coerce) preserves everything. And without the coerce, a value that really isn't a number for execution with a clear message:

    basico/cap10_limpeza_tipos_texto.pylinhas 37 a 40
    try:
        pd.to_numeric(pd.Series(["10", "abc"]))
    except ValueError as erro:
        print("ValueError:", erro)
    
    Output
    ValueError: Unable to parse string "abc" at position 1
    

    The errors="coerce" is useful, but it's a decision to discard data. I use it after checking how many values it will transform into NaN, and not as the first attempt.

    The `astype(int)` and the missing value

    Converting a column with absents to integer fails because the NaN is not an integer. The nullable type Int64 solves:

    basico/cap10_limpeza_tipos_texto.pylinhas 45 a 49
    try:
        df["idade"].astype(int)
    except ValueError as erro:
        print(type(erro).__name__)
    print(df["idade"].astype("Int64").tolist()[4:8])
    
    Output
    IntCastingNaNError
    [28, 29, <NA>, 31]
    

    Dates

    A date stored as text is useless: it doesn't sort by time, doesn't subtract, doesn't extract the year. The to_datetime transforms it into a real date type, and the accessor .dt provides the pieces:

    basico/cap10_limpeza_tipos_texto.pylinhas 54 a 56
    datas = pd.to_datetime(df["data_admissao"])
    print(datas.dtype, datas.dt.year.tolist()[:5])
    print(datas.min().date(), datas.max().date())
    
    Output
    datetime64[us] [2021, 2022, 2023, 2024, 2021]
    2021-01-10 2024-08-17
    

    Column names

    The rename returns a new table and leaves the original intact (no need for inplace). To clean all the names at once, chain the text methods. In Portuguese, removing accents avoids a lot of headaches with column names, and the normalize does this:

    basico/cap10_limpeza_tipos_texto.pylinhas 61 a 74
    renomeada = df.rename(columns={"nome": "nome_completo"})
    print(renomeada.columns.tolist()[:3], df.columns.tolist()[:3])
    
    sujas = pd.Series([" Nome Completo ", "Salário (R$)", "Data de Admissão"])
    limpas = (
        sujas.str.strip()
        .str.lower()
        .str.normalize("NFKD")
        .str.encode("ascii", errors="ignore")
        .str.decode("ascii")
        .str.replace(r"[^a-z0-9]+", "_", regex=True)
        .str.strip("_")
    )
    print(limpas.tolist())
    
    Output
    ['id_funcionario', 'nome_completo', 'departamento'] ['id_funcionario', 'nome', 'departamento']
    ['nome_completo', 'salario_r', 'data_de_admissao']
    

    The normalize("NFKD") separates each accented letter into letter plus accent, and the encode("ascii", errors="ignore") discards everything that is not ASCII (the loose accents), so that Salário becomes Salario. I chose this path on purpose, instead of a regular expression that deletes the accents: with the pyarrow installed, the type str from Pandas 3 uses the Arrow regular expression engine, which accepts \p{M} but rejects \u0300, and the module re of Python does the opposite. The same pattern may work on one machine and fail on another. Chapter 17 revisits this.

    Inconsistent text

    The departamento has engenharia and MARKETING next to Engenharia and Marketing. The common solution is strip().str.title(), but the title() ruins acronyms and prepositions:

    basico/cap10_limpeza_tipos_texto.pylinha 79
    print(df["departamento"].str.strip().str.title().dropna().unique().tolist())
    
    Output
    ['Engenharia', 'Marketing', 'Ciência De Dados', 'Rh', 'Finanças']
    

    Notice in Rh (the acronym lost its form) and in Ciência De Dados (the De gained uppercase). In columns of known categories, the safest path is a dictionary with the correctnames, applied with the map. And it brings a bonus: what are not is in the dictionary becomes NaN, which allows to detect unexpected values:

    basico/cap10_limpeza_tipos_texto.pylinhas 81 a 91
    CANONICO = {
        "engenharia": "Engenharia",
        "marketing": "Marketing",
        "finanças": "Finanças",
        "ciência de dados": "Ciência de Dados",
        "rh": "RH",
    }
    limpo = df["departamento"].str.strip().str.lower().map(CANONICO)
    inesperados = df.loc[limpo.isna() & df["departamento"].notna(), "departamento"]
    print(limpo.value_counts(dropna=False).to_dict())
    print(len(inesperados))
    
    Output
    {'Marketing': 4, 'Engenharia': 3, 'RH': 3, 'Ciência de Dados': 2, 'Finanças': 2, nan: 1}
    0
    

    Now there are 5 departments (plus 1 missing), and no unexpected value: what was not in the dictionary would be shown in inesperados. If tomorrow it arrives Tecnologia in a new file, it becomes NaN and you are notified, instead of silently gaining a sixth department.

    Exercise 1

    Clean the salary

    Write limpar_salario(serie), which receives a Series of text with values like "R$ 4650", "4800" and missing, and returns a Series of numbers (float64), without losing any value that exists.