Engenharia de Software para Cientistas de Dados — Pt. 0, Interfaces
Um guia para não-especialistas
Tem um ditado, famoso nas rodas da malandragem, que diz o seguinte:
Programe para uma interface, e nunca para implementações.
Muito simples, e envolve muita coisa. Posso dizer com segurança que as melhores práticas de produção de código atualmente - seguidas por empresas como Facebook, Google, Twitter - se baseiam nisso.
Talvez você pense:
“O que é uma interface? Qual a diferença de interface para implementação?”
“Como programo para uma interface?”
Para os menos habituados na linguagem de Engenharia de Software, imagino que essas devem ser algumas das suas dúvidas. Por isso, nesse artigo, vou tentar facilitar para vocês, meus confrades Analistas/Cientistas de Dados, e outros interessados que estiverem lendo, as noções básicas e o valor das interfaces.
Sobre programar pensando na “implementação”
Quando estamos programando para uma análise, ou na construção de um dashboard por demanda, normalmente pensamos no objetivo e como chegar a ele.
O objetivo é X e temos N meios de chegar até lá.
Daí, escolhemos um caminho que pareça mais simples, testamos o código em um ambiente de testes e lançamos para produção depois de validarmos sua corretude.
Mas esse mindset tem alguns problemas. Quando o processo é feito dessa forma, estamos programando orientados à “implementação”.
E qual o problema disso?
A programação orientada à implementação tem “memória curta” e - numa perspectiva de equipe - é confusa, porque por esse prisma, pensamos somente no código e no problema isolados, e não como parte de um sistema/serviço, no nosso caso de Dados.
Imagine um time de 30 Analistas e Cientistas de Dados. Alguns deles estão trabalhando em projetos em comum, outros distintos, outros estão fazendo programação em par. Ou seja, temos os mais variados tipos de fluxo de desenvolvimento em time.
Vamos supor que estão todos programando pensando na implementação.
Manipulações de dados com R e Python, modelos utilizando sci-kit learn, importações e exportações de dados de várias fontes distintas. No geral, mesmo que todos usem muitas ferramentas em comum, o fluxo de trabalho individual é bem heterogêneo dependendo da atividade.
Talvez você já tenha notado o problema. No fim dos projetos, se compararmos os scripts desenvolvidos por todos os 30, provavelmente veremos muita replicação de código ( a tal memória curta ), e várias formas diferentes de organizar os scripts. Isso porque - individualmente - cada analista ou cientista, pensa a solução do problema de uma forma específica pra ele mesmo.
Das consequências de um código orientado à implementação, temos:
- Código confuso, feito sem pensar num padrão para o time, feito às pressas por conta de uma atividade por demanda;
- Dificuldade para outra pessoa debugar um erro. Digamos que, na modelagem de alguns dados, uma coluna não foi tratada. Nisso, o erro só apareceu depois que um caso “outlier” surgiu em produção, talvez tenha dado erro de conexão com uma API, ou algum bug nas dependências do código de outro time, o que implica em buscar pelo erro, que pode não ser intuitivo, e o retrabalho de novamente alterar um script que deveria estar pronto;
- Ausência de testes para validar o código e os dados que passam por ele, antes de ir para produção.
Resumindo, quando o código é complexo e despadronizado, não só é difícil de mapear aonde está o erro, como se torna um atrito para todo o processo de desenvolvimento atual e futuro do time.
Dado o contexto, vamos para outra perspectiva mais otimista, um projeto de Machine Learning.
Melhorando com o que já sabemos e fazemos
A maior parte dos Cientistas de Dados conhecem a convenção de produção de um modelo, que segue o seguinte fluxo:
Nesse modelo, descrevemos um processo e uma estrutura de projeto de forma generalizada, que permite a sua reprodutibilidade, facilidade em encontrar e corrigir o erros em alto nível, mesmo com muitos integrantes no projeto.
No fundo, essa imagem descreve exatamente o que é uma interface na Computação.
Olhando a literatura de Engenharia de Software, muitos definem uma interface como um contrato, que para os desavisados pode ser incômodo.
“Mas o que tem a ver Direito com Computação? É sobre LGPD isso?”
Não exatamente, contrato vem do latim [Con]-[tractus], que significa um acordo conjunto, uma ou mais partes em acordo com as outras.
Em outras palavras, uma convenção.
Quando definimos dentro de nossas empresas informalmente:
“Vamos seguir o modelo GitFlow de deployment.”
“Nossos jobs de ETL ficam nessa pasta, e todos têm um arquivo cron e um main de execução que chama as dependências do job.”
“Vamos todos botar um header nos nossos scripts para definir quem vai cuidar dele quando der problema, e mapear a qual time ele atende”
Estamos sempre estabelecendo contratos, convenções, pra que possamos facilitar a localização de erros, escalar melhor nossa produtividade e falar numa mesma linguagem com colegas do mesmo time.
Pela perspectiva da Engenharia de Software, estamos definindo “interfaces” para o nosso trabalho.
Programar pensando no Todo
No contexto de Software, normalmente penso as interfaces em duas dimensões:
- Arquitetura de Código Orientada à Interface;
- Programação Orientada à Interface;
Quando falo em “Programação Orientada a Interface”, me refiro à produção de código seguindo convenções bem definidas, como o conhecido Clean Code do Tio Roberto, ou as convenções de PEP8 em Python.
Assim a legibilidade, interpretabilidade do código é facilitada, e por consequência, o debug.
Se até aqui fez sentido para você, então não vai ser difícil entender o próximo passo.
Quando estamos falando de uma Arquitetura de Código Orientada a Interfaces, ou Arquitetura baseada em Interface, falamos em princípios SOLID, que são a base de uma Arquitetura Limpa, que o Tio Roberto também nos agraciou.
Isso significa que vamos fazer códigos que definem convenções, restringindo a ação e estrutura de outros códigos, para que no fim, todos os módulos de um mesmo tipo respeitem a mesma convenção.
Ao debugar um código que segue os princípios de uma Arquitetura Limpa, mesmo que você use pouco o módulo, se conhecer as suas convenções/interfaces, vai ser fácil se encontrar.
À exemplo, sabemos da inconveniência de um código “hard-coded”, fora das convenções de Clean Code, e também sabemos ( ou pelo menos sentimos ) como uma arquitetura “hard-coded” dificulta nossa vida.
Casos de uma arquitetura em desenvolvimento, ainda “hard-coded” seriam:
Caso 1: Alguns Cientistas vão precisar começar a puxar dados do Firebase Analytics pela API de consulta BigQuery do Google. Como não tinham uma infraestrutura preparada pra isso, terão que mudar o código-fonte de um arquivo que possui todas conexões. Para isso, precisarão alterar mais que 7 arquivos na mesma branch para adaptar a mudança, cada um com dependências diferentes a serem tratadas, onde terão que investigar cada uma pra não quebrar os jobs que dependem desse script, e depois ainda re-buildar jobs inteiros novamente.
O problema é clássico de uma arquitetura altamente acoplada/monolítica.
Idealmente, mudar somente o módulo que serve os dados seria suficiente para que em tempo de execução os jobs puxassem a versão correta do código, sem precisar alterar mais arquivos.
Caso 2: No script main e dependências dele temos que lembrar de todas as funcionalidade essenciais para execução do job. Provavelmente temos um modelo de job para copiar e colar, então acabamos com 1000 linhas replicadas entre jobs, quando poderiamos ter talvez só 400, ou 500.
Um caso comum de abstração seria, partir deste trecho de código:
df = connect(aws_access_key_id=’YOUR_ACCESS_KEY_ID’,
aws_secret_access_key=’YOUR_SECRET_ACCESS_KEY’,
s3_staging_dir=’s3://YOUR_S3_BUCKET/path/to/’,
region_name=’us-west-2',
cursor_class=PandasCursor) \
.cursor() \
.execute(DADOS_QUE_QUERO)
E criar uma classe DatabaseConnector, encapsulando a conexão assim:
db_conn = DatabaseConnector("Athena") df = db_conn.query(DADOS_QUE_QUERO) \
.as_type(pd.DataFrame)
Assim conseguimos:
- Não expor as chaves de conexão explicitamente, todas definidas implicitamente, o que melhora a velocidade da prototipagem do código para nós, Cientistas de Dados;
- O código fica mais limpo, o ajuda à lembrar como implementar na mão sem precisar do CTRL+C e CTRL+V;
- E o código fica mais seguro, porque isola informações de baixo nível.
Para cada empresa, faz sentido fazer uma abstração nos seus códigos internos em relação à APIs externas. Isso acontece porque cada distribuidor de código, o qual dependemos, como sci-kit learn, pandas, MXNet, desenvolvem de acordo com as arquiteturas que querem, conforme interessa às suas comunidades.
É muito difícil que essas arquiteturas todas juntas resolvam nossos casos de uso de forma escalável e padronizada, por conta da heterogeneidade delas entre si. Por exemplo, sci-kit learn tem uma arquitetura bem diferente de Tensorflow.
Por isso, para cada sistema com seus casos de uso específicos, faz sentido a gente construir uma abstração sobre essas dependências.
Caso 3: A dificuldade para desenvolver testes em código de Ciência de Dados. Com a arquitetura “hard-coded”, sem coesão e abstração suficiente, a perspectiva de teste é cansativa. Os Cientistas devem imaginar que vão ter que fazer testes para todo job, toda função, classe, sem saber o quanto é muito ou pouco de teste. E assim a implementação de testes em produção é ignorada para dar preferência a velocidade da entrega.
Normalmente, isso só acontece pelos mesmos motivos anteriores. Há excesso de replicação, pouca abstração e módulos altamente acoplados.
Idealmente, o teste deveria ocorrer numa função, classe com boa abstração e garantir o seu uso adiante de forma segura, o que evita retrabalho, trata os erros possíveis de antemão e, a médio-longo prazo, torna a entrega mais ágil.
Outra questão importante de frisar no campo dos testes, foi que numa discussão sobre TDD, o Tio Roberto colocou TDD como pré-requisito de profissionalismo em produção de software.
Seria “rude” fazermos o deploy do código em produção sem ter passado por testes unitários, de integração ou afins. Apesar de doer na gente, acho que é verdade. O valor entregado a empresa e aos usuários depende da qualidade do código em produção, que tem sua corretude validada por testes. É uma parte do nosso trabalho que entrega muito valor à empresa.
Bom! Por todos esse motivos, acho possível deduzir que grande parte dos atritos iniciais no desenvolvimento de código para uma equipe de Dados venham de uma arquitetura de código imatura.
Pessoalmente, acho muito importante que hajam Cientistas de Dados engajados em discutir e se inteirar deste tema. É uma discussão muito importante do ponto de vista da maturidade da nossa atuação no mercado internacional e para aumentar a confiança nos dados, ampliando a cultura Data-Driven pelo mercado.
Por isso, nesta série, vou tentar introduzir de forma contextualizada à nossa prática o sentido e valor de replanejar a arquitetura de código e módulos de uma área de Ciência de Dados, e como fazê-lo baseado na nossa experiência no Passei Direto.
Caso tenham dúvidas podem me mandar um e-mail [victor.souza@passeidireto.com] ou perguntar aqui mesmo no post :)
No mais, agradeço aos interessados, que motivaram o artigo, e vamos aos estudos!