Amora Data Build Tool
Para entendermos o problema que o Amora se propõe a resolver, precisamos primeiro entender o contexto de trabalho onde ele surgiu. Trabalho na Pagar.me, uma empresa da Stone Co., no contexto de prevenção a fraudes de transações financeiras por meios eletrônicos, em um time de dados composto por Analistas de Dados, Cientistas de Dados e Engenheiros e Engenheiras de Software.
No time de dados, lidamos com a extração, carregamento, transformação, organização e catalogação de dados, para geração de insumos para treino de modelos de machine learning de prevenção a fraude, inteligência de negócios e analytics.
Do ponto de vista de stack de tecnologias, usamos Google Cloud Platform como provedor de plataforma de computação de nuvem e Google Big Query como Data Warehouse, onde armazenamos dados de pagamento e entrega, dados pessoais de pessoas físicas e jurídicas, resultados de análises de antifraude, fluxos de transações, operações de estorno, chargebacks, etc.
Uma vez no Data Warehouse, do ponto de vista de um membro do time, o processo de extrair valor dos dados no Big Query tende a se resumir a criar 1-N consultas SQL de transformação e agregação desses dados para criação de tabelase views. No começo, views foram criadas e alteradas diretamente através do console do Big Query. Com o passar do tempo, os problemas dessa abordagem em um time, se tornaram óbvios:
- Controle de versão: é comum que mais de uma pessoa trabalhe sobre um modelo de dados e que esse modelo sofra mudanças ao longo do tempo. Modelos de dados exigem um trabalho de desenvolvimento de código. Código não versionado tende a gerar os mesmos problemas, independente do contexto.
- Garantias de qualidade: Garbage in, Garbage out. Dados ruins geram modelos de dados ruins, que podem resultar em predições incorretas ou numa visão distorcida do estado atual negócio. Não tínhamos garantia de qualidade através de testes automatizados.
- Documentação: software não documentado gera barreiras de adoção, aumentando a carga cognitiva, fazendo com que novos usuários e desenvolvedores tenham que reinterpretar o código exposto de acordo com os seus conhecimentos e vieses. Quando estamos falando de modelos de dados, queremos descrições completas, explícitas e sem ambiguidades do dataset e suas colunas.
- Modularização: sem o ferramental adequado para reuso de código, é inevitável que copy/paste ocorra. Sem reuso, a adoção de correções e atualização de definições se torna problemática e custosa.
Ex.: Digamos que os modelos de dados
A
,B
eC
fazem uso do CPF do comprador. Esse dado, por sua vez, não está no formato desejado, podendo conter separadores estéticos (ex.:453.178.287-91
). Para resolver esse problema, ao criar o modeloA
, João criou uma expressão regular para transformar453.178.287-91 → 45317828791
. O modeloB
foi desenvolvido por Joana, que teve contato com o modeloA
e copiou a implementação de João. O modeloC
, feito por Maria, que não teve contato com nenhum dos outros modelos, desenvolveu uma nova implementação para resolver o mesmo problema. Com o passar do tempo, João descobriu um problema na sua implementação no modeloA
e a corrigiu. Nesse cenário, a correção não é replicada para o modeloB
e também não podemos saber se o mesmo problema afetou a solução utilizada emC
.
O tempo de desenvolvimento de mudanças e implementação de novas features está diretamente ligado com a manutenibilidade do software em questão. Isso não é diferente no processo de análise de dados. Os problemas a serem resolvidos são os mesmos, mas em um contexto diferente, que ainda carece do ferramental adequado.
Sobre os Ombros de Gigantes
Para endereçar esses problemas, testamos e adotamos o DBT. Passamos então a escrever modelos de dados e suas respectivas transformações utilizando uma combinação de SQL com macros Jinja. Isso nos possibilitou reuso de código, uso de estruturas de controle, iteração, configuração externalizada, múltiplos ambientes e um ciclo de integração contínua com testes de dados e garantias de qualidade que não tínhamos antes. Com um processo estável de contribuição e validação de qualidade, passamos a ter centenas de modelos de dados com boa cobertura de testes e documentação web acessível a todos.
Com modelos cada vez mais complexos, ao longo do ciclo de desenvolvimento, erros se tornam mais frequentes e para maioria deles, dependemos de tentativa de execução no BigQuery para percebê-los. Entendemos que o ambiente de desenvolvimento e ciclo de feedback ainda estava longe do ideal. Havia espaço para melhora.
Amora
Após estressarmos o uso do DBT, formamos uma opinião sobre o que gostamos e o que poderia ser melhor. Como na maioria dos times de dados, usamos Python, linguagem de programação que se tornou o padrão da indústria em projetos de dados e machine learning. Estamos acostumados com a ótima ergonomia de desenvolvimento que a linguagem nos oferece e gostaríamos de ter as mesmas facilidades na manipulação de grandes volumes de dados. A solução que combina SQL com Jinja2 tem um ambiente de desenvolvimento deficiente:
- Falta de link entre o schema de dados e o ambiente de desenvolvimento. Sem isso, a IDE não consegue oferecer autocomplete de código.
- Erros sintáticos não são detectados no ambiente de desenvolvimento, nem no processo de compilação.
Amora nasceu da ideia de criar uma prova de conceito capaz de utilizar Python para criar um ambiente ergonomicamente mais confortável para analistas e engenheiros de dados descreverem modelos de dados e suas respectivas transformações. Com o Amora, descrevemos o esquema dos dados através de anotações de tipos (PEP484) e transformações de dados através de SELECTs com https://github.com/sqlalchemy/sqlalchemy. Amora então é capaz de transformar código Python em Jobs de transformação de dados que rodam no Data Warehouse.
Preparando ambiente
Para instalar o amora em um novo projeto, sigas as instruções da documentação oficial: https://mundipagg.github.io/amora-data-build-tool/#installation
AmoraModel
💡 Caso prefira, os modelos/exemplos abaixo estão disponíveis no repositório oficial do amora no GitHub: https://github.com/mundipagg/amora-data-build-tool/tree/main/examples/amora_project
Um modelo de dados Amora é definido como uma subclasse de amora.models.AmoraModel
. Uma forma de expressar o esquema de dados, o modo de materialização e, opcionalmente, uma declaração de transformação.
Para esse exemplo, usaremos dados exportados do app Apple Health, que concentra dezenas de tipos de dados, dos quais usaremos apenas os dados de frequência cardíaca. Pra isso, vamos criar os modelos Health
e HeartReate
.
from datetime import datetimefrom amora.types import Compilable
from amora.models import (
AmoraModel,
ModelConfig,
PartitionConfig,
MaterializationTypes,
)
from examples.amora_project.models.health import Health
from sqlmodel import Field, selectclass HeartRate(AmoraModel, table=True):
__tablename__ = "heart_rate"
__depends_on__ = [Health]
__model_config__ = ModelConfig(
description="Dados de batimento cardíaco coletados pelo Apple health",
materialized=MaterializationTypes.table,
partition_by=PartitionConfig(
field="creationDate", data_type="TIMESTAMP", granularity="day"
),
cluster_by=["sourceName"],
labels={"freshness": "daily"},
) creationDate: datetime
device: str
endDate: datetime
id: int = Field(primary_key=True)
sourceName: str
startDate: datetime
unit: str
value: float @classmethod
def source(cls) -> Compilable:
return select(
[
Health.creationDate,
Health.device,
Health.endDate,
Health.id,
Health.sourceName,
Health.startDate,
Health.unit,
Health.value,
]
).where(Health.type == "HeartRate")
No exemplo acima, podemos observar as partes descritas anteriormente:
Esquema de dados
creationDate: datetime = Field(description="Data de inserção dos dados")
device: str = Field(description="Dispositivo de origem dos dados")
endDate: datetime = Field(description="Data do fim da medida")
id: int = Field(primary_key=True, description="Id único da medida")
sourceName: str = Field(description="Origem dos dados")
startDate: datetime = Field(description="Data do início da medida")
unit: str = Field(description="Unidade de medida")
value: float = Field(description="Valor observado")
Configuração de materialização
__model_config__ = ModelConfig(
description="Dados de batimento cardíaco coletados pelo Apple health",
materialized=MaterializationTypes.table,
partition_by=PartitionConfig(
field="creationDate", data_type="TIMESTAMP", granularity="day"
),
cluster_by=["sourceName"],
labels={"freshness": "daily"},
)
Declaração de transformação
@classmethod
def source(cls) -> Compilable:
return select(
[
Health.creationDate,
Health.device,
Health.endDate,
Health.id,
Health.sourceName,
Health.startDate,
Health.unit,
Health.value,
]
).where(Health.type == "HeartRate")
Esse código é suficiente para que o Amora entenda que:
- O modelo
HeartRate
deve ser criado como uma tabela particionada no BigQuery - A coluna
creationDate
deve ser usada como campo de particionamento - Os dados devem ser agrupados através da coluna
sourceName
- A declaração de transformação necessária para gerar a tabela
- O esquema final dos dados gerados pela transformação
- Os modelo de dados necessário para a transformação de dados,
Health
A partir dessa definição, podemos usar a CLI do amora para transformar código Python em uma tabela no BigQuery.
CLI
Ao instalar o amora como dependência, o comando amora
é disponibilizado no seu ambiente. Para uma lista completa dos comandos, com documentação, basta executar amora —help
.
amora compile
Para compilar o modelo que acabamos de escrever em código SQL, vamos usar o comando compile
amora materialize
Para criar a tabela no Data Warehouse, vamos usar o comando materialize
. Se quiser uma visualização da ordem que a materialização ocorrerá, basta passar a flag —draw-dag
. Como o amora é capaz de entender a dependência entre os modelos, os modelos são criados na ordem correta. Também significa que no futuro, podemos paralelizar a execução e tornar todo o processo mais veloz.
amora test
Agora que já temos os dados no Data Warehouse, precisamos nos certificar de que temos dados de qualidade. Vamos aos testes!
No amora, testes também são escritos como código Python, onde cada teste é uma afirmação sobre uma determinada característica dos dados. Sobre os dados de batimentos cardíacos, as seguintes afirmações devem ser verdadeiras:
- O valor observado por uma coleta de frequência cardíaca não pode ser negativo.
- Como
HeartRate
é derivado deHealth
, as linhas deHeartRate
devem estar contidas emHealth
- A unidade de medida deve ser
count/min
- A coluna
endDate
deve ser uma data futura astartDate
from amora.tests.assertions import (
relationship,
that,
is_non_negative,
has_accepted_values,
expression_is_true,
)
from examples.amora_project.models.health import Health
from examples.amora_project.models.heart_rate import HeartRatedef test_value_is_non_negative():
assert that(HeartRate.value, is_non_negative)def test_relates_to_health():
assert relationship(from_=HeartRate.id, to=Health.id)def test_unit_accepted_values():
assert that(HeartRate.unit, has_accepted_values, values=["count/min"])def test_end_date_after_start_date():
assert expression_is_true(HeartRate.endDate >= HeartRate.startDate)
Uma asserção de dados é uma query que busca por problemas no dataset. Por exemplo, a afirmação is_non_negative
vai buscar por valores em que HeartRate.value
seja < 0
.
Se nenhuma linha for retornada, nenhum erro foi encontrado. Caso contrário, o retorno são valores que invalidam a afirmativa. Testes de dados são realizados dessa forma pois costuma ser mais fácil buscar por problemas do que pela ausência deles e, em caso de erros, é mais útil ter em mãos exemplos de valores que invalidam a afirmativa.
Existe um custo financeiro atrelado ao escaneamento dos dados no Big Query, e ao final dos testes, Amora disponibiliza um relatório de estimativa de custos.
Como testes Amora são testes Python construídos utilizando pytest, você pode executar os testes como você já está habituado a executar qualquer outro teste. Seu debugger e todas as facilidades que você tem no seu ferramental de testes podem ser usadas. Se preferir, basta rodar os testes usando a CLI amora test
, que a execução dos testes será paralelizada em múltiplos processos, usando pytest-xdist.
Espero ter conseguido deixar um gosto do que é possível construir! Se quiser saber mais, acompanhar e colaborar com o projeto, não deixe de dar uma estrela no GitHub: https://github.com/mundipagg/amora-data-build-tool