Pular para o conteúdo

    Capítulo 18, Intermediário

    Datas e séries temporais

    Uma data guardada como texto não ordena, não subtrai e não agrupa por mês. Convertida em data de verdade, ela abre três ferramentas: o acessor `.dt`, o `resample` e as operações de deslocamento.

    Ler datas direto na leitura

    O parse_dates converte as colunas já no read_csv, e o acessor .dt entrega as peças de cada data:

    intermediario/cap18_datas.pylinhas 10 a 21
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    
    pedidos = pd.read_csv("dados/pedidos.csv", parse_dates=["data_pedido"])
    print(pedidos["data_pedido"].dtype, pedidos["data_pedido"].min().date(), pedidos["data_pedido"].max().date())
    
    data = pedidos["data_pedido"]
    print(data.dt.year.iloc[0], data.dt.month.iloc[0], data.dt.dayofweek.iloc[0], data.dt.quarter.iloc[0])
    dias = {0: "seg", 1: "ter", 2: "qua", 3: "qui", 4: "sex", 5: "sáb", 6: "dom"}
    print(data.dt.dayofweek.map(dias).value_counts().reindex(list(dias.values())).tolist())
    
    Saída
    datetime64[us] 2025-01-01 2025-12-31
    2025 1 2 1
    [126, 136, 125, 83, 109, 108, 113]
    

    O dayofweek vai de 0 (segunda) a 6 (domingo). Eu uso um dicionário para os nomes em português, em vez do day_name(locale=...), que depende de a máquina ter esse idioma instalado.

    Reamostrar: de dias para meses

    O resample é um groupby para períodos de tempo: ele precisa de um índice de datas, e você escolhe a frequência. Aqui, a quantidade pedida por mês:

    intermediario/cap18_datas.pylinhas 26 a 28
    por_mes = pedidos.set_index("data_pedido")["quantidade"].resample("MS").sum()
    print(len(por_mes), por_mes.index[0].date(), por_mes.iloc[:3].tolist())
    print(por_mes.head(3))
    
    Saída
    12 2025-01-01 [366, 286, 367]
    data_pedido
    2025-01-01    366
    2025-02-01    286
    2025-03-01    367
    Freq: MS, Name: quantidade, dtype: int64
    
    FrequênciaSignifica
    DDia
    WSemana
    MSInício de cada mês
    MEFim de cada mês
    QSInício de cada trimestre
    YSInício de cada ano

    O código antigo usava "M" para mês, e isso não funciona mais no Pandas 3:

    intermediario/cap18_datas.pylinhas 30 a 33
    try:
        pedidos.set_index("data_pedido")["quantidade"].resample("M").sum()
    except ValueError as erro:
        print("ValueError:", str(erro)[:35])
    
    Saída
    ValueError: Invalid frequency: M. Failed to par
    

    Variação entre períodos

    Com a série mensal, shift, diff e pct_change comparam cada mês com o anterior, sem laço:

    intermediario/cap18_datas.pylinhas 38 a 42
    resumo = pd.DataFrame({"quantidade": por_mes})
    resumo["mes_anterior"] = resumo["quantidade"].shift(1)
    resumo["variacao"] = resumo["quantidade"].diff()
    resumo["variacao_pct"] = (resumo["quantidade"].pct_change() * 100).round(1)
    print(resumo.head(4))
    
    Saída
                 quantidade  mes_anterior  variacao  variacao_pct
    data_pedido                                                  
    2025-01-01          366           NaN       NaN           NaN
    2025-02-01          286         366.0     -80.0         -21.9
    2025-03-01          367         286.0      81.0          28.3
    2025-04-01          317         367.0     -50.0         -13.6
    

    O primeiro mês fica sem comparação (NaN), porque não tem anterior.

    Datas em formatos misturados

    No arquivo de funcionários, três datas estão escritas como dd/mm/aaaa, e as demais como aaaa-mm-dd. O Pandas infere o formato da primeira data e falha ao encontrar a primeira que não bate:

    intermediario/cap18_datas.pylinhas 47 a 51
    f = pd.read_csv("dados/funcionarios_100.csv")
    try:
        pd.to_datetime(f["data_admissao"])
    except ValueError as erro:
        print("ValueError:", str(erro).splitlines()[0])
    
    Saída
    ValueError: time data "29/09/2019" doesn't match format "%Y-%m-%d". You might want to try:
    

    A "correção" que mais se encontra por aí é format="mixed" com dayfirst=True. Ela não dá erro, e por isso parece resolver. Veja o que ela faz de verdade, comparando com a conversão explícita: primeiro o formato ISO, depois o brasileiro nas que sobraram, e NaT (a data ausente) para o que não casar com nenhum:

    intermediario/cap18_datas.pylinhas 53 a 62
    iso = pd.to_datetime(f["data_admissao"], format="%Y-%m-%d", errors="coerce")
    br = pd.to_datetime(f["data_admissao"], format="%d/%m/%Y", errors="coerce")
    explicito = iso.fillna(br)
    print(int(iso.isna().sum()), int(explicito.isna().sum()))
    
    tentacao = pd.to_datetime(f["data_admissao"], format="mixed", dayfirst=True)
    trocadas = tentacao != explicito
    print(int(trocadas.sum()), len(f))
    linha = trocadas.idxmax()
    print(f.loc[linha, "data_admissao"], tentacao[linha].date(), explicito[linha].date())
    
    Saída
    3 0
    32 104
    2019-08-07 2019-07-08 2019-08-07
    

    A conversão explícita acha as 3 datas brasileiras e não deixa nenhuma sem data. Já o dayfirst=True trocou o dia pelo mês em 32 das 104 datas: a data ISO 2019-08-07 (7 de agosto) virou 2019-07-08 (8 de julho). Isso acontece com toda data ISO cujo dia seja 12 ou menos, porque o dayfirst manda tratar o primeiro número depois do ano como dia. É a pior espécie de erro: o código roda, as datas continuam válidas, e um terço delas está errado.

    O format="mixed" sem o dayfirst acerta aqui, mas só por sorte: as três datas brasileiras têm dia maior que 12, então não podem ser lidas como mês. Uma data como 04/05/2020 seria lida como abril:

    intermediario/cap18_datas.pylinhas 64 a 65
    ambiguas = pd.Series(["2020-01-31", "04/05/2020"])
    print(pd.to_datetime(ambiguas, format="mixed").dt.strftime("%Y-%m-%d").tolist())
    
    Saída
    ['2020-01-31', '2020-04-05']
    

    A data 04/05/2020 brasileira é 4 de maio, e o Pandas a leu como 5 de abril. A regra que eu sigo: nunca adivinhe um formato de data. Declare cada formato que você aceita, converta por grupos com errors="coerce", conte o que ficou como NaT e investigue. Uma data recusada é um problema visível, e uma data trocada é um problema invisível.

    Tempo de casa e deslocamentos

    A subtração de duas datas dá uma duração (Timedelta). Com uma data de referência fixa, o resultado é reproduzível: um relatório que usa hoje() muda a cada dia, e isso quebra qualquer teste.

    intermediario/cap18_datas.pylinhas 70 a 75
    referencia = pd.Timestamp("2026-01-01")
    admissao = explicito
    tempo_de_casa_anos = ((referencia - admissao).dt.days // 365)
    print(tempo_de_casa_anos.describe()[["min", "max"]].astype(int).tolist())
    print(pd.Timestamp("2025-01-31") + pd.offsets.MonthEnd(1), pd.Timestamp("2025-01-10") + pd.Timedelta(days=30))
    print(pd.date_range("2025-01-01", periods=4, freq="W").strftime("%d/%m").tolist())
    
    Saída
    [0, 10]
    2025-02-28 00:00:00 2025-02-09 00:00:00
    ['05/01', '12/01', '19/01', '26/01']
    

    Fuso horário

    Uma data sem fuso é ambígua. O tz_localize diz em que fuso ela foi registrada, e o tz_convert a leva para outro. A regra de ouro: guarde em UTC e converta só para mostrar.

    intermediario/cap18_datas.pylinhas 80 a 82
    registro = pd.to_datetime(pd.Series(["2025-01-10 12:00", "2025-07-10 12:00"]))
    em_sp = registro.dt.tz_localize("America/Sao_Paulo")
    print(em_sp.dt.tz_convert("UTC").astype(str).tolist())
    
    Saída
    ['2025-01-10 15:00:00+00:00', '2025-07-10 15:00:00+00:00']
    

    Exercício 1

    Pedidos por dia da semana

    Escreva pedidos_por_dia_da_semana(tabela), que devolva uma Series com o número de pedidos de cada dia da semana (índice de 0 a 6), e confira que a soma é igual ao total de pedidos.