Você está na página 1de 175

Machine Translated by Google

Figura 7-14. A guia Armazenamento mostra detalhes sobre o uso de memória

Indo um pouco mais adiante clicando no link “Tabela na memória `UsersTbl`” na Figura 7-14 mostra como a tabela
é armazenada em cache na memória e no disco em 1 executor e 8 partições - esse número corresponde ao
número de buckets que criado para esta tabela (veja a Figura 7-15).

Figura 7-15. IU do Spark mostrando a distribuição da tabela em cache na memória do executor

Inspecionando a IU do Spark | 201


Machine Translated by Google

SQL

Os efeitos das consultas SQL do Spark executadas como parte do seu aplicativo Spark são rastreáveis e visíveis
por meio da guia SQL. Você pode ver quando as consultas foram executadas e por quais jobs e sua duração.
Por exemplo, em nosso exemplo SortMergeJoin executamos algumas consultas; todos eles são exibidos na
Figura 7-16, com links para detalhamento.

Figura 7-16. A guia SQL mostra detalhes sobre as consultas SQL concluídas

Clicar na descrição de uma consulta exibe detalhes do plano de execução com todos os operadores físicos,
conforme mostrado na Figura 7-17. Em cada operador físico do plano – aqui, Scan In-memory table,
HashAggregate e Exchange – estão métricas SQL.

Essas métricas são úteis quando queremos inspecionar os detalhes de um operador físico e descobrir o que
aconteceu: quantas linhas foram verificadas, quantos bytes aleatórios foram gravados, etc.

202 | Capítulo 7: Otimizando e ajustando aplicativos Spark


Machine Translated by Google

Figura 7-17. IU do Spark mostrando estatísticas detalhadas em uma consulta SQL

Ambiente
A aba Ambiente, mostrada na Figura 7-18, é tão importante quanto as outras. Conhecer o ambiente no qual seu
aplicativo Spark está sendo executado revela muitas pistas que são úteis para solução de problemas. Na
verdade, é imperativo saber quais variáveis de ambiente estão definidas, quais jars estão incluídos, quais
propriedades do Spark estão definidas (e seus respectivos valores, especialmente se você ajustou algumas das
configurações mencionadas em “Otimizando e ajustando o Spark para eficiência” em página 173), quais
propriedades do sistema estão definidas, qual ambiente de tempo de execução (como JVM ou versão Java) é
usado, etc. Todos esses detalhes somente leitura são uma mina de ouro de informações que complementam
seus esforços de investigação caso você perceba algum comportamento anormal em seu aplicativo Spark.

Inspecionando a IU do Spark | 203


Machine Translated by Google

Figura 7-18. A guia Ambiente mostra as propriedades de tempo de execução do seu cluster Spark

Depurando aplicativos Spark

Nesta seção, navegamos pelas várias guias na UI do Spark. Como você viu, a UI fornece muitas
informações que você pode usar para depurar e solucionar problemas com seus aplicativos Spark.
Além do que abordamos aqui, ele também fornece acesso aos logs stdout/stderr do driver e do
executor, onde você pode ter registrado informações de depuração.

Depurar por meio da UI é um processo diferente de percorrer um aplicativo em seu IDE favorito – mais
como uma investigação, seguindo trilhas de migalhas de pão – embora

204 | Capítulo 7: Otimizando e ajustando aplicativos Spark


Machine Translated by Google

se preferir essa abordagem, você também pode depurar um aplicativo Spark em um IDE como o IntelliJ
IDEA em um host local.

As guias da interface do usuário do Spark 3.0 revelam informações detalhadas sobre o que aconteceu,
juntamente com acesso aos logs stdout/stderr do driver e do executor, onde você pode ter registrado
informações de depuração.

Inicialmente, esta infinidade de informações pode ser esmagadora para um novato. Mas com o tempo
você entenderá o que procurar em cada guia e começará a detectar e diagnosticar anomalias mais
rapidamente. Os padrões ficarão claros e, ao visitar frequentemente essas guias e se familiarizar com
eles depois de executar alguns exemplos do Spark, você se acostumará a ajustar e inspecionar seus
aplicativos Spark por meio da UI.

Resumo
Neste capítulo, discutimos diversas técnicas de otimização para ajustar seus aplicativos Spark. Como
você viu, ajustando algumas das configurações padrão do Spark, você pode melhorar o escalonamento
para grandes cargas de trabalho, aprimorar o paralelismo e minimizar a falta de memória entre os
executores do Spark. Você também teve uma ideia de como usar estratégias de cache e persistência
com níveis apropriados para agilizar o acesso aos seus conjuntos de dados usados com frequência, e
examinamos duas junções comumente usadas que o Spark emprega durante agregações complexas e
demonstramos como agrupar quadros de dados por chaves classificadas. , você pode pular operações
caras de embaralhamento.

Finalmente, para obter uma perspectiva visual do desempenho, a UI do Spark completou o quadro. Por
mais informativa e detalhada que seja a interface do usuário, ela não é equivalente à depuração passo
a passo em um IDE; ainda assim, mostramos como você pode se tornar um detetive do Spark
examinando e coletando insights de métricas e estatísticas, dados de computação e uso de memória e
rastreamentos de execução de consulta SQL disponíveis em meia dúzia de guias da UI do Spark.

No próximo capítulo, nos aprofundaremos no streaming estruturado e mostraremos como as APIs


estruturadas que você aprendeu nos capítulos anteriores permitem que você escreva aplicativos de
streaming e em lote de maneira contínua, permitindo a criação de dados confiáveis. lagos e oleodutos.

Resumo | 205
Machine Translated by Google
Machine Translated by Google

CAPÍTULO 8

Streaming Estruturado

Nos capítulos anteriores, você aprendeu como usar APIs estruturadas para processar volumes de
dados muito grandes, mas finitos. No entanto, muitas vezes os dados chegam continuamente e
precisam ser processados em tempo real. Neste capítulo, discutiremos como as mesmas APIs
estruturadas também podem ser usadas para processar fluxos de dados.

Evolução do mecanismo de processamento Apache Spark Stream

O processamento de fluxo é definido como o processamento contínuo de fluxos infinitos de dados.


Com o advento do big data, os sistemas de processamento de fluxo passaram de mecanismos de
processamento de nó único para mecanismos de processamento distribuído de vários nós.
Tradicionalmente, o processamento de fluxo distribuído tem sido implementado com um modelo de
processamento de registro por vez, conforme ilustrado na Figura 8-1.

Figura 8-1. Modelo tradicional de processamento de registro por vez

207
Machine Translated by Google

O pipeline de processamento é composto por um grafo direcionado de nós, conforme mostrado na Figura
8-1; cada nó recebe continuamente um registro por vez, processa-o e então encaminha o(s) registro(s)
gerado(s) para o próximo nó no gráfico. Esse modelo de processamento pode atingir latências muito baixas
– ou seja, um registro de entrada pode ser processado pelo pipeline e a saída resultante pode ser gerada
em milissegundos. No entanto, este modelo não é muito eficiente na recuperação de falhas de nós e nós
retardatários (isto é, nós que são mais lentos que outros); ele pode se recuperar de uma falha muito
rapidamente com muitos recursos extras de failover ou usar recursos extras mínimos, mas se recuperar
lentamente.1

O advento do processamento de fluxo em microlote Essa

abordagem tradicional foi desafiada pelo Apache Spark quando introduziu o Spark Streaming (também
chamado de DStreams). Ele introduziu a ideia de processamento de fluxo em microlotes, onde a
computação de streaming é modelada como uma série contínua de pequenos trabalhos de processamento
em lote no estilo mapear/reduzir (daí, “microlotes”) em pequenos pedaços dos dados do fluxo. . Isso é
ilustrado na Figura 8-2.

Figura 8-2. O streaming estruturado usa um modelo de processamento em microlote

Conforme mostrado aqui, o Spark Streaming divide os dados do fluxo de entrada em, digamos, microlotes
de 1 segundo. Cada lote é processado no cluster Spark de forma distribuída com pequenas tarefas
determinísticas que geram a saída em microlotes.
Dividir a computação de streaming nessas pequenas tarefas nos dá duas vantagens sobre o modelo
tradicional de operador contínuo:

1 Para uma explicação mais detalhada, consulte o artigo de pesquisa original “Discretized Streams: Fault-Tolerant Streamÿ
ing Computation at Scale” por Matei Zaharia et al. (2013).

208 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

• O agendamento ágil de tarefas do Spark pode se recuperar de forma muito rápida e eficiente de
falhas e executores retardatários, reprogramando uma ou mais cópias das tarefas em qualquer um
dos outros executores.

• A natureza determinística das tarefas garante que os dados de saída sejam os mesmos, não importa
quantas vezes a tarefa seja reexecutada. Essa característica crucial permite que o Spark Streaming
forneça garantias de processamento ponta a ponta exatamente uma vez, ou seja, os resultados de
saída gerados serão tais que cada registro de entrada foi processado exatamente uma vez.

Essa tolerância eficiente a falhas tem o custo da latência: o modelo de microlote não consegue atingir
latências no nível de milissegundos; geralmente atinge latências de alguns segundos (até meio segundo
em alguns casos). No entanto, observamos que, para a esmagadora maioria dos casos de uso de
processamento de fluxo, os benefícios do processamento em microlote superam a desvantagem das
latências de segunda escala. Isso ocorre porque a maioria dos pipelines de streaming possui pelo menos
uma das seguintes características:

• O pipeline não precisa de latências inferiores a alguns segundos. Por exemplo, quando a saída de
streaming só será lida por trabalhos de hora em hora, não é útil gerar saída com latências de
subsegundos.

• Existem atrasos maiores noutras partes do gasoduto. Por exemplo, se as gravações de um sensor
no Apache Kafka (um sistema para ingestão de fluxos de dados) forem agrupadas em lote para
obter maior rendimento, nenhuma quantidade de otimização nos sistemas de processamento
downstream poderá tornar a latência de ponta a ponta menor do que a do processamento em lote.
atrasos.

Além disso, a API DStream foi construída com base na API RDD em lote do Spark. Portanto, os DStreams
tinham a mesma semântica funcional e modelo de tolerância a falhas que os RDDs.
Assim, o Spark Streaming provou que é possível que um mecanismo de processamento único e unificado
forneça APIs e semântica consistentes para cargas de trabalho em lote, interativas e de streaming. Essa
mudança fundamental de paradigma no processamento de stream impulsionou o Spark Streaming a se
tornar um dos mecanismos de processamento de stream de código aberto mais amplamente usados.

Lições aprendidas com Spark Streaming (DStreams)


Apesar de todas as vantagens, a API DStream tinha suas falhas. Aqui estão algumas áreas principais
para melhoria que foram identificadas:

Evolução do mecanismo de processamento de fluxo Apache Spark | 209


Machine Translated by Google

Falta de uma API única para processamento em lote e fluxo Embora


DStreams e RDDs tenham APIs consistentes (ou seja, mesmas operações e mesma semântica), os
desenvolvedores ainda tiveram que reescrever explicitamente seu código para usar classes diferentes ao
converter seus trabalhos em lote em trabalhos de streaming.

Falta de separação entre os planos lógico e físico O Spark Streaming


executa as operações DStream na mesma sequência em que foram especificadas pelo desenvolvedor. Como
os desenvolvedores especificam efetivamente o plano físico exato, não há espaço para otimizações
automáticas e os desenvolvedores precisam otimizar manualmente seu código para obter o melhor
desempenho.

Falta de suporte nativo para janelas de tempo de evento Os


DStreams definem as operações da janela com base apenas no horário em que cada registro é recebido pelo
Spark Streaming (conhecido como tempo de processamento). No entanto, muitos casos de uso precisam
calcular agregados em janelas com base no horário em que os registros foram gerados (conhecido como
horário do evento) em vez de quando foram recebidos ou processados. A falta de suporte nativo para janelas
de tempo de evento tornou difícil para os desenvolvedores construir tais pipelines com Spark Streaming.

Essas desvantagens moldaram a filosofia de design do Streaming Estruturado, que discutiremos a seguir.

A Filosofia do Streaming Estruturado Com base nessas lições do

DStreams, o Streaming Estruturado foi projetado do zero com uma filosofia central: para desenvolvedores, escrever
pipelines de processamento de stream deve ser tão fácil quanto escrever pipelines em lote. Resumindo, os
princípios orientadores do Streaming Estruturado são:

Um modelo de programação e interface únicos e unificados para processamento em lote e fluxo


Este modelo unificado oferece uma interface API simples para cargas de trabalho em lote e streaming. Você
pode usar SQL familiar ou consultas DataFrame semelhantes a lote (como aquelas que você aprendeu nos
capítulos anteriores) em seu fluxo como faria em um lote, deixando de lidar com as complexidades subjacentes
de tolerância a falhas, otimizações e atrasos. dados para o motor. Nas próximas seções, examinaremos
algumas das perguntas que você pode escrever.

Uma definição mais ampla de processamento de fluxo


Os aplicativos de processamento de big data tornaram-se complexos o suficiente para que a linha entre o
processamento em tempo real e o processamento em lote tenha se confundido significativamente. O objetivo
do Structured Streaming era ampliar sua aplicabilidade do processamento tradicional de stream para uma
classe maior de aplicações; qualquer aplicativo que processe dados periodicamente (por exemplo, a cada
poucas horas) ou continuamente (como aplicativos de streaming tradicionais) deve ser expressável usando
Streaming Estruturado.

210 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

A seguir, discutiremos o modelo de programação usado pelo Streaming Estruturado.

O modelo de programação de streaming estruturado


“Tabela” é um conceito bem conhecido com o qual os desenvolvedores estão familiarizados ao criar
aplicativos em lote. O Streaming Estruturado estende esse conceito para aplicativos de streaming
tratando um stream como uma tabela ilimitada e continuamente anexada, conforme ilustrado na Figura
8-3.

Figura 8-3. O modelo de programação de Streaming Estruturado: fluxo de dados como uma tabela
ilimitada

Cada novo registro recebido no fluxo de dados é como uma nova linha anexada à tabela de entrada
ilimitada. Na verdade, o streaming estruturado não reterá todas as entradas, mas a saída produzida pelo
streaming estruturado até o momento T será equivalente a ter todas as entradas até T em uma tabela
estática e limitada e executar um trabalho em lote na tabela.

Conforme mostrado na Figura 8.4, o desenvolvedor define então uma consulta nessa tabela de entrada
conceitual, como se fosse uma tabela estática, para calcular a tabela de resultados que será gravada em
um coletor de saída. O streaming estruturado converterá automaticamente essa consulta em lote em um
plano de execução de streaming. Isso é chamado de incrementalização: o Streaming Estruturado
descobre qual estado precisa ser mantido para atualizar o resultado cada vez que um registro chega.
Finalmente, os desenvolvedores especificam políticas de acionamento para controlar quando atualizar
os resultados. Cada vez que um gatilho é acionado, o Streaming Estruturado verifica novos dados (ou
seja, uma nova linha na tabela de entrada) e atualiza o resultado de forma incremental.

O modelo de programação de streaming estruturado | 211


Machine Translated by Google

Figura 8-4. O modelo de processamento de streaming estruturado

A última parte do modelo é o modo de saída. Cada vez que a tabela de resultados for atualizada, o
desenvolvedor desejará gravar as atualizações em um sistema externo, como um sistema de arquivos (por
exemplo, HDFS, Amazon S3) ou um banco de dados (por exemplo, MySQL, Cassandra). Geralmente
queremos escrever a saída de forma incremental. Para isso, o Structured Streaming oferece três modos de
saída:

Modo anexar
Somente as novas linhas anexadas à tabela de resultados desde o último gatilho serão gravadas no
armazenamento externo. Isto é aplicável somente em consultas onde as linhas existentes na tabela
de resultados não podem ser alteradas (por exemplo, um mapa em um fluxo de entrada).

Modo de
atualização Somente as linhas que foram atualizadas na tabela de resultados desde o último gatilho
serão alteradas no armazenamento externo. Este modo funciona para coletores de saída que podem
ser atualizados no local, como uma tabela MySQL.

Modo completo
Toda a tabela de resultados atualizada será gravada no armazenamento externo.

212 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

A menos que o modo completo seja especificado, a tabela de resultados não será
totalmente materializada pelo Streaming Estruturado. Apenas informações suficientes
(conhecidas como “estado”) serão mantidas para garantir que as alterações na
tabela de resultados possam ser calculadas e as atualizações possam ser geradas.

Pensar nos fluxos de dados como tabelas não apenas torna mais fácil conceituar os cálculos lógicos
nos dados, mas também torna mais fácil expressá-los em código.
Como o DataFrame do Spark é uma representação programática de uma tabela, você pode usar a
API DataFrame para expressar seus cálculos em dados de streaming. Tudo o que você precisa fazer
é definir um DataFrame de entrada (ou seja, a tabela de entrada) de uma fonte de dados de streaming
e, em seguida, aplicar operações no DataFrame da mesma forma que faria em um DataFrame definido
em uma fonte em lote.

Na próxima seção, você verá como é fácil escrever consultas de streaming estruturado usando
DataFrames.

Os fundamentos de uma consulta de streaming estruturada


Nesta seção, abordaremos alguns conceitos de alto nível que você precisará entender para
desenvolver consultas de streaming estruturado. Primeiro percorreremos as principais etapas para
definir e iniciar uma consulta de streaming e, em seguida, discutiremos como monitorar a consulta
ativa e gerenciar seu ciclo de vida.

Cinco etapas para definir uma consulta de streaming

Conforme discutido na seção anterior, o streaming estruturado usa a mesma API DataFrame que
consultas em lote para expressar a lógica de processamento de dados. No entanto, existem algumas
diferenças importantes que você precisa conhecer para definir uma consulta de streaming estruturado.
Nesta seção, exploraremos as etapas envolvidas na definição de uma consulta de streaming
construindo uma consulta simples que lê fluxos de dados de texto em um soquete e conta as palavras.

Etapa 1: Definir fontes de entrada

Assim como acontece com consultas em lote, a primeira etapa é definir um DataFrame a partir de uma fonte de streaming.
No entanto, ao ler fontes de dados em lote, precisamos de spark.read para criar um DataFra
meReader, enquanto com fontes de streaming precisamos de spark.readStream para criar um
DataStreamReader. DataStreamReader possui quase os mesmos métodos do DataFrameR eader,
portanto, você pode usá-lo de maneira semelhante. Aqui está um exemplo de criação de um
DataFrame a partir de um fluxo de dados de texto a ser recebido por meio de uma conexão de soquete:

Os fundamentos de uma consulta de streaming estruturada | 213


Machine Translated by Google

# Em Python
spark = SparkSession...
linhas =

(spark .readStream.format("socket") .option("host", "localhost") .option("port", 9999) .load())

// Em Scala
val spark = SparkSession... val
lines =

spark .readStream.format("socket") .option("host", "localhost") .option("port", 9999) .load()

Este código gera as linhas DataFrame como uma tabela ilimitada de dados de texto separados por nova
linha lidos de localhost:9999. Observe que, semelhante às fontes em lote com spark.read, isso não inicia
imediatamente a leitura dos dados de streaming; ele apenas define as configurações necessárias para a
leitura dos dados quando a consulta de streaming é explicitamente iniciada.

Além dos soquetes, o Apache Spark oferece suporte nativo à leitura de fluxos de dados do Apache Kafka
e de todos os vários formatos baseados em arquivo que o DataFrameReader suporta (Parquet, ORC,
JSON, etc.). Os detalhes dessas fontes e suas opções suportadas são discutidos posteriormente neste
capítulo. Além disso, uma consulta de streaming pode definir múltiplas fontes de entrada, tanto streaming
quanto em lote, que podem ser combinadas usando operações DataFrame como uniões e junções
(também discutidas posteriormente neste capítulo).

Etapa 2: transformar dados

Agora podemos aplicar as operações usuais do DataFrame, como dividir as linhas em palavras individuais
e depois contá-las, conforme mostrado no código a seguir:

# Em Python
de pyspark.sql.functions import * words
= lines.select(split(col("value"), "\\s").alias("word")) counts =
words.groupBy("word") .contar()

// Em Scala
import org.apache.spark.sql.functions._ val
words = lines.select(split(col("value"), "\\s").as("word")) val counts =
words .groupBy("palavra").count()

counts é um DataFrame de streaming (ou seja, um DataFrame em dados de streaming ilimitados) que
representa as contagens de palavras em execução que serão computadas quando a consulta de streaming
for iniciada e os dados de entrada de streaming estiverem sendo processados continuamente.

Observe que essas operações para transformar as linhas de streaming do DataFrame funcionariam
exatamente da mesma maneira se as linhas fossem um DataFrame em lote. Em geral, a maioria dos DataFrame

214 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

operações que podem ser aplicadas em um DataFrame em lote também podem ser aplicadas em um
DataFrame de streaming. Para entender quais operações são suportadas no Streaming Estruturado, você
precisa reconhecer as duas grandes classes de transformações de dados:

Transformações sem
estado Operações como select(), filter(), map(), etc. não requerem nenhuma informação
das linhas anteriores para processar a próxima linha; cada linha pode ser processada sozinha.
A falta de “estado” prévio nestas operações torna-as apátridas. As operações sem estado podem ser
aplicadas a DataFrames em lote e streaming.

Transformações com estado


Em contraste, uma operação de agregação como count() requer a manutenção do estado para
combinar dados em múltiplas linhas. Mais especificamente, quaisquer operações do DataFrame que
envolvam agrupamento, união ou agregação são transformações com estado. Embora muitas dessas
operações sejam suportadas em Streaming Estruturado, algumas combinações delas não são
suportadas porque é computacionalmente difícil ou inviável calculá-las de maneira incremental.

As operações com estado suportadas pelo Streaming Estruturado e como gerenciar seu estado em tempo
de execução serão discutidas posteriormente neste capítulo.

Etapa 3: definir o coletor de saída e o modo

de saída Depois de transformar os dados, podemos definir como gravar os dados de saída processados
com DataFrame.writeStream (em vez de DataFrame.write, usado para dados em lote).
Isso cria um DataStreamWriter que, semelhante ao DataFrameWriter, possui métodos adicionais para
especificar o seguinte:

• Detalhes de gravação de saída (onde e como gravar a saída) • Detalhes

de processamento (como processar dados e como se recuperar de falhas)

Vamos começar com os detalhes da gravação da saída (nos concentraremos nos detalhes do
processamento na próxima etapa). Por exemplo, o trecho a seguir mostra como gravar as contagens finais
no console:

# Em Python
escritor = counts.writeStream.format("console").outputMode("complete")

// Em Scala
val escritor = counts.writeStream.format("console").outputMode("complete")

Aqui especificamos "console" como coletor de streaming de saída e "complete" como modo de saída. O
modo de saída de uma consulta de streaming especifica qual parte da saída atualizada será gravada após
o processamento de novos dados de entrada. Neste exemplo, à medida que um pedaço de novos dados
de entrada é processado e a contagem de palavras é atualizada, podemos optar por imprimir no console
a contagem de todas as palavras vistas até agora (que

Os fundamentos de uma consulta de streaming estruturada | 215


Machine Translated by Google

é, modo completo) ou apenas aquelas palavras que foram atualizadas na última parte dos dados de
entrada. Isto é decidido pelo modo de saída especificado, que pode ser um dos seguintes (como já vimos
em “O modelo de programação de streaming estruturado” na página
211:

Modo Append
Este é o modo padrão, onde apenas as novas linhas adicionadas à tabela de resultados/frame de
dados (por exemplo, a tabela de contagens ) desde o último gatilho serão enviadas para o coletor.
Semanticamente, este modo garante que qualquer linha gerada nunca será alterada ou atualizada
pela consulta no futuro. Conseqüentemente, o modo de acréscimo é suportado apenas pelas
consultas (por exemplo, consultas sem estado) que nunca modificarão os dados de saída anteriores.
Por outro lado, nossa consulta de contagem de palavras pode atualizar contagens geradas
anteriormente; portanto, ele não oferece suporte ao modo de acréscimo.

Modo completo
Neste modo, todas as linhas da tabela de resultados/DataFrame serão geradas no final de cada
gatilho. Isto é suportado por consultas onde a tabela de resultados provavelmente será muito menor
que os dados de entrada e, portanto, pode ser retida na memória. Por exemplo, nossa consulta de
contagem de palavras oferece suporte ao modo completo porque os dados de contagem
provavelmente serão muito menores que os dados de entrada.

Modo de
atualização Neste modo, apenas as linhas da tabela de resultados/DataFrame que foram atualizadas
desde o último gatilho serão geradas no final de cada gatilho. Isso contrasta com o modo de
acréscimo, pois as linhas de saída podem ser modificadas pela consulta e geradas novamente no
futuro. A maioria das consultas oferece suporte ao modo de atualização.

Detalhes completos sobre os modos de saída suportados por diferentes consultas


podem ser encontrados no Guia de programação de streaming estruturado mais
recente.

Além de gravar a saída no console, o Structured Streaming suporta nativamente gravações de streaming
em arquivos e no Apache Kafka. Além disso, você pode gravar em locais arbitrários usando os métodos
de API foreachBatch() e foreach() . Na verdade, você pode usar foreachBatch() para gravar saídas de
streaming usando fontes de dados em lote existentes (mas você perderá garantias de exatamente uma
vez). Os detalhes desses coletores e suas opções suportadas serão discutidos posteriormente neste
capítulo.

Etapa 4: Especificar detalhes de

processamento A etapa final antes de iniciar a consulta é especificar detalhes de como processar os dados.
Continuando com nosso exemplo de contagem de palavras, especificaremos os detalhes de processamento
da seguinte forma:

216 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

# Em Python
checkpointDir = "..."
escritor2 = (escritor
.trigger(processingTime="1
segundo") .option("checkpointLocation", checkpointDir))

// No Scala
importar org.apache.spark.sql.streaming._
val checkpointDir = "..." val
escritor2 = escritor
.trigger(Trigger.ProcessingTime("1
segundo")) .option("checkpointLocation", checkpointDir)

Aqui especificamos dois tipos de detalhes usando o DataStreamWriter que criamos com
DataFrame.writeStream:

Detalhes de
acionamento Indica quando acionar a descoberta e o processamento de dados de streaming
recentemente disponíveis. Existem quatro opções:

Padrão
Quando o gatilho não é especificado explicitamente, por padrão, a consulta de streaming
executa dados em microlotes, onde o próximo microlote é acionado assim que o microlote
anterior for concluído.

Tempo de processamento com intervalo de disparo

Você pode especificar explicitamente o gatilho ProcessingTime com um intervalo, e a


consulta acionará microlotes nesse intervalo fixo.

Uma vez

Nesse modo, a consulta de streaming executará exatamente um microlote – ela processa


todos os novos dados disponíveis em um único lote e depois se interrompe.
Isso é útil quando você deseja controlar o disparo e o processamento de um agendador
externo que reiniciará a consulta usando qualquer agendamento personalizado (por
exemplo, para controlar custos executando uma consulta apenas uma vez por dia ).

Contínuo
Este é um modo experimental (a partir do Spark 3.0) onde a consulta de streaming
processará dados continuamente em vez de em microlotes. Embora apenas um pequeno
subconjunto de operações do DataFrame permita que esse modo seja usado, ele pode
fornecer latência muito menor (até milissegundos) do que os modos de disparo de microlote.
Consulte o Guia de programação de streaming estruturado mais recente para obter as
informações mais atualizadas.

Os fundamentos de uma consulta de streaming estruturada | 217


Machine Translated by Google

Localização do ponto
de verificação Este é um diretório em qualquer sistema de arquivos compatível com HDFS onde uma
consulta de streaming salva suas informações de progresso – ou seja, quais dados foram
processados com sucesso. Em caso de falha, esses metadados são usados para reiniciar a consulta
com falha exatamente de onde ela parou. Portanto, definir esta opção é necessário para recuperação
de falhas com garantias exatamente uma vez.

Etapa 5: inicie a consulta


Depois que tudo estiver especificado, a etapa final é iniciar a consulta, o que você pode fazer da seguinte
maneira:

# Em Python
streamingQuery = escritor2.start()

// Em Scala
val streamingQuery = escritor2.start()

O objeto retornado do tipo streamingQuery representa uma consulta ativa e pode ser usado para gerenciar
a consulta, que abordaremos mais adiante neste capítulo.

Observe que start() é um método sem bloqueio, portanto ele retornará assim que a consulta for iniciada
em segundo plano. Se quiser que o thread principal seja bloqueado até que a consulta de streaming seja
encerrada, você pode usar streamingQuery.awaitTermination(). Se a consulta falhar em segundo plano
com um erro, awaitTermination() também falhará com a mesma exceção.

Você pode esperar até um tempo limite usando awaitTermination(timeoutMillis) e pode interromper
explicitamente a consulta com streamingQuery.stop().

Juntando tudo
Para resumir, aqui está o código completo para ler fluxos de dados de texto em um soquete, contar as
palavras e imprimir as contagens no console:

# Em Python
de pyspark.sql.functions import * spark
= SparkSession... linhas =

(spark .readStream.format("socket") .option("host", "localhost") .option("port", 9999) .carregar())

palavras = linhas.select(split(col("valor"), "\\s").alias("palavra")) contagens =


palavras.groupBy("palavra").count()
checkpointDir = "..."
streamingQuery =

(conta .writeStream .format("console")

218 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

.outputMode("complete") .trigger(processingTime="1
segundo") .option("checkpointLocation",

checkpointDir) .start()) streamingQuery.awaitTermination()

// No Scala
import org.apache.spark.sql.functions._
import org.apache.spark.sql.streaming._ val
spark = SparkSession... val
lines =

spark .readStream.format("socket") .option ("host", "localhost") .option("porta", 9999) .load()

val palavras = linhas.select(split(col("valor"), "\\s").as("palavra")) val contagens


= palavras.groupBy("palavra").count()

val checkpointDir = "..." val


streamingQuery =

counts.writeStream .format("console") .outputMode("complete") .trigger(Trigger.ProcessingTime("1 segundo")) .option("che

Após o início da consulta, um thread em segundo plano lê continuamente novos dados da


fonte de streaming, processa-os e grava-os nos coletores de streaming. A seguir, vamos dar
uma olhada rápida em como isso é executado.

Nos bastidores de uma consulta de streaming ativo

Depois que a consulta é iniciada, a sequência de etapas a seguir ocorre no mecanismo,


conforme ilustrado na Figura 8-5. As operações do DataFrame são convertidas em um plano
lógico, que é uma representação abstrata da computação que o Spark SQL usa para planejar um
consulta:

1. O Spark SQL analisa e otimiza esse plano lógico para garantir que ele possa ser executado
de forma incremental e eficiente em dados de streaming.
2. Spark SQL inicia um thread em segundo plano que executa continuamente o seguinte
ciclo:2

2 Este loop de execução é executado para modos de disparo baseados em microlotes (ou seja, ProcessingTime e Once), mas não para
o modo de disparo contínuo .

Os fundamentos de uma consulta de streaming estruturada | 219


Machine Translated by Google

a. Com base no intervalo de disparo configurado, o encadeamento verifica as fontes de streaming


quanto à disponibilidade de novos dados. b. Se

disponíveis, os novos dados são executados executando um microlote. A partir do plano lógico
otimizado, é gerado um plano de execução Spark otimizado que lê os novos dados da fonte, calcula
incrementalmente o resultado atualizado e grava a saída no coletor de acordo com o modo de saída
configurado.

c. Para cada microlote, o intervalo exato de dados processados (por exemplo, o conjunto de arquivos
ou o intervalo de compensações do Apache Kafka) e qualquer estado associado são salvos no local
do ponto de verificação configurado para que a consulta possa reprocessar deterministicamente o
intervalo exato se necessário.

3. Esse loop continua até que a consulta seja encerrada, o que pode ocorrer por um dos seguintes motivos:
a. Ocorreu uma falha

na consulta (um erro de processamento ou uma falha no


o aglomerado).

b. A consulta é interrompida explicitamente usando streamingQuery.stop(). c. Se o

gatilho estiver definido como Uma vez, a consulta será interrompida sozinha após a execução de um
único microlote contendo todos os dados disponíveis.

Figura 8-5. Execução incremental de consultas de streaming

220 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

Um ponto importante que você deve lembrar sobre o streaming estruturado é


que por baixo dele está o uso do Spark SQL para executar os dados. Como tal,
todo o poder do mecanismo de execução hiperotimizado do Spark SQL é
utilizado para maximizar o rendimento do processamento de fluxo, proporcionando
vantagens importantes de desempenho.

A seguir, discutiremos como reiniciar uma consulta de streaming após o encerramento e o ciclo de vida de uma
consulta de streaming.

Recuperando-se de falhas com garantias exatamente uma vez Para reiniciar uma

consulta encerrada em um processo completamente novo, você deve criar uma nova SparkSession, redefinir
todos os DataFrames e iniciar a consulta de streaming no resultado final usando o mesmo local de ponto de
verificação usado. quando a consulta foi iniciada pela primeira vez. Para nosso exemplo de contagem de palavras,
você pode simplesmente reexecutar todo o trecho de código mostrado anteriormente, desde a definição de spark
na primeira linha até o start() final na última linha.

O local do ponto de verificação deve ser o mesmo durante as reinicializações porque esse diretório contém a
identidade exclusiva de uma consulta de streaming e determina o ciclo de vida da consulta. Se o diretório de
checkpoint for excluído ou a mesma consulta for iniciada com um diretório de checkpoint diferente, é como iniciar
uma nova consulta do zero. Especificamente, os pontos de verificação possuem informações em nível de registro
(por exemplo, compensações do Apache Kafka) para rastrear o intervalo de dados que o último microlote
incompleto estava processando. A consulta reiniciada usará essas informações para iniciar o processamento dos
registros precisamente após o último microlote concluído com êxito. Se a consulta anterior planejou um microlote,
mas terminou antes da conclusão, a consulta reiniciada reprocessará o mesmo intervalo de dados antes de
processar novos dados. Juntamente com a execução determinística da tarefa do Spark, a saída regenerada será
a mesma que era esperada antes da reinicialização.

O Streaming Estruturado pode garantir garantias de ponta a ponta exatamente uma vez (ou seja, a saída é como
se cada registro de entrada tivesse sido processado exatamente uma vez) quando as seguintes condições forem
atendidas:

Fontes de streaming reproduzíveis


O intervalo de dados do último microlote incompleto pode ser relido a partir da fonte.

Cálculos determinísticos
Todas as transformações de dados produzem deterministicamente o mesmo resultado quando recebem os
mesmos dados de entrada.

Coletor de streaming idempotente


O coletor pode identificar microlotes reexecutados e ignorar gravações duplicadas que podem ser causadas
por reinicializações.

Os fundamentos de uma consulta de streaming estruturada | 221


Machine Translated by Google

Observe que nosso exemplo de contagem de palavras não fornece garantias de exatamente uma vez porque a
fonte do soquete não pode ser reproduzida e o coletor do console não é idempotente.

Como observação final sobre o reinício de consultas, é possível fazer pequenas modificações em uma consulta
entre reinicializações. Aqui estão algumas maneiras de modificar a consulta:

Transformações de DataFrame
Você pode fazer pequenas modificações nas transformações entre reinicializações. Por exemplo, em nosso

exemplo de contagem de palavras de streaming, se você quiser ignorar linhas que possuem sequências
de bytes corrompidas que podem travar a consulta, você pode adicionar um filtro na transformação:

# Em Python #
isCorruptedUdf = udf para detectar corrupção na string

Linhas filtradas = linhas.filter("isCorruptedUdf(valor) = falso") palavras = Linhas


filtradas.select(split(col("valor"), "\\s").alias("palavra"))

// Em Scala //
val isCorruptedUdf = udf para detectar corrupção na string

val linhas filtradas = linhas.filter("isCorruptedUdf(valor) = falso") palavras val = linhas


filtradas.select(split(col("valor"), "\\s").as("palavra"))

Ao reiniciar com essas palavras modificadas DataFrame, a consulta reiniciada aplicará o filtro em todos os
dados processados desde a reinicialização (incluindo o último microlote incompleto), evitando que a
consulta falhe novamente.

Opções de origem e coletor


Se uma opção readStream ou writeStream pode ser alterada entre reinicializações depende da semântica
da origem ou coletor específico. Por exemplo, você não deve alterar as opções de host e porta da origem
do soquete se os dados forem enviados para esse host e porta. Mas você pode adicionar uma opção ao
coletor do console para imprimir até cem contagens alteradas após cada acionamento:

writeStream.format("console").option("numRows", "100")....

Detalhes do
processamento Conforme discutido anteriormente, o local do ponto de verificação não deve ser alterado
entre as reinicializações. No entanto, outros detalhes como o intervalo de disparo podem ser alterados sem
quebrar as garantias de tolerância a falhas.

Para obter mais informações sobre o conjunto restrito de alterações permitidas entre reinicializações, consulte o
Guia de programação de streaming estruturado mais recente.

222 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

Monitorando uma consulta ativa Uma

parte importante da execução de um pipeline de streaming em produção é monitorar sua integridade. O


Streaming Estruturado oferece diversas maneiras de rastrear o status e as métricas de processamento
de uma consulta ativa.

Consultando o status atual usando StreamingQuery

Você pode consultar o funcionamento atual de uma consulta ativa usando a instância StreamingQuery.
Aqui estão dois métodos:

Obtenha métricas atuais usando StreamingQuery. Quando uma consulta processa alguns dados em um
microlote, consideramos que houve algum progresso. lastProgress() retorna informações sobre o último
microlote concluído. Por exemplo, imprimir o objeto retornado (StreamingQueryProgress em Scala/Java
ou um dicionário em Python) produzirá algo assim:

//Em Scala/Python {

"id": "ce011fdc-8762-4dcb-84eb-a77333e28109", "runId":


"88e2ff94-ede0-45a8-b687-6316fbef529a", "nome":
"MyQuery", "timestamp":
"2016-12-14T18 :45:24.873Z", "numInputRows":
10, "inputRowsPerSecond":
120,0, "processedRowsPerSecond":
200,0, "durationMs": { "triggerExecution":
3, "getOffset": 2},
"stateOperators": [], "fontes":
[ { "descrição":

"KafkaSource[Subscribe[topic-0]]",
"startOffset":
{ "topic-0": { "2": 0, "1": 1, "0": 1

} }, "endOffset":
{ "tópico-0":
{ "2": 0,
"1": 134,
"0": 534

} }, "numInputRows": 10,
"inputRowsPerSecond": 120,0,
"processedRowsPerSecond": 200,0

Os fundamentos de uma consulta de streaming estruturada | 223


Machine Translated by Google

} ],
"sink" :
{ "description" : "MemorySink"
}
}

Algumas das colunas dignas de nota são:

eu ia

Identificador exclusivo vinculado a um local de ponto de verificação. Isso permanece o mesmo durante todo o tempo
de vida de uma consulta (ou seja, durante as reinicializações).

ID de execução

Identificador exclusivo para a instância atual (re)iniciada da consulta. Isso muda a cada reinicialização.

numInputRows
Número de linhas de entrada que foram processadas no último microlote.

inputRowsPerSecond Taxa
atual na qual as linhas de entrada estão sendo geradas na origem (média durante a duração do último
microlote).

processadasRowsPerSecond
Taxa atual na qual as linhas estão sendo processadas e gravadas pelo coletor (média da duração do último
microlote). Se essa taxa for consistentemente menor que a taxa de entrada, a consulta não será capaz de
processar os dados tão rápido quanto eles estão sendo gerados pela origem. Este é um indicador chave da
saúde da consulta.

origens e coletor

Fornece detalhes específicos da origem/coletor dos dados processados no último lote.

Obtenha o status atual usando StreamingQuery.status(). Isso fornece informações sobre o que o thread de
consulta em segundo plano está fazendo neste momento. Por exemplo, imprimir o objeto retornado produzirá algo
assim:

//Em Scala/Python {

"message": "Aguardando a chegada dos dados",


"isDataAvailable": falso,
"isTriggerActive": falso }

A publicação de métricas usando o Dropwizard

Metrics Spark oferece suporte a relatórios de métricas por meio de uma biblioteca popular chamada Dropwizard Metrics.
Esta biblioteca permite que métricas sejam publicadas em muitas estruturas de monitoramento populares (Ganglia,
Graphite, etc.). Por padrão, essas métricas não estão habilitadas para consultas de streaming estruturado devido
ao alto volume de dados relatados. Para habilitá-los, além

224 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

ao configurar as métricas do Dropwizard para Spark, você deve definir explicitamente a configuração do
SparkSession spark.sql.streaming.metricsEnabled como true antes de iniciar sua consulta.

Observe que apenas um subconjunto das informações disponíveis por meio de StreamingQuery.lastProgress()
é publicado por meio do Dropwizard Metrics. Se quiser publicar continuamente mais informações de
progresso em locais arbitrários, você terá que escrever ouvintes personalizados, conforme discutido a
seguir.

Publicação de métricas usando StreamingQueryListeners

personalizados StreamingQueryListener é uma interface de ouvinte de eventos com a qual você pode
injetar lógica arbitrária para publicar métricas continuamente. Esta API de desenvolvedor está disponível
apenas em Scala/Java. Existem duas etapas para usar ouvintes personalizados:

1. Defina seu ouvinte personalizado. A interface StreamingQueryListener fornece três métodos que
podem ser definidos pela sua implementação para obter três tipos de eventos relacionados a uma
consulta de streaming: início, progresso (ou seja, um gatilho foi executado) e encerramento. Aqui
está um exemplo:

// No Scala
importar org.apache.spark.sql.streaming._
val myListener = new StreamingQueryListener() {
override def onQueryStarted(event: QueryStartedEvent): Unit =
{ println("Consulta iniciada: " + event.id)

} override def onQueryTerminated(event: QueryTerminatedEvent): Unit =


{ println("Consulta finalizada: " + event.id)

} override def onQueryProgress(event: QueryProgressEvent): Unit =


"
{ println("Consulta feita em progresso: + evento.progresso)
}
}

2. Adicione seu ouvinte ao SparkSession antes de iniciar a consulta:

// Em Scala
spark.streams.addListener(myListener)

Depois de adicionar o ouvinte, todos os eventos de consultas de streaming em execução nesta


sessão do Spark começarão a chamar os métodos do ouvinte.

Os fundamentos de uma consulta de streaming estruturada | 225


Machine Translated by Google

Streaming de fontes de dados e coletores


Agora que cobrimos as etapas básicas necessárias para expressar uma consulta de streaming estruturado
de ponta a ponta, vamos examinar como usar as fontes e coletores de dados de streaming integrados.
Como lembrete, você pode criar DataFrames a partir de fontes de streaming usando
SparkSession.readStream() e gravar a saída de um DataFrame de resultado usando DataFrame.writeStream().
Em cada caso, você pode especificar o tipo de fonte usando o método format(). Veremos alguns exemplos
concretos mais adiante.

arquivos

O Streaming Estruturado suporta a leitura e gravação de fluxos de dados de e para arquivos nos mesmos
formatos suportados no processamento em lote: texto simples, CSV, JSON, Parquet, ORC, etc. Aqui
discutiremos como operar o Streaming Estruturado em arquivos.

A leitura de arquivos

de streaming estruturado pode tratar arquivos gravados em um diretório como um fluxo de dados. Aqui
está um exemplo:

# Em Python
de pyspark.sql.types import *
inputDirectoryOfJsonFiles = ...

fileSchema =
(StructType() .add(StructField("chave",
IntegerType())) .add(StructField("valor", IntegerType())))

inputDF =
( faísca.readStream

.format("json") .schema(fileSchema) .load(inputDirectoryOfJsonFiles))

// No Scala
importar org.apache.spark.sql.types._
val inputDirectoryOfJsonFiles = ...

val fileSchema = new


StructType() .add("chave",
IntegerType) .add("valor", IntegerType)

val inputDF = spark.readStream

.format("json") .schema(fileSchema) .load(inputDirectoryOfJsonFiles)


O DataFrame de streaming retornado terá o esquema especificado. Aqui estão alguns pontos importantes
a serem lembrados ao usar arquivos:

226 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

• Todos os arquivos devem ter o mesmo formato e devem ter o mesmo esquema. Por exemplo, se o
formato for "json", todos os arquivos deverão estar no formato JSON com um registro JSON por
linha. O esquema de cada registro JSON deve corresponder àquele especificado com readStream().
A violação dessas suposições pode levar a análises incorretas (por exemplo, valores nulos
inesperados ) ou falhas de consulta. • Cada arquivo deve aparecer na listagem de

diretórios atomicamente — ou seja, o arquivo inteiro deve estar disponível de uma só vez para leitura
e, uma vez disponível, o arquivo não pode ser atualizado ou modificado. Isso ocorre porque o
Structured Streaming processará o arquivo quando o mecanismo o encontrar (usando a listagem
de diretórios) e o marcará internamente como processado. Quaisquer alterações nesse arquivo não
serão processadas. • Quando há vários arquivos novos para

processar, mas só é possível selecionar alguns deles no próximo microlote (por exemplo, devido a
limites de taxa), ele selecionará os arquivos com os carimbos de data/hora mais antigos. Dentro do
microlote, porém, não existe uma ordem predefinida de leitura dos arquivos selecionados; todos
eles serão lidos em paralelo.

Essa fonte de arquivo de streaming oferece suporte a diversas opções comuns,


incluindo as opções específicas de formato de arquivo suportadas por spark.read()
(consulte “Fontes de dados para DataFrames e tabelas SQL” na página 94 no
Capítulo 4) e diversas opções específicas de streaming ( por exemplo,
maxFilesPerTrigger para limitar a taxa de processamento do arquivo). Consulte
o guia de programação para obter detalhes completos.

A gravação em

arquivos de streaming estruturado oferece suporte à gravação da saída da consulta de streaming em


arquivos nos mesmos formatos de leitura. No entanto, ele suporta apenas o modo anexar, porque
embora seja fácil escrever novos arquivos no diretório de saída (ou seja, anexar dados a um diretório), é
difícil modificar arquivos de dados existentes (como seria esperado com os modos atualização e
conclusão). . Ele também suporta particionamento. Aqui está um exemplo:

# Em Python
outputDir = ...
checkpointDir = ...
resultDF = ...

streamingQuery =

(resultDF.writeStream .format("parquet") .option("caminho",


outputDir) .option("checkpointLocation", checkpointDir) .start())

Fontes e coletores de dados de streaming | 227


Machine Translated by Google

// No Scala
val outputDir = ...
val checkpointDir = val ...
resultDF = ...

val streamingQuery =

resultDF .writeStream .format("parquet") .option("caminho",


outputDir) .option("checkpointLocation", checkpointDir) .start()

Em vez de usar a opção "caminho" , você pode especificar o diretório de saída diretamente como start(outputDir).

Alguns pontos-chave a serem lembrados:

• O Streaming Estruturado obtém garantias de ponta a ponta exatamente uma vez ao gravar em arquivos,
mantendo um log dos arquivos de dados que foram gravados no diretório. Este log é mantido no subdiretório
_spark_metadata. Qualquer consulta do Spark no diretório (não em seus subdiretórios) usará automaticamente
o log para ler o conjunto correto de arquivos de dados, de modo que a garantia exatamente uma vez seja
mantida (ou seja, nenhum dado duplicado ou arquivos parciais serão lidos). Observe que outros mecanismos
de processamento podem não estar cientes desse registro e, portanto, podem não fornecer a mesma garantia.

• Se você alterar o esquema do DataFrame de resultado entre as reinicializações, o diretório de saída terá dados
em vários esquemas. Esses esquemas devem ser reconciliados ao consultar o diretório.

Apache Kafka Apache

Kafka é um sistema popular de publicação/assinatura amplamente utilizado para armazenamento de fluxos de


dados. O streaming estruturado tem suporte integrado para leitura e gravação no Apache Kafka.

Lendo do Kafka Para

realizar leituras distribuídas do Kafka, você deve usar opções para especificar como se conectar à fonte. Digamos
que você queira assinar dados do tópico "eventos".
Veja como você pode criar um DataFrame de streaming:

# Em Python
inputDF =

(spark .readStream .format("kafka") .option("kafka.bootstrap.servers",

"host1:port1,host2:port2") .option("subscribe", "events") .load ())

228 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

// Em Scala
val inputDF = faísca
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "host1:porta1,host2:porta2")
.option("inscrever-se", "eventos")
.carregar()

O DataFrame retornado terá o esquema descrito na Tabela 8-1.

Tabela 8-1. Esquema do DataFrame gerado pela fonte Kafka


Nome da coluna Tipo de coluna Descrição

chave binário Dados-chave do registro como bytes.

valor binário Dados de valor do registro em bytes.

tema corda Tópico Kafka em que o registro estava. Isso é útil quando inscrito em vários tópicos.

partição int Partição do tópico Kafka em que o registro estava.

desvio longo Valor de deslocamento do registro.

carimbo de data/hora longo Carimbo de data/hora associado ao registro.

timestampType int Enumeração do tipo de carimbo de data/hora associado ao registro.

Você também pode optar por assinar vários tópicos, um padrão de tópicos ou até mesmo um tópico específico.
partição específica de um tópico. Além disso, você pode escolher se deseja ler apenas novos dados
nos tópicos inscritos ou processar todos os dados disponíveis nesses tópicos. Você pode
até mesmo ler dados Kafka de consultas em lote – ou seja, tratar tópicos Kafka como tabelas. Ver
o Guia de Integração Kafka para mais detalhes.

Escrevendo para Kafka

Para escrever no Kafka, o Structured Streaming espera que o resultado do DataFrame tenha um
algumas colunas de nomes e tipos específicos, conforme descrito na Tabela 8-2.

Tabela 8-2. Esquema do DataFrame que pode ser gravado no coletor Kafka
Nome da coluna Tipo de coluna Descrição

chave (opcional) string ou Se presente, os bytes serão escritos como a chave de registro Kafka; caso contrário, a chave

binário estará vazio.

valor (obrigatório) corda ou Os bytes serão escritos como o valor do registro Kafka.

binário

tópico (obrigatório apenas se corda Se "tópico" não for especificado como opção, isso determinará o tópico para escrever o

"tópico" não é chave/valor para. Isso é útil para distribuir as gravações em vários tópicos. Se o

especificado como opção) A opção "topic" foi especificada, este valor será ignorado.

Fontes e coletores de dados de streaming | 229


Machine Translated by Google

Você pode escrever no Kafka em todos os três modos de saída, embora o modo completo não seja
recomendado, pois ele produzirá repetidamente os mesmos registros. Aqui está um exemplo concreto de
gravação da saída de nossa consulta anterior de contagem de palavras no Kafka no modo de atualização:

# Em Python
conta = ... # DataFrame[palavra: string, contagem: longo]
streamingQuery =

(conta .selectExpr( "cast(palavra como


string) como chave", "cast(conta como string) como valor")

.writeStream .format("kafka") .option("kafka.bootstrap.servers",


"host1:port1,host2:port2") .option("topic",

"wordCounts") .outputMode("update") .option


("checkpointLocation", checkpointDir) .start())

// Em Scala
val counts = ... // DataFrame[word: string, count: long] val
streamingQuery =

counts .selectExpr( "cast(word as


string) as key", "cast(count as string) as value" )

.writeStream .format("kafka") .option("kafka.bootstrap.servers",


"host1:port1,host2:port2") .option("topic",

"wordCounts") .outputMode("update") .option


("checkpointLocation", checkpointDir) .start()

Consulte o Guia de Integração Kafka para obter mais detalhes.

Fontes e coletores de streaming personalizados Nesta

seção, discutiremos como ler e gravar em sistemas de armazenamento que não possuem suporte integrado
em streaming estruturado. Em particular, você verá como usar os métodos foreachBatch() e foreach() para
implementar lógica personalizada para gravar em seu armazenamento.

Gravando em qualquer sistema

de armazenamento Existem duas operações que permitem gravar a saída de uma consulta de streaming em
sistemas de armazenamento arbitrários: foreachBatch() e foreach(). Eles têm casos de uso ligeiramente
diferentes: enquanto foreach() permite lógica de gravação personalizada em cada linha, foreach Batch()
permite operações arbitrárias e lógica personalizada na saída de cada microlote. Vamos explorar seu uso
com mais detalhes.

230 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

Usando foreachBatch(). foreachBatch() permite especificar uma função que é executada na


saída de cada microlote de uma consulta de streaming. São necessários dois parâmetros: um
DataFrame ou Dataset que possui a saída de um microlote e o identificador exclusivo do
microlote. Por exemplo, digamos que queremos escrever a saída de nossa consulta anterior
de contagem de palavras no Apache Cassandra. A partir do Spark Cassandra Connector 2.4.2,
não há suporte para gravação de DataFames de streaming. Mas você pode usar o suporte ao
DataFrame em lote do conector para gravar a saída de cada lote (ou seja, contagens de
palavras atualizadas) no Cassandra, conforme mostrado aqui:

# Em Python
hostAddr = "<endereço IP>"
keyspaceName = "<keyspace>"
tableName = "<tableName>"

spark.conf.set("spark.cassandra.connection.host", hostAddr)

def writeCountsToCassandra(atualizadoCountsDF, batchId):


# Use a fonte de dados em lote Cassandra para gravar as contagens atualizadas
(updatedCountsDF
.escrever

.format("org.apache.spark.sql.cassandra") .mode("append") .options(table=tableName, keyspace=keyspaceName) .save

streamingQuery =

(conta .writeStream .foreachBatch(writeCountsToCassandra) .outputMode("update") .option("checkpointLocation", checkpoint

// No Scala
importe org.apache.spark.sql.DataFrame

val hostAddr = "<endereço IP>" val


keyspaceName = "<keyspace>" val
tableName = "<tableName>"

spark.conf.set("spark.cassandra.connection.host", hostAddr)

def writeCountsToCassandra(updatedCountsDF: DataFrame, batchId: Long) { // Use a fonte


de dados em lote Cassandra para gravar as contagens atualizadas

atualizadaCountsDF .write .format("org.apache.spark.sql.cassandra") .options(Map("table"


-> tableName,
"keyspace" -> keyspaceName)) .mode("append") .save()
}

Fontes e coletores de dados de streaming | 231


Machine Translated by Google

val streamingQuery =

conta .writeStream .foreachBatch(writeCountsToCassandra

_) .outputMode("atualização") .option("checkpointLocation",
checkpointDir) .start()

Com foreachBatch(), você pode fazer o seguinte:

Reutilizar fontes de dados em lote


existentes Como mostrado no exemplo anterior, com foreachBatch() você pode usar fontes de dados em
lote existentes (ou seja, fontes que suportam a gravação de DataFrames em lote) para gravar a saída de
consultas de streaming.

Gravar em vários locais Se você


quiser gravar a saída de uma consulta de streaming em vários locais (por exemplo, um data warehouse
OLAP e um banco de dados OLTP), basta gravar o DataFrame/Dataset de saída diversas vezes. No
entanto, cada tentativa de gravação pode fazer com que os dados de saída sejam recalculados (incluindo
uma possível releitura dos dados de entrada). Para evitar recomputações, você deve armazenar em cache
o quadro batchOutputData, gravá-lo em vários locais e, em seguida, removê-lo do cache:

# Em Python
def writeCountsToMultipleLocations(updatedCountsDF, batchId):
atualizadoCountsDF.persist()
atualizadoCountsDF.write.format(...).save() # Local 1
atualizadoCountsDF.write.format(...).save() # Local 2
atualizadoCountsDF.unpersist()

// Em Scala def
writeCountsToMultipleLocations( atualizadoCountsDF:
DataFrame, batchId: Long)

{ atualizadoCountsDF.persist()
atualizadoCountsDF.write.format(...).save() // Local 1
atualizadoCountsDF.write.format(...). save() // Local 2
atualizadoCountsDF.unpersist()
}

Aplicar operações adicionais do DataFrame


Muitas operações da API DataFrame não são suportadas3 em DataFrames de streaming porque o
Streaming Estruturado não suporta a geração de planos incrementais nesses casos. Usando foreachBatch(),
você pode aplicar algumas dessas operações em cada saída de microlote. No entanto, você terá que
raciocinar sobre a semântica ponta a ponta de fazer a operação sozinho.

3 Para obter a lista completa de operações não suportadas, consulte o Guia de programação de streaming estruturado.

232 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

foreachBatch() fornece garantias de gravação pelo menos uma vez. Você pode
obter garantias exatamente uma vez usando batchId para desduplicar múltiplas
gravações de microlotes reexecutados.

Usando foreach(). Se foreachBatch() não for uma opção (por exemplo, se um gravador de dados em lote
correspondente não existir), você poderá expressar sua lógica de gravador personalizada usando foreach().
Especificamente, você pode expressar a lógica de gravação de dados dividindo-a em três métodos: open(),
process() e close(). O Streaming Estruturado usará esses métodos para gravar cada partição dos registros de
saída. Aqui está um exemplo abstrato:

# Em Python
# Variação 1: Usando a função def
process_row(row):
# Grava linha no armazenamento
passar

consulta = streamingDF.writeStream.foreach(process_row).start()

# Variação 2: Usando a classe ForeachWriter class


ForeachWriter:
def open(self, partiçãoId, epochId):
# Abra a conexão com o armazenamento de dados
# Retorna True se a gravação continuar
# Este método é opcional em Python
# Se não for especificado, a escrita continuará automaticamente retornando
True

processo def(self, linha):


# Grava string no armazenamento de dados usando conexão aberta
# Este método NÃO é opcional em Python
passar

def close(self, erro): # Fecha


a conexão. Este método é opcional em Python
passar

resultadoDF.writeStream.foreach(ForeachWriter()).start()

// Em Scala
import org.apache.spark.sql.ForeachWriter val
foreachWriter = new ForeachWriter[String] { // digitado com Strings

def open(partitionId: Long, epochId: Long): Boolean = {


//Abre conexão com o armazenamento de dados
// Retorna verdadeiro se a escrita deve continuar
}

processo def (registro: String): Unidade = {


// Grava string no armazenamento de dados usando conexão aberta

Fontes e coletores de dados de streaming | 233


Machine Translated by Google

def close(errorOrNull: Throwable): Unidade = {


// Fecha a conexão
}
}

resultadoDSofStrings.writeStream.foreach(foreachWriter).start()
A semântica detalhada desses métodos executados é discutida no Estruturado
Guia de programação de streaming.

Lendo de qualquer sistema de

armazenamento Infelizmente, a partir do Spark 3.0, as APIs para criar fontes e coletores de streaming
personalizados ainda são experimentais. A iniciativa DataSourceV2 no Spark 3.0 introduz as APIs de
streaming, mas elas ainda não foram declaradas como estáveis. Conseqüentemente, não existe uma
maneira oficial de ler sistemas de armazenamento arbitrários.

Transformações de dados

Nesta seção, vamos nos aprofundar nas transformações de dados suportadas no Streaming Estruturado.
Conforme discutido brevemente anteriormente, apenas as operações DataFrame que podem ser
executadas de forma incremental são suportadas no Streaming Estruturado. Essas operações são
amplamente classificadas em operações sem estado e sem estado. Definiremos cada tipo de operação
e explicaremos como identificar quais operações têm estado.

Execução incremental e estado de streaming Conforme

discutimos em “Nos bastidores de uma consulta de streaming ativa” na página 219, o otimizador
Catalyst no Spark SQL converte todas as operações do DataFrame em um plano lógico otimizado. O
planejador Spark SQL, que decide como executar um plano lógico, reconhece que este é um plano
lógico de streaming que precisa operar em fluxos de dados contínuos. Conseqüentemente, em vez de
converter o plano lógico em um plano de execução física único, o planejador gera uma sequência
contínua de planos de execução. Cada plano de execução atualiza o DataFrame de resultado final de
forma incremental – ou seja, o plano processa apenas uma parte dos novos dados dos fluxos de
entrada e possivelmente algum resultado intermediário e parcial calculado pelo plano de execução
anterior.

Cada execução é considerada um microlote, e o resultado intermediário parcial que é comunicado entre
as execuções é chamado de “estado” de streaming. As operações de quadro de dados podem ser
amplamente classificadas em operações sem estado e com estado com base no fato de a execução
incremental da operação exigir a manutenção de um estado. No restante desta seção, exploraremos a
distinção entre operações sem estado e com estado e como sua presença em uma consulta de
streaming requer diferentes configurações de tempo de execução e gerenciamento de recursos.

234 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

Algumas operações lógicas são fundamentalmente impraticáveis ou muito caras para


serem computadas de forma incremental e, portanto, não são suportadas no streaming
estruturado. Por exemplo, qualquer tentativa de iniciar uma consulta de streaming com
uma operação como cube() ou rollup() gerará uma UnsupportedOperationException.

Transformações sem Estado

Todas as operações de projeção (por exemplo, select(), explode(), map(), flatMap()) e operações de seleção (por
exemplo, filter(), where()) processam cada registro de entrada individualmente sem precisar de nenhuma informação das
linhas anteriores . Esta falta de dependência de dados de entrada anteriores torna-as operações sem estado.

Uma consulta de streaming com apenas operações sem estado oferece suporte aos modos de saída de acréscimo e
atualização, mas não ao modo completo. Isso faz sentido: como qualquer linha de saída processada de tal consulta não
pode ser modificada por quaisquer dados futuros, ela pode ser gravada em todos os coletores de streaming no modo de
acréscimo (incluindo aqueles somente de acréscimo, como arquivos de qualquer formato). Por outro lado, tais consultas
naturalmente não combinam informações nos registros de entrada e, portanto, podem não reduzir o volume dos dados
no resultado.
O modo completo não é suportado porque o armazenamento de dados de resultados cada vez maiores costuma ser
caro. Isto contrasta fortemente com as transformações com estado, como discutiremos
próximo.

Transformações com estado

O exemplo mais simples de uma transformação stateful é DataFrame.groupBy().count(), que gera uma contagem
contínua do número de registros recebidos desde o início da consulta. Em cada microlote, o plano incremental adiciona
a contagem de novos registros à contagem anterior gerada pelo microlote anterior. Essa contagem parcial comunicada
entre os planos é o estado. Este estado é mantido na memória dos executores Spark e é verificado no local configurado
para tolerar falhas. Embora o Spark SQL gerencie automaticamente o ciclo de vida desse estado para garantir resultados
corretos, normalmente é necessário ajustar alguns botões para controlar o uso de recursos para manter o estado. Nesta
seção, exploraremos como diferentes operadores com estado gerenciam seu estado nos bastidores.

Transformações de dados | 235


Machine Translated by Google

Gerenciamento de estado distribuído e tolerante a falhas

Lembre-se dos Capítulos 1 e 2 de que um aplicativo Spark em execução em um cluster possui um driver e um
ou mais executores. O agendador do Spark em execução no driver divide suas operações de alto nível em
tarefas menores e as coloca em filas de tarefas e, à medida que os recursos ficam disponíveis, os executores
extraem as tarefas das filas para executá-las. Cada microlote em uma consulta de streaming executa
essencialmente um conjunto de tarefas que lê novos dados de fontes de streaming e grava a saída atualizada
em coletores de streaming. Para consultas de processamento de fluxo com estado, além de gravar em sinks,
cada microlote de tarefas gera dados de estado intermediários que serão consumidos pelo próximo microlote.
Essa geração de dados de estado é completamente particionada e distribuída (já que toda a leitura, gravação e
processamento ocorre no Spark) e é armazenada em cache na memória do executor para consumo eficiente.
Isso é ilustrado na Figura 8.6, que mostra como o estado é gerenciado em nossa consulta original de contagem
de palavras de streaming.

Figura 8-6. Gerenciamento de estado distribuído em streaming estruturado

Cada microlote lê um novo conjunto de palavras, embaralha-as nos executores para agrupá-las, calcula as
contagens dentro do microlote e, finalmente, adiciona-as às contagens em execução para produzir as novas
contagens. Essas novas contagens são a saída e o estado do próximo microlote e, portanto, são armazenadas
em cache na memória dos executores. O próximo microlote de dados é agrupado entre executores exatamente
da mesma maneira que antes, de modo que cada palavra seja sempre processada pelo mesmo executor e
possa, portanto, ler e atualizar localmente sua contagem em execução.

236 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

Porém, não é suficiente apenas manter esse estado na memória, pois qualquer falha (seja de um executor ou de
toda a aplicação) fará com que o estado da memória seja perdido. Para evitar perdas, salvamos de forma síncrona
a atualização do estado da chave/valor como logs de alterações no local do ponto de verificação fornecido pelo
usuário. Essas alterações são co-versionadas com os intervalos de deslocamento processados em cada lote, e a
versão necessária do estado pode ser reconstruída automaticamente lendo os logs verificados. Em caso de falha,
o Streaming Estruturado é capaz de reexecutar o microlote com falha, reprocessando os mesmos dados de entrada
junto com o mesmo estado que tinha antes desse microlote, produzindo assim os mesmos dados de saída que
teria se não houve fracasso. Isto é fundamental para garantir garantias de ponta a ponta exatamente uma vez.

Resumindo, para todas as operações com estado, o Streaming Estruturado garante a correção da operação
salvando e restaurando automaticamente o estado de maneira distribuída. Dependendo da operação com estado,
tudo o que você precisa fazer é ajustar a política de limpeza de estado de forma que chaves e valores antigos
possam ser eliminados automaticamente do estado em cache. Isto é o que discutiremos a seguir.

Tipos de operações com estado

A essência do estado de streaming é reter resumos de dados anteriores. Às vezes, resumos antigos precisam ser
eliminados do estado para dar lugar a novos resumos.
Com base em como isso é feito, podemos distinguir dois tipos de operações com estado:

Operações com estado gerenciadas


Eles identificam e limpam automaticamente o estado antigo, com base em uma definição específica de
operação de “antigo”. Você pode ajustar o que está definido como antigo para controlar o uso de recursos
(por exemplo, memória do executor usada para armazenar estado). As operações que se enquadram nesta
categoria são aquelas destinadas a:

• Agregações de streaming •

Junções fluxo-stream •

Desduplicação de streaming

Operações com estado não gerenciadas


Essas operações permitem definir sua própria lógica de limpeza de estado personalizada. As operações
nesta categoria são:

• MapGroupsWithState

• FlatMapGroupsWithState

Essas operações permitem definir operações com estado arbitrárias (sessionização, etc.).

Cada uma dessas operações é discutida em detalhes nas seções a seguir.

Transformações de dados | 237


Machine Translated by Google

Agregações de streaming com estado


O Streaming Estruturado pode executar de forma incremental a maioria das operações de agregação de
DataFrame. Você pode agregar dados por chaves (por exemplo, contagem de palavras de streaming) e/ou por
tempo (por exemplo, contagem de registros recebidos a cada hora). Nesta seção, discutiremos a semântica e
os detalhes operacionais do ajuste desses diferentes tipos de agregações de streaming. Também discutiremos
brevemente os poucos tipos de agregações que não são compatíveis com streaming. Vamos começar com
agregações que não envolvem tempo.

Agregações não baseadas no tempo


As agregações que não envolvem tempo podem ser amplamente classificadas em duas categorias:

Agregações globais
Agregações em todos os dados do fluxo. Por exemplo, digamos que você tenha um fluxo de leituras de
sensores como um DataFrame de streaming chamado sensorReadings.
Você pode calcular a contagem contínua do número total de leituras recebidas com a seguinte consulta:

# Em Python
runningCount = sensorReadings.groupBy().count()

// Em Scala
val runningCount = sensorReadings.groupBy().count()

Você não pode usar operações de agregação direta como Data


Frame.count() e Dataset.reduce() em streaming de Data-Frames.
Isso ocorre porque, para DataFrames estáticos, essas operações
retornam imediatamente os agregados finais computados,
enquanto para DataFrames de streaming os agregados precisam
ser atualizados continuamente. Portanto, você deve sempre usar
Data Frame.groupBy() ou Dataset.groupByKey() para agregações
em streaming de DataFrames.

Agregações agrupadas
Agregações dentro de cada grupo ou chave presente no fluxo de dados. Por exemplo, se sensorReadings
contém dados de vários sensores, você pode calcular a leitura média de execução de cada sensor (por
exemplo, para configurar um valor de linha de base para cada sensor) com o seguinte:

# Em Python
baselineValues = sensorReadings.groupBy("sensorId").mean("value")

// Em Scala
val baselineValues = sensorReadings.groupBy("sensorId").mean("value")

238 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

Além de contagens e médias, os DataFrames de streaming suportam os seguintes tipos de agregações


(semelhantes aos DataFrames em lote):

Todas as funções de agregação


integradas sum(), mean(), stddev(), countDistinct(), collect_set(), approx_count_dis tinct(), etc. Consulte
a documentação da API (Python e Scala) para obter mais detalhes.

Múltiplas agregações computadas juntas


Você pode aplicar múltiplas funções de agregação para serem computadas juntas da seguinte maneira:

# Em Python
de pyspark.sql.functions import *
multipleAggs =

(sensorReadings .groupBy("sensorId") .agg(count("*"),


mean("value").alias("baselineValue"), collect_set("errorCode ").alias("allErrorCodes")))

// Em Scala
importar org.apache.spark.sql.functions.* val
multipleAggs =

sensorReadings .groupBy("sensorId") .agg(count("*"),


mean("value").alias("baselineValue") , collect_set("errorCode").alias("allErrorCodes"))

Funções de agregação definidas pelo


usuário Todas as funções de agregação definidas pelo usuário são suportadas. Consulte o guia de
programação Spark SQL para obter mais detalhes sobre funções de agregação definidas pelo usuário
não digitadas e digitadas.

Com relação à execução de tais agregações de streaming, já ilustramos nas seções anteriores como as
agregações em execução são mantidas em um estado distribuído.
Além disso, há dois pontos muito importantes a serem lembrados para agregações não baseadas no tempo:
o modo de saída a ser usado para tais consultas e o planejamento do uso de recursos por estado. Eles são
discutidos no final desta seção. A seguir, discutiremos agregações que combinam dados em janelas de tempo.

Agregações com janelas de tempo de evento Em muitos

casos, em vez de executar agregações em todo o fluxo, você deseja agregações em dados agrupados por
janelas de tempo. Continuando com nosso exemplo de sensor, digamos que se espera que cada sensor envie
no máximo uma leitura por minuto e queremos detectar se algum sensor está reportando um número
incomumente alto de vezes. Para encontrar tais anomalias, podemos contar o número de leituras recebidas
de cada sensor em intervalos de cinco minutos. Além disso, para maior robustez, deveríamos calcular o
intervalo de tempo com base em quando os dados foram gerados no sensor e não com base em quando os
dados foram gerados no sensor.

Agregações de streaming com estado | 239


Machine Translated by Google

dados foram recebidos, pois qualquer atraso no trânsito distorceria os resultados. Em outras palavras,
queremos usar a hora do evento – ou seja, o carimbo de data/hora no registro que representa quando
a leitura foi gerada. Digamos que o sensorReadings DataFrame tenha o carimbo de data/hora de
geração como uma coluna chamada eventTime. Podemos expressar esta contagem de cinco minutos
da seguinte forma:

# Em Python
de pyspark.sql.functions import *

(sensorReadings .groupBy("sensorId", window("eventTime", "5


minutos")) .count())

// Em Scala
importar org.apache.spark.sql.functions.*

sensorReadings .groupBy("sensorId", window("eventTime", "5


minutos")) .count()

A principal coisa a ser observada aqui é a função window() , que nos permite expressar as janelas de
cinco minutos como uma coluna de agrupamento calculada dinamicamente. Quando iniciada, esta
consulta fará efetivamente o seguinte para cada leitura do sensor:

• Use o valor eventTime para calcular a janela de tempo de cinco minutos na qual a leitura do sensor
se enquadra.

• Agrupar a leitura com base no grupo composto (<janela computada>,


SensorID).
• Atualize a contagem do grupo composto.

Vamos entender isso com um exemplo ilustrativo. A Figura 8-7 mostra como algumas leituras de
sensores são mapeadas para grupos de janelas oscilantes de cinco minutos (isto é, não sobrepostas)
com base no tempo do evento. As duas linhas do tempo mostram quando cada evento recebido
será processado pelo Streaming Estruturado, e o carimbo de data/hora nos dados do evento
(geralmente, o horário em que o evento foi gerado no sensor).

240 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

Figura 8-7. Mapeamento do horário do evento para janelas em queda

Cada janela de cinco minutos ao longo do tempo do evento é considerada para o agrupamento com base no qual as
contagens serão calculadas. Observe que os eventos podem chegar atrasados e fora de ordem em termos de horário do
evento. Conforme mostrado na figura, o evento com horário 12h07 foi recebido e processado após o evento com horário
12h11. No entanto, independentemente de quando eles chegam, cada evento é atribuído ao grupo apropriado com base
no horário do evento. Na verdade, dependendo da especificação da janela, cada evento pode ser atribuído a vários
grupos. Por exemplo, se quiser calcular contagens correspondentes a janelas de 10 minutos que deslizam a cada 5
minutos, você pode fazer o seguinte:

# Em Python
(sensorReadings
.groupBy("sensorId", window("eventTime", "10 minutos", "5 minutos")) .count())

// Em Scala

sensorReadings .groupBy("sensorId", window("eventTime", "10 minutos", "5 minutos")) .count()

Nesta consulta, cada evento será atribuído a duas janelas sobrepostas, conforme ilustrado na Figura 8-8.

Agregações de streaming com estado | 241


Machine Translated by Google

Figura 8-8. Mapeamento do horário do evento para múltiplas janelas sobrepostas

Cada tupla exclusiva de (<janela de tempo atribuída>, sensorId) é considerada um grupo gerado
dinamicamente para o qual as contagens serão calculadas. Por exemplo, o evento [eventTime = 12:07,
sensorId = id1] é mapeado para duas janelas de tempo e, portanto, dois grupos, (12:00-12:10, id1) e
(12:05-12:15, id1) . As contagens para essas duas janelas são incrementadas em 1. A Figura 8-9 ilustra
isso para os eventos mostrados anteriormente.

Supondo que os registros de entrada foram processados com um intervalo de disparo de cinco minutos,
as tabelas na parte inferior da Figura 8-9 mostram o estado da tabela de resultados (ou seja, as contagens)
em cada um dos microlotes. À medida que o horário do evento avança, novos grupos são criados
automaticamente e seus agregados são atualizados automaticamente. Eventos atrasados e fora de serviço
são tratados automaticamente, pois simplesmente atualizam os mais antigos
grupos.

242 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

Figura 8-9. Contagens atualizadas na tabela de resultados após cada acionamento de cinco minutos

Contudo, do ponto de vista da utilização de recursos, isto coloca um problema diferente: o crescimento indefinido do
tamanho do estado. À medida que novos grupos são criados correspondentes às últimas janelas de tempo, os grupos
mais antigos continuam a ocupar a memória de estado, aguardando quaisquer dados atrasados para atualizá-los.
Mesmo que na prática haja um limite sobre o atraso dos dados de entrada (por exemplo, os dados não podem estar
atrasados mais de sete dias), a consulta não conhece essa informação. Conseqüentemente, ele não sabe quando
considerar uma janela como “velha demais para receber atualizações” e retirá-la do estado. Para fornecer um atraso

vinculado a uma consulta (e evitar um estado ilimitado), você pode especificar marcas d'água, conforme discutiremos
a seguir.

Tratamento de dados atrasados com

marcas d'água Uma marca d'água é definida como um limite móvel no tempo do evento que fica atrás do tempo
máximo do evento visto pela consulta nos dados processados. O intervalo final, conhecido como atraso da marca
d'água, define quanto tempo o mecanismo aguardará a chegada dos dados atrasados. Ao saber o ponto em que não
chegarão mais dados para um determinado grupo, o mecanismo pode finalizar automaticamente os agregados de
determinados grupos e retirá-los do estado. Isto limita a quantidade total de estado que o mecanismo deve manter
para calcular os resultados da consulta.

Por exemplo, suponha que você saiba que os dados do seu sensor não atrasarão mais de 10 minutos. Então você
pode definir a marca d’água da seguinte maneira:

# Em Python

(sensorReadings .withWatermark("eventTime", "10


minutos") .groupBy("sensorId", window("eventTime", "10 minutos", "5
minutos")) .mean("valor"))

Agregações de streaming com estado | 243


Machine Translated by Google

// Em Scala

sensorReadings .withWatermark("eventTime", "10


minutos") .groupBy("sensorId", window("eventTime", "10 minutos", "5
minutos")) .mean("valor")

Observe que você deve chamar withWatermark() antes de groupBy() e na mesma coluna de carimbo de data/
hora usada para definir janelas. Quando esta consulta é executada, o Streaming Estruturado rastreará
continuamente o valor máximo observado da coluna eventTime e atualizará a marca d’água de acordo, filtrará
os dados “tarde demais” e limpará o estado antigo. Ou seja, quaisquer dados atrasados em mais de 10 minutos
serão ignorados e todas as janelas de tempo que forem mais de 10 minutos anteriores aos dados de entrada
mais recentes (por horário do evento) serão limpas do estado. Para esclarecer como esta consulta será
executada, considere a linha do tempo na Figura 8-10 que mostra como uma seleção de registros de entrada foi
processada.

Figura 8-10. Ilustração de como o mecanismo rastreia o tempo máximo do evento entre eventos, atualiza a
marca d'água e lida adequadamente com os dados atrasados

Esta figura mostra um gráfico bidimensional de registros processados em termos de seus tempos de
processamento (eixo x) e seus tempos de evento (eixo y). Os registros são processados em microlotes de cinco
minutos e marcados com círculos. As tabelas na parte inferior mostram o estado da tabela de resultados após a

conclusão de cada microlote.

Cada registro foi recebido e processado após todos os registros à sua esquerda. Considere os dois registros
[12:15, id1] (processado por volta de 12:17) e [12:13, id3] (processado por volta de 12:18). O recorde para id3 foi
considerado tardio (e portanto marcado em sólido

244 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

vermelho) porque foi gerado pelo sensor antes do registro para id1 , mas foi processado depois deste
último. No entanto, no microlote para o intervalo de tempo de processamento 12h15-12h20, a marca
d'água usada foi 12h04, que foi calculada com base no tempo máximo do evento visto até o microlote
anterior (ou seja, 12 :14 menos o atraso da marca d’água de 10 minutos). Portanto, o registro tardio
[12:13, id3] não foi considerado tardio e foi contado com sucesso. Em contrapartida, no microlote
seguinte, o registro [12:04, id1] foi considerado muito atrasado em comparação com a nova marca
d’água de 12:11 e foi descartado.

Você pode definir o atraso da marca d'água com base nos requisitos do seu aplicativo – valores maiores
para esse parâmetro permitem que os dados cheguem mais tarde, mas ao custo de um tamanho de
estado maior (ou seja, uso de memória) e vice-versa.

Garantias semânticas com marcas d’água. Antes de concluirmos esta seção sobre marcas d’água,
vamos considerar a garantia semântica precisa que a marca d’água fornece. Uma marca d'água de 10
minutos garante que o mecanismo nunca descartará nenhum dado com atraso inferior a 10 minutos em
comparação com o horário do último evento visto nos dados de entrada. No entanto, a garantia é rigorosa
apenas num sentido. Não é garantido que os dados atrasados por mais de 10 minutos sejam eliminados,
ou seja, podem ser agregados.
Se um registro de entrada com mais de 10 minutos de atraso será realmente agregado ou não, depende
do momento exato em que o registro foi recebido e de quando o processamento do microlote foi
acionado.

Modos de saída

suportados Ao contrário das agregações de streaming que não envolvem tempo, as agregações com
janelas de tempo podem usar todos os três modos de saída. No entanto, existem outras implicações
relacionadas à limpeza de estado das quais você precisa estar ciente, dependendo do modo:

Modo de
atualização Neste modo, cada microlote produzirá apenas as linhas onde o agregado foi atualizado.
Este modo pode ser usado com todos os tipos de agregações. Especificamente para agregações
de janelas de tempo, a marca d'água garantirá que o estado seja limpo regularmente. Este é o
modo mais útil e eficiente para executar consultas com agregações de streaming. No entanto, você
não pode usar esse modo para gravar agregações em coletores de streaming somente anexados,
como qualquer formato baseado em arquivo, como Parquet e ORC (a menos que você use Delta
Lake, que discutiremos no próximo capítulo).

Modo completo
Neste modo, cada microlote produzirá todos os agregados atualizados, independentemente da sua
idade ou se contêm alterações. Embora este modo possa ser usado em todos os tipos de
agregações, para agregações de janela de tempo, usar o modo completo significa que o estado
não será limpo mesmo se uma marca d’água for especificada. A saída de todos os agregados
requer todo o estado passado e, portanto, os dados de agregação devem ser preservados

Agregações de streaming com estado | 245


Machine Translated by Google

mesmo que uma marca d'água tenha sido definida. Use esse modo em agregações de janela de tempo
com cuidado, pois isso pode levar a um aumento indefinido no tamanho do estado e no uso de memória.

Modo de
acréscimo Este modo pode ser usado somente com agregações em janelas de tempo de evento e com
marca d'água habilitada. Lembre-se de que o modo de acréscimo não permite que os resultados de saída
anteriores sejam alterados. Para qualquer agregação sem marcas d'água, cada agregação pode ser
atualizada com quaisquer dados futuros e, portanto, estes não podem ser gerados no modo de acréscimo.
Somente quando a marca d’água está habilitada em agregações em janelas de tempo de evento a consulta
sabe quando uma agregação não será mais atualizada.
Portanto, em vez de gerar as linhas atualizadas, o modo de acréscimo gera cada chave e seu valor
agregado final somente quando a marca d’água garante que o agregado não será atualizado novamente.
A vantagem desse modo é que ele permite gravar agregações em coletores de streaming somente
anexados (por exemplo, arquivos). A desvantagem é que a saída será atrasada pela duração da marca
d’água – a consulta terá que esperar que a marca d’água final exceda o intervalo de tempo de uma chave
antes que sua agregação possa ser finalizada.

Junções de streaming

O streaming estruturado oferece suporte à junção de um conjunto de dados de streaming com outro conjunto de
dados estático ou de streaming. Nesta seção exploraremos quais tipos de junções (internas, externas, etc.) são
suportadas e como usar marcas d'água para limitar o estado armazenado para junções com estado.
Começaremos com o caso simples de unir um fluxo de dados e um conjunto de dados estático.

Fluxo – junções estáticas

Muitos casos de uso exigem a união de um fluxo de dados com um conjunto de dados estático. Por exemplo,
consideremos o caso da monetização de anúncios. Suponha que você seja uma empresa de publicidade que
exibe anúncios em sites e ganhe dinheiro quando os usuários clicam neles. Vamos supor que você tenha um
conjunto de dados estático de todos os anúncios a serem exibidos (conhecidos como impressões) e outro fluxo
de eventos para cada vez que os usuários clicam nos anúncios exibidos. Para calcular a receita de cliques, você
deve combinar cada clique no fluxo de eventos com a impressão de anúncio correspondente na tabela. Vamos
primeiro representar os dados como dois Data-Frames, um estático e um de streaming, conforme mostrado aqui:

# Em Python
# Static DataFrame [adId: String, impressionTime: Timestamp, ...] # lendo de sua
fonte de dados estática impressionsStatic =
spark.read. ...

# Streaming DataFrame [adId: String, clickTime: Timestamp, ...] # lendo de


sua fonte de streaming clicksStream =
spark.readStream. ...

246 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

// Em Scala //
DataFrame estático [adId: String, impressionTime: Timestamp, ...] // lendo de sua fonte
de dados estática val impressionsStatic =
spark.read. ...

// Streaming DataFrame [adId: String, clickTime: Timestamp, ...] // leitura de sua fonte
de streaming val clicksStream = spark.readStream. ...

Para combinar os cliques com as impressões, você pode simplesmente aplicar uma equi-join interna entre eles
usando a coluna adId comum:

# Em Python
correspondido = clicksStream.join(impressionsStatic, "adId")

// Em Scala
val correspondido = clicksStream.join(impressionsStatic, "adId")

Este é o mesmo código que você teria escrito se as impressões e os cliques fossem DataFrames estáticos — a
única diferença é que você usa spark.read() para processamento em lote e spark.readStream() para um stream.
Quando esse código é executado, cada microlote de cliques é unido internamente na tabela de impressões
estáticas para gerar o fluxo de saída de eventos correspondentes.

Além de junções internas, o Streaming Estruturado também oferece suporte a dois tipos de junções externas
estáticas de fluxo:

• Junção externa esquerda quando o lado esquerdo é um DataFrame de

streaming • Junção externa direita quando o lado direito é um DataFrame de streaming

Os outros tipos de junções externas (por exemplo, full outer e left outer com um Data-Frame de streaming à
direita) não são suportados porque não são fáceis de executar de forma incremental.
Em ambos os casos suportados, o código é exatamente como seria para uma junção externa esquerda/direita
entre dois DataFrames estáticos:

# Em Python
correspondido = clicksStream.join(impressionsStatic, "adId", "leftOuter")

// Em Scala
val correspondido = clicksStream.join(impressionsStatic, Seq("adId"), "leftOuter")

Há alguns pontos-chave a serem observados sobre junções estáticas de fluxo:

• As junções stream-static são operações sem estado e, portanto, não requerem nenhum tipo de marca d'água.

• O DataFrame estático é lido repetidamente enquanto se junta aos dados de streaming de cada microlote,
para que você possa armazenar em cache o DataFrame estático para acelerar as leituras. • Se os dados

subjacentes na fonte de dados na qual o DataFrame estático foi definido forem alterados, dependerá se essas
alterações serão vistas pela consulta de streaming.

Junções de streaming | 247


Machine Translated by Google

no comportamento específico da fonte de dados. Por exemplo, se o DataFrame estático foi definido em
arquivos, as alterações nesses arquivos (por exemplo, anexos) não serão detectadas até que a consulta de
streaming seja reiniciada.

Neste exemplo stream-static, fizemos uma suposição significativa: que a tabela de impressão é uma tabela
estática. Na realidade, haverá um fluxo de novas impressões geradas à medida que novos anúncios forem
exibidos. Embora as junções stream-static sejam boas para enriquecer dados em um stream com informações
estáticas adicionais (ou que mudam lentamente), essa abordagem é insuficiente quando ambas as fontes de
dados estão mudando rapidamente. Para isso você precisa de junções stream-stream, que discutiremos a seguir.

Stream–Stream junções

O desafio de gerar junções entre dois fluxos de dados é que, a qualquer momento, a visualização de qualquer
conjunto de dados fica incompleta, tornando muito mais difícil encontrar correspondências entre as entradas. Os
eventos correspondentes dos dois fluxos podem chegar em qualquer ordem e podem ser atrasados
arbitrariamente. Por exemplo, em nosso caso de uso de publicidade, um evento de impressão e seu evento de
clique correspondente podem chegar fora de ordem, com atrasos arbitrários entre eles. O streaming estruturado
leva em conta esses atrasos, armazenando em buffer os dados de entrada de ambos os lados como o estado do
streaming e verificando continuamente as correspondências à medida que novos dados são recebidos. A ideia
conceitual está esboçada na Figura 8-11.

Figura 8-11. Monetização de anúncios usando uma junção stream-stream

Vamos considerar isso com mais detalhes, primeiro com junções internas e depois com junções externas.

Junções internas com marca d'água opcional

Digamos que redefinimos nosso DataFrame de impressões para ser um DataFrame de streaming. Para obter o
fluxo de impressões correspondentes e seus cliques correspondentes, podemos usar o mesmo código que
usamos anteriormente para junções estáticas e junções estáticas de fluxo:

248 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

# Em Python
# Streaming DataFrame [adId: String, impressionTime: Timestamp, ...] impressões =
spark.readStream. ...

# Streaming DataFrame[adId: String, clickTime: Timestamp, ...] cliques =


spark.readStream. ... correspondido
= impressões.join(cliques, "adId")

// Em Scala //
Streaming DataFrame [adId: String, impressionTime: Timestamp, ...] val impressões
= spark.readStream. ...

// Streaming DataFrame[adId: String, clickTime: Timestamp, ...] val clicks =


spark.readStream. ... val correspondido =
impressões.join(cliques, "adId")

Embora o código seja o mesmo, a execução é completamente diferente. Quando esta consulta for
executada, o mecanismo de processamento a reconhecerá como uma junção fluxo-fluxo em vez de
uma junção fluxo-estática. O mecanismo armazenará em buffer todos os cliques e impressões como
estado e gerará uma impressão e um clique correspondentes assim que um clique recebido
corresponder a uma impressão armazenada em buffer (ou vice-versa, dependendo de qual foi recebido primeiro).
Vamos visualizar como essa junção interna funciona usando o exemplo de linha do tempo de eventos
na Figura 8.12.

Figura 8-12. Linha do tempo ilustrativa de cliques, impressões e resultados combinados

Na Figura 8-12, os pontos azuis representam os tempos dos eventos de impressão e clique que foram
recebidos em diferentes microlotes (separados pelas linhas cinzas tracejadas). Para os propósitos
desta ilustração, suponha que cada evento foi realmente recebido no mesmo horário do relógio de
parede que o horário do evento. Observe os diferentes cenários em que os eventos relacionados estão

sendo associados. Ambos os eventos com adId = ÿ foram

Junções de streaming | 249


Machine Translated by Google

recebidos no mesmo microlote, portanto, sua saída conjunta foi gerada por esse microlote. No entanto, para
adId = ÿ a impressão foi recebida às 12h04, muito antes do clique correspondente às 12h13. O streaming
estruturado receberá primeiro a impressão às 12h04 e a armazenará em buffer no estado. Para cada clique
recebido, o mecanismo tentará juntá-lo a todas as impressões armazenadas em buffer (e vice-versa).
Eventualmente, em um microlote posterior rodando por volta de 12h13, o mecanismo recebe o clique para adId
= ÿ e gera a saída associada.

No entanto, nesta consulta, não fornecemos nenhuma indicação de quanto tempo o mecanismo deve armazenar
em buffer um evento para encontrar uma correspondência. Portanto, o mecanismo pode armazenar um evento
em buffer para sempre e acumular uma quantidade ilimitada de estado de streaming. Para limitar o estado de
streaming mantido por junções stream-stream, você precisa saber as seguintes informações sobre seu caso de
uso:

• Qual é o intervalo de tempo máximo entre a geração dos dois eventos nas suas respectivas fontes? No
contexto do nosso caso de uso, vamos supor que um clique possa ocorrer de zero segundos a uma hora
após a impressão correspondente. • Qual é a duração máxima que um evento pode ser atrasado

no trânsito entre a origem e o mecanismo de processamento? Por exemplo, os cliques em anúncios de um


navegador podem atrasar devido à conectividade intermitente e chegar muito mais tarde do que o esperado
e fora de serviço. Digamos que as impressões e os cliques possam atrasar no máximo duas e três horas,
respectivamente.

Esses limites de atraso e restrições de tempo de evento podem ser codificados nas operações do DataFrame
usando marcas d’água e condições de intervalo de tempo. Em outras palavras, você terá que executar as
seguintes etapas adicionais na junção para garantir a limpeza do estado:

1. Defina atrasos de marca d'água em ambas as entradas, de modo que o mecanismo saiba como
atrasada a entrada pode ser (semelhante às agregações de streaming).

2. Defina uma restrição no tempo do evento entre as duas entradas, de modo que o mecanismo possa descobrir
quando as linhas antigas de uma entrada não serão necessárias (ou seja, não satisfarão a restrição de
tempo) para correspondências com a outra entrada. Essa restrição pode ser definida de uma das seguintes
maneiras: a. Condições de junção de intervalo de

tempo (por exemplo, condição de junção = "leftTime BETWEEN rightTime AND rightTime + INTERVAL 1
HOUR")

b. Junte-se em janelas de tempo de evento (por exemplo, join condition = "leftTimeWindow =


rightTimeWindow")

Em nosso caso de uso de publicidade, nosso código de junção interno ficará um pouco mais complicado:

250 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

# Em Python
# Defina marcas d'água
impressionsWithWatermark =
(impressions .selectExpr("adId AS impressionAdId",
"impressionTime") .withWatermark("impressionTime", "2 horas"))

clicksWithWatermark =
(cliques .selectExpr("adId AS clickAdId",
"clickTime") .withWatermark("clickTime", "3 horas"))

# Junção interna com condições de intervalo de


tempo (impressionsWithWatermark.join(clicksWithWatermark,
expr("""
clickAdId = impressionAdId AND
clickTime BETWE impressionTime AND impressionTime + intervalo de 1 hora""")))

//Em Scala
// Definir marcas d'água
val impressõesWithWatermark = impressões
.selectExpr("adId AS impressionAdId",
"impressionTime") .withWatermark("impressionTime", "2 horas ")

val clicksWithWatermark = cliques


.selectExpr("adId AS clickAdId",
"clickTime") .withWatermark("clickTime", "3 horas")

// Junção interna com condições de intervalo de


tempo impressionsWithWatermark.join(clicksWithWatermark,
expr("""
clickAdId = impressionAdId AND
clickTime BETWE impressionTime AND impressionTime + intervalo de 1 hora"""))

Com essas restrições de tempo para cada evento, o mecanismo de processamento pode calcular
automaticamente por quanto tempo os eventos precisam ser armazenados em buffer para gerar resultados
corretos e quando os eventos podem ser eliminados do estado. Por exemplo, avaliará o seguinte (ilustrado na
Figura 8-13):

• As impressões precisam ser armazenadas em buffer por no máximo quatro horas (no horário do evento),
pois um clique com três horas de atraso pode corresponder a uma impressão feita há quatro horas (ou
seja, três horas de atraso + atraso de até uma hora entre a impressão e clique). • Por outro

lado, os cliques precisam ser armazenados em buffer por no máximo duas horas (no horário do evento),
pois uma impressão com atraso de duas horas pode corresponder a um clique recebido há duas horas.

Junções de streaming | 251


Machine Translated by Google

Figura 8-13. O streaming estruturado calcula automaticamente os limites para limpeza de estado usando
atrasos de marca d'água e condições de intervalo de tempo

Existem alguns pontos-chave a serem lembrados sobre junções internas:

• Para junções internas, a especificação de marca d'água e restrições de tempo de evento são opcionais.
Em outras palavras, correndo o risco de um estado potencialmente ilimitado, você pode optar por não
especificá-los. Somente quando ambos forem especificados você obterá a limpeza do estado.

• Semelhante às garantias fornecidas pela marca d’água em agregações, um atraso de marca d’água de
duas horas garante que o mecanismo nunca irá descartar ou não corresponder a quaisquer dados com
menos de duas horas de atraso, mas os dados com atraso de mais de duas horas podem ou podem não
seja processado.

Junções externas com marca

d'água A junção interna anterior produzirá apenas os anúncios para os quais ambos os eventos foram
recebidos. Em outras palavras, os anúncios que não receberam cliques não serão relatados.
Em vez disso, você pode querer que todas as impressões de anúncios sejam relatadas, com ou sem os dados
de cliques associados, para permitir análises adicionais posteriormente (por exemplo, taxas de cliques). Isso
nos leva às junções externas stream-stream. Tudo que você precisa fazer para implementar isso é especificar
o tipo de junção externa:

# Em Python #
Junção externa esquerda com condições de intervalo de tempo
(impressionsWithWatermark.join(clicksWithWatermark,
expr("""
clickAdId = impressionAdId AND clickTime
BETWEEN impressionTime AND impressionTime + intervalo 1 hora"""), "leftOuter")) # única alteração:
defina o tipo de junção externa

// Em Scala
// Junção externa esquerda com condições de intervalo de tempo

252 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

impressõesWithWatermark.join (cliquesComWatermark,
expr("""
clickAdId = impressionAdId AND
clickTime BETWEEN impressionTime AND impressionTime + intervalo 1 hora"""),
"leftOuter") // Apenas alteração: defina o tipo de junção externa

Como esperado de junções externas, esta consulta começará a gerar saída para cada impressão,
com ou sem (isto é, usando NULLs) os dados de clique. Entretanto, há alguns pontos adicionais a
serem observados sobre junções externas:

• Ao contrário das junções internas, o atraso da marca d'água e as restrições de tempo de evento
não são opcionais para junções externas. Isso ocorre porque para gerar os resultados NULL ,
o mecanismo deve saber quando um evento não irá corresponder a mais nada no futuro. Para
resultados corretos de junção externa e limpeza de estado, a marca d'água e as restrições de
tempo do evento devem ser especificadas.

• Conseqüentemente, os resultados externos NULL serão gerados com atraso, pois o mecanismo
terá que esperar um pouco para garantir que não houve nem haveria quaisquer correspondências.
Esse atraso é o tempo máximo de armazenamento em buffer (em relação ao tempo do evento)
calculado pelo mecanismo para cada evento, conforme discutido na seção anterior (ou seja,
quatro horas para impressões e duas horas para cliques).

Computações com estado arbitrário


Muitos casos de uso exigem uma lógica mais complicada do que as operações SQL que discutimos
até agora. Por exemplo, digamos que você queira acompanhar os status (por exemplo, conectado,
ocupado, inativo) dos usuários rastreando suas atividades (por exemplo, cliques) em tempo real.
Para construir esse pipeline de processamento de fluxo, você terá que rastrear o histórico de
atividades de cada usuário como um estado com estrutura de dados arbitrária e aplicar continuamente
alterações arbitrariamente complexas na estrutura de dados com base nas ações do usuário. A
operação mapGroupsWithState() e sua contraparte mais flexível flatMapGroupsWithState() são
projetadas para casos de uso analíticos complexos.

A partir do Spark 3.0, essas duas operações estão disponíveis apenas em Scala e
Java.

Nesta seção, começaremos com um exemplo simples com mapGroupsWithState() para ilustrar as
quatro etapas principais para modelar dados de estado personalizados e definir operações
personalizadas neles. Em seguida, discutiremos o conceito de tempos limite e como você pode usá-
los para expirar um estado que não foi atualizado há algum tempo. Terminaremos com
flatMapGroupsWithState(), que oferece ainda mais flexibilidade.

Computações arbitrárias com estado | 253


Machine Translated by Google

Modelando operações arbitrárias com estado com mapGroupsWithState()

O estado com um esquema arbitrário e transformações arbitrárias no estado é modelado como uma função
definida pelo usuário que usa a versão anterior do valor do estado e novos dados como entradas e gera o estado
atualizado e o resultado calculado como saídas. Programaticamente em Scala, você terá que definir uma função
com a seguinte assinatura (K, V, S e U são tipos de dados, conforme explicado em breve):

// Em Scala
def arbitráriaStateUpdateFunction( chave:
K,
newDataForKey: Iterator[V],
previousStateForKey: GroupState[S]
): VOCÊ

Esta função é fornecida para uma consulta de streaming usando as operações groupByKey() e
mapGroupsWithState(), como segue:

// Em Scala
val inputDataset: Dataset[V] = // entrada de streaming Dataset

inputDataset .groupByKey(keyFunction) // keyFunction() gera chave de


input .mapGroupsWithState(arbitraryStateUpdateFunction)

Quando esta consulta de streaming for iniciada, em cada microlote o Spark chamará esta
arbitráriaStateUpdateFunction() para cada chave exclusiva nos dados do microlote.
Vamos dar uma olhada mais de perto em quais são os parâmetros e com quais valores de parâmetro o Spark
chamará a função:

chave:
KK é o tipo de dados das chaves comuns definidas no estado e na entrada. O Spark chamará essa função
para cada chave exclusiva nos dados.

newDataForKey: Iterador[V]
V é o tipo de dados do conjunto de dados de entrada. Quando o Spark chama esta função para uma chave,
este parâmetro terá todos os novos dados de entrada correspondentes a essa chave. Observe que a ordem
em que os objetos de dados de entrada estarão presentes no iterador não está definida.

anteriorStateForKey: GroupState[S]
S é o tipo de dados do estado arbitrário que você irá manter, e Group State[S] é um objeto wrapper digitado
que fornece métodos para acessar e gerenciar o valor do estado. Quando o Spark chama essa função para
uma chave, esse objeto fornecerá o valor do estado definido na vez anterior em que o Spark chamou essa
função para essa chave (ou seja, para um dos microlotes anteriores).

254 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

você

U é o tipo de dados da saída da função.

Existem alguns parâmetros adicionais que você deve fornecer. Todos os tipos (K,
V, S, U) devem ser codificáveis pelos codificadores do Spark SQL. Da mesma
forma, em mapGroupsWithState(), você deve fornecer os codificadores digitados
para S e U implicitamente em Scala ou explicitamente em Java. Consulte
“Codificadores de conjunto de dados” na página 168 no Capítulo 6 para obter mais
detalhes.

Vamos examinar como expressar a função de atualização de estado desejada neste formato com um exemplo.
Digamos que queremos entender o comportamento do usuário com base em suas ações. Conceitualmente é
bastante simples: em cada microlote, para cada usuário ativo, usaremos as novas ações realizadas pelo usuário e
atualizaremos o “status” do usuário. Programaticamente, podemos definir a função de atualização de estado com
as seguintes etapas:

1. Defina os tipos de dados. Precisamos definir os tipos exatos de K, V, S e U. Neste


caso, usaremos o seguinte: a. Dados

de entrada (V) = classe de caso UserAction(userId: String, action:


Corda)

b. Chaves (K) = String (ou seja, o userId) c. Estado (S)

= classe de caso UserStatus(userId: String, ativo: Boolean)

d. Saída (U) = UserStatus, pois queremos gerar o status mais recente do usuário

Observe que todos esses tipos de dados são suportados em codificadores.

2. Defina a função. Com base nos tipos escolhidos, vamos traduzir a ideia conceitual em código. Quando esta
função é chamada com novas ações do usuário, há duas situações principais que precisamos tratar: se existe
um estado anterior (ou seja, status anterior do usuário) para aquela chave (ou seja, userId) ou não. Dessa
forma, inicializaremos o status do usuário ou atualizaremos o status existente com as novas ações.
Atualizaremos explicitamente o estado com a nova contagem em execução e, finalmente, retornaremos o par
userId-userStatus atualizado:

// No Scala
importar org.apache.spark.sql.streaming._

def updateUserStatus( userId:


String, newActions:
Iterator[UserAction], estado:
GroupState[UserStatus]): UserStatus = {

val userStatus = state.getOption.getOrElse {


novo UserStatus(userId, falso)

Computações arbitrárias com estado | 255


Machine Translated by Google

} newActions.foreach { ação =>


userStatus.updateWith (ação)

} state.update(userStatus)
retornar userStatus
}

3. Aplique a função nas ações. Agruparemos o conjunto de dados de ações de entrada usando
groupByKey() e então aplicaremos a função updateUserStatus usando mapGroupsWithState():

// Em Scala
val userActions: Dataset[UserAction] = val ...
lastStatuses =
userActions .groupByKey(userAction =>
userAction.userId) .mapGroupsWithState(updateUserStatus _)

Assim que iniciarmos esta consulta de streaming com saída do console, veremos os status atualizados
do usuário sendo impressos.

Antes de passarmos para tópicos mais avançados, há alguns pontos importantes a serem lembrados:

• Quando a função é chamada, não há uma ordem bem definida para os registros de entrada no novo
iterador de dados (por exemplo, newActions). Se você precisar atualizar o estado com os registros
de entrada em uma ordem específica (por exemplo, na ordem em que as ações foram executadas),
então você terá que reordená-los explicitamente (por exemplo, com base no carimbo de data/hora
do evento ou em alguma outra ordem). EU IA). Na verdade, se houver a possibilidade de que as
ações possam ser lidas fora de ordem na fonte, então você deve considerar a possibilidade de que
um microlote futuro possa receber dados que devem ser processados antes dos dados do lote
atual. Nesse caso, você deve armazenar os registros em buffer como parte do estado.

• Em um microlote, a função será chamada em uma chave apenas uma vez se o microlote tiver dados
para essa chave. Por exemplo, se um usuário ficar inativo e não fornecer novas ações por um longo
período, então, por padrão, a função não será chamada por um longo período. Se você quiser
atualizar ou remover o estado com base na inatividade de um usuário durante um longo período,
você terá que usar tempos limite, que discutiremos na próxima seção.

• A saída de mapGroupsWithState() é assumida pelo mecanismo de processamento incremental como


registros de chave/valor atualizados continuamente, semelhante à saída de agregações. Isso limita
quais operações são suportadas na consulta após mapGroupsWithState() e quais coletores são
suportados. Por exemplo, não há suporte para anexar a saída em arquivos. Se você quiser aplicar
estado arbitrário

256 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

operações com maior flexibilidade, então você deve usar flatMapGroupsWith State(). Discutiremos isso
após o tempo limite.

Usando tempos limite para gerenciar grupos inativos No

exemplo anterior de acompanhamento de sessões de usuários ativos, à medida que mais usuários se tornam
ativos, o número de chaves no estado continuará aumentando, assim como a memória usada pelo estado.
Agora, em um cenário real, os usuários provavelmente não permanecerão ativos o tempo todo. Pode não ser
muito útil manter o status dos usuários inativos no estado, pois ele não mudará novamente até que esses
usuários se tornem ativos novamente. Portanto, podemos querer descartar explicitamente todas as
informações de usuários inativos. No entanto, um usuário não pode executar explicitamente nenhuma ação
para se tornar inativo (por exemplo, fazer logoff explicitamente) e poderemos ter que definir inatividade como
a falta de qualquer ação durante um determinado período. Isso se torna complicado de codificar na função,
pois a função não é chamada para um usuário até que haja novas ações desse usuário.

Para codificar a inatividade baseada em tempo, mapGroupsWithState() suporta tempos limite definidos da
seguinte forma:

• Cada vez que a função é chamada em uma tecla, um tempo limite pode ser definido na tecla com base
em uma duração ou em um carimbo de data/hora

limite. • Se essa chave não receber nenhum dado, de modo que a condição de tempo limite seja atendida,
a chave será marcada como “tempo limite”. O próximo microlote chamará a função nesta chave expirada,
mesmo que não haja dados para essa chave nesse microlote. Nesta chamada de função especial, o
novo iterador de dados de entrada estará vazio (já que não há dados novos) e GroupState.hasTimedOut()
retornará verdadeiro. Esta é a melhor forma de identificar dentro da função se a chamada foi devido a
novos dados ou a um tempo limite.

Existem dois tipos de tempos limite, com base em nossas duas noções de tempo: tempo de processamento e
tempo de evento. O tempo limite de processamento é o mais simples de usar dos dois, então começaremos
com ele.

Tempos limite de tempo de

processamento Os tempos limites de tempo de processamento são baseados na hora do sistema (também
conhecida como hora do relógio de parede) da máquina que executa a consulta de streaming e são definidos
da seguinte forma: se uma chave recebeu dados pela última vez no carimbo de data/hora do sistema T, e o
atual timestamp for maior que (T + <tempo limite>), então a função será chamada novamente com um novo
iterador de dados vazio.

Vamos investigar como usar tempos limite atualizando nosso exemplo de usuário para remover o estado de
um usuário com base em uma hora de inatividade. Faremos três alterações:

Computações arbitrárias com estado | 257


Machine Translated by Google

• Em mapGroupsWithState(), especificaremos o tempo limite como GroupStateTime


out.ProcessingTimeTimeout.
• Na função de atualização de estado, antes de atualizar o estado com novos dados, temos que verificar
se o estado expirou ou não. Conseqüentemente, atualizaremos ou removeremos o estado.

• Além disso, toda vez que atualizarmos o estado com novos dados, definiremos o tempo limite
duração.

Aqui está o código atualizado:

// Em Scala
def updateUserStatus( userId:
String, newActions:
Iterator[UserAction], estado:
GroupState[UserStatus]): UserStatus = {

if (!state.hasTimedOut) { val // Não foi chamado devido ao tempo limite


userStatus = state.getOption.getOrElse {
novo UserStatus(userId, falso)

} newActions.foreach { action => userStatus.updateWith(action) }


state.update(userStatus)
state.setTimeoutDuration("1 hora") // Definir duração do tempo limite
return userStatus

} else
{ val userStatus = state.get()
state.remove() // Remove o estado quando o tempo
expirar return userStatus.asInactive() // Retorna o status do usuário inativo
}
}

val últimosStatus = userActions


.groupByKey(userAction =>
userAction.userId) .mapGroupsWithState(
GroupStateTimeout.ProcessingTimeTimeout)
( atualizaçãoUserStatus _)

Esta consulta limpará automaticamente o estado dos usuários para os quais a consulta não processou
nenhum dado por mais de uma hora. No entanto, há alguns pontos a serem observados sobre os tempos
limite:

• O timeout definido pela última chamada à função é automaticamente cancelado quando a função é
chamada novamente, seja para os novos dados recebidos ou para o timeout.
Portanto, sempre que a função é chamada, a duração do tempo limite ou carimbo de data/hora
precisa ser definido explicitamente para ativar o

tempo limite. • Como os tempos limite são processados durante os microlotes, o tempo de sua execução
é impreciso e depende muito do intervalo de disparo e

258 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

tempos de processamento de microlotes. Portanto, não é aconselhável usar intervalos para controle
preciso do tempo.

• Embora seja fácil raciocinar sobre os tempos limite de processamento, eles não são resistentes a
lentidão e tempos de inatividade. Se a consulta de streaming sofrer um tempo de inatividade de mais
de uma hora, após a reinicialização, todas as chaves no estado atingirão o tempo limite porque mais
de uma hora se passou desde que cada chave recebeu dados. Tempos limite em grande escala
semelhantes podem ocorrer se a consulta processar os dados mais lentamente do que chegam à
origem (por exemplo, se os dados estiverem chegando e sendo armazenados em buffer no Kafka).
Por exemplo, se o tempo limite for de cinco minutos, uma queda repentina na taxa de processamento
(ou um aumento na taxa de chegada de dados) que cause um atraso de cinco minutos poderá produzir
tempos limite falsos. Para evitar tais problemas, podemos usar um tempo limite de evento, que discutiremos a seguir.

Tempo limite do evento

Em vez da hora do relógio do sistema, um tempo limite de evento é baseado na hora do evento nos dados
(semelhante às agregações baseadas em tempo) e em uma marca d'água definida na hora do evento. Se
uma chave estiver configurada com um carimbo de data/hora de tempo limite específico de T (ou seja, não
uma duração), então essa chave expirará quando a marca d'água exceder T se nenhum novo dado tiver
sido recebido para essa chave desde a última vez que a função foi chamada . Lembre-se de que a marca
d’água é um limite móvel que fica atrasado em relação ao tempo máximo do evento visto durante o
processamento dos dados. Conseqüentemente, diferentemente da hora do sistema, a marca d'água avança
no tempo na mesma taxa em que os dados são processados. Isso significa que (ao contrário dos tempos
limite de processamento) qualquer lentidão ou tempo de inatividade no processamento de consultas não causará erros falsos.
tempos limite.

Vamos modificar nosso exemplo para usar um tempo limite de evento. Além das alterações que já fizemos
para usar o tempo limite de processamento, faremos as seguintes alterações:

• Definir marcas d'água no Dataset de entrada (suponha que a classe UserAction possua um campo
eventTimestamp ). Lembre-se de que o limite da marca d'água representa o período aceitável pelo
qual os dados de entrada podem atrasar e ficar fora de ordem.

• Atualize mapGroupsWithState() para usar EventTimeTimeout. • Atualize a

função para definir o carimbo de data/hora limite em que o tempo limite ocorrerá. Observe que os tempos
limite do evento não permitem definir uma duração de tempo limite, como os tempos limites do tempo
de processamento. Discutiremos o motivo disso mais tarde. Neste exemplo, calcularemos esse tempo
limite como a marca d’água atual mais uma hora.

Aqui está o exemplo atualizado:

Computações arbitrárias com estado | 259


Machine Translated by Google

// Em Scala
def updateUserStatus( userId:
String, newActions:
Iterator[UserAction], estado:
GroupState[UserStatus]):UserStatus = {

if (!state.hasTimedOut) { // Não foi chamado devido ao tempo limite val


userStatus = if (state.getOption.getOrElse {
novo status do usuário()

} newActions.foreach { ação => userStatus.updateWith(action) }


state.update(userStatus)

// Defina o carimbo de data e hora limite para a marca d'água atual + 1 hora
state.setTimeoutTimestamp(state.getCurrentWatermarkMs, "1 hour") return
userStatus } else { val
userStatus
= state.get() state.remove() return

userStatus.asInactive() }
}

val últimosStatus = userActions


.withWatermark("eventTimestamp", "10
minutos") .groupByKey(userAction =>

userAction.userId) .mapGroupsWithState( GroupStateTimeout.EventTimeTimeout)( updateUserStatus _)

Esta consulta será muito mais robusta a tempos limite falsos causados por reinicializações e atrasos no
processamento.

Aqui estão alguns pontos a serem observados sobre os tempos limite de tempo do evento:

• Ao contrário do exemplo anterior com tempos limite de processamento, usamos


GroupState.setTimeoutTimestamp() em vez de GroupState.setTimeoutDura tion(). Isso ocorre porque com
os tempos limite de processamento a duração é suficiente para calcular o carimbo de data/hora futuro
exato (isto é, hora atual do sistema + duração especificada) quando o tempo limite ocorreria, mas esse não
é o caso dos tempos limites de tempo de evento. Diferentes aplicativos podem querer usar estratégias
diferentes para calcular o carimbo de data/hora limite. Neste exemplo, simplesmente calculamos com base
na marca d’água atual, mas um aplicativo diferente pode optar por calcular o carimbo de data/hora de
tempo limite de uma chave com base no carimbo de data/hora máximo do evento visto para essa chave
(rastreado e salvo como parte do estado). .

• O carimbo de data/hora do tempo limite deve ser definido com um valor maior que a marca d’água atual.
Isso ocorre porque espera-se que o tempo limite aconteça quando o carimbo de data/hora ultrapassar a
marca d’água, portanto, é ilógico definir o carimbo de data/hora para um valor já maior que a marca d'água
atual.

260 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

Antes de prosseguirmos com os tempos limite, uma última coisa a lembrar é que você pode usar esses
mecanismos de tempo limite para um processamento mais criativo do que os tempos limites de duração
fixa. Por exemplo, você pode implementar uma tarefa aproximadamente periódica (digamos, a cada hora)
no estado salvando o carimbo de data/hora de execução da última tarefa no estado e usando-o para definir
a duração do tempo limite do tempo de processamento, conforme mostrado neste trecho de código:

// No Scala
timeoutDurationMs = lastTaskTimstampMs + periodIntervalMs -
groupState.getCurrentProcessingTimeMs()

Generalização com atMapGroupsWithState()


Existem duas limitações principais com mapGroupsWithState() que podem limitar a flexibilidade que
queremos para implementar casos de uso mais complexos (por exemplo, sessões encadeadas):

• Cada vez que mapGroupsWithState() é chamado, você deve retornar um e somente um registro. Para
algumas aplicações, em alguns gatilhos, você pode não querer gerar nada.

• Com mapGroupsWithState(), devido à falta de mais informações sobre a função de atualização de


estado opaco, o mecanismo assume que os registros gerados são pares de dados chave/valor
atualizados. Conseqüentemente, ele raciocina sobre operações downstream e permite ou não
algumas delas. Por exemplo, o DataFrame gerado usando mapGroupsWithState() não pode ser
gravado em modo anexado a arquivos.
No entanto, alguns aplicativos podem querer gerar registros que podem ser considerados como
anexos.

flatMapGroupsWithState() supera essas limitações, ao custo de uma sintaxe um


pouco mais complexa. Tem duas diferenças de mapGroupsWithState():

• O tipo de retorno é um iterador, em vez de um único objeto. Isso permite que a função
para retornar qualquer número de registros ou, se necessário, nenhum registro.

• É necessário outro parâmetro, chamado modo de saída do operador (não deve ser confundido com os
modos de saída da consulta que discutimos anteriormente neste capítulo), que define se os registros
de saída são novos registros que podem ser anexados (Output Mode.Append) ou atualizados .
registros de chave/valor (OutputMode.Update).

Para ilustrar o uso dessa função, vamos estender nosso exemplo de rastreamento de usuários (removemos
os tempos limite para manter o código simples). Por exemplo, se quisermos gerar alertas apenas para
determinadas alterações do usuário e quisermos gravar os alertas de saída em arquivos, podemos fazer o
seguinte:

// Em Scala
def getUserAlerts( userId:
String, newActions:
Iterator[UserAction],

Computações arbitrárias com estado | 261


Machine Translated by Google

estado: GroupState[UserStatus]): Iterator[UserAlert] = {

val userStatus = state.getOption.getOrElse {


novo UserStatus(userId, falso)

} newActions.foreach { ação =>


userStatus.updateWith(action) }

state.update(userStatus)

// Gere qualquer número de alertas


return userStatus.generateAlerts().toIterator }

val userAlerts = userActions


.groupByKey (userAction =>

userAction.userId) .flatMapGroupsWithState
( OutputMode.Append, GroupStateTimeout.NoTimeout) ( getUserAlerts)

Ajuste de desempenho
O Structured Streaming usa o mecanismo Spark SQL e, portanto, pode ser ajustado com os mesmos parâmetros
discutidos para Spark SQL nos Capítulos 5 e 7. No entanto, diferentemente dos trabalhos em lote que podem processar
gigabytes a terabytes de dados, os trabalhos em microlote geralmente processam dados muito menores. volumes de
dados. Conseqüentemente, um cluster Spark que executa consultas de streaming geralmente precisa ser ajustado de
maneira um pouco diferente. Aqui estão algumas considerações a serem lembradas:

Provisionamento de recursos de cluster


Como os clusters Spark que executam consultas de streaming funcionarão 24 horas por dia, 7 dias por semana,
é importante provisionar os recursos de maneira adequada. O provisionamento insuficiente dos recursos pode
fazer com que as consultas de streaming fiquem para trás (com microlotes demorando cada vez mais), enquanto
o provisionamento excessivo (por exemplo, núcleos alocados, mas não utilizados) pode causar custos
desnecessários. Além disso, a alocação deve ser feita com base na natureza das consultas de streaming:
consultas sem estado geralmente precisam de mais núcleos, e consultas com estado geralmente precisam de
mais memória.

Número de partições para embaralhamento


Para consultas de streaming estruturado, o número de partições embaralhadas geralmente precisa ser definido
muito mais baixo do que para a maioria das consultas em lote – dividir demais a computação aumenta as despesas
gerais e reduz o rendimento. Além disso, embaralhamentos devido a operações com estado têm sobrecargas de
tarefas significativamente maiores devido ao checkpoint.
Portanto, para consultas de streaming com operações com estado e intervalos de disparo de alguns segundos a
minutos, é recomendado ajustar o número de embaralhamento

262 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

partições do valor padrão de 200 para no máximo duas a três vezes o número de núcleos alocados.

Definir limites de taxa de origem para estabilidade


Depois que os recursos e as configurações alocados tiverem sido otimizados para as taxas de dados de entrada
esperadas de uma consulta, é possível que aumentos repentinos nas taxas de dados possam gerar trabalhos
inesperadamente grandes e subsequente instabilidade. Além da abordagem dispendiosa de superprovisionamento,
você pode se proteger contra instabilidade usando limites de taxa de origem. Definir limites nas fontes suportadas
(por exemplo, Kafka e arquivos) evita que uma consulta consuma muitos dados em um único microlote. Os dados
de pico permanecerão armazenados em buffer na origem e a consulta eventualmente será atualizada. No entanto,
observe o seguinte:

• Definir o limite muito baixo pode fazer com que a consulta subutilize os recursos alocados e fique atrás da
taxa de entrada.

• Os limites não protegem eficazmente contra aumentos sustentados na taxa de entrada.


Embora a estabilidade seja mantida, o volume de dados armazenados em buffer e não processados crescerá
indefinidamente na origem, assim como as latências de ponta a ponta.

Várias consultas de streaming no mesmo aplicativo Spark


A execução de várias consultas de streaming no mesmo SparkContext ou SparkSession pode levar a um
compartilhamento de recursos refinado. No entanto:

• A execução contínua de cada consulta utiliza recursos do driver Spark (ou seja, a JVM onde ela está sendo
executada). Isso limita o número de consultas que o driver pode executar simultaneamente. Atingir esses
limites pode causar gargalos no agendamento de tarefas (isto é, subutilizar os executores) ou exceder os
limites de memória.

• Você pode garantir uma alocação de recursos mais justa entre consultas no mesmo contexto configurando-as
para serem executadas em pools de agendadores separados. Defina a propriedade local de thread do
SparkContext spark.scheduler.pool como um valor de string diferente para cada fluxo:

// Em Scala //
Executa streaming query1 no agendador pool1
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "pool1")
df.writeStream.queryName("query1").format("parquet").start( caminho1)

// Executa streaming query2 no agendador pool2


spark.sparkContext.setLocalProperty("spark.scheduler.pool", "pool2")
df.writeStream.queryName("query2").format("parquet").start(path2)

Ajuste de desempenho | 263


Machine Translated by Google

# Em Python
# Execute streaming query1 no agendador pool1
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "pool1")
df.writeStream.queryName("query1").format("parquet").start(path1)

# Executa streaming query2 no agendador pool2


spark.sparkContext.setLocalProperty("spark.scheduler.pool", "pool2")
df.writeStream.queryName("query2").format("parquet").start(path2)

Resumo

Este capítulo explorou a escrita de consultas de streaming estruturado usando a API DataFrame.
Especificamente, discutimos:

• A filosofia central do Streaming Estruturado e o modelo de processamento de


tratando fluxos de dados de entrada como tabelas ilimitadas

• As principais etapas para definir, iniciar, reiniciar e monitorar consultas de streaming •

Como usar várias fontes e coletores de streaming integrados e gravar consultas personalizadas
coletores de streaming

• Como usar e ajustar operações com estado gerenciadas, como agregações de streaming e junções
fluxo-stream. • Técnicas para

expressar computações com estado personalizadas.

Ao trabalhar nos trechos de código do capítulo e nos blocos de anotações no repositório GitHub do livro,
você terá uma ideia de como usar o streaming estruturado de maneira eficaz. No próximo capítulo,
exploraremos como você pode gerenciar dados estruturados lidos e gravados simultaneamente em cargas
de trabalho em lote e streaming.

264 | Capítulo 8: Streaming Estruturado


Machine Translated by Google

CAPÍTULO 9

Construindo Data Lakes Confiáveis com Apache Spark

Nos capítulos anteriores, você aprendeu como usar o Apache Spark de maneira fácil e eficaz para construir
pipelines de processamento de dados escalonáveis e de alto desempenho. No entanto, na prática,
expressar a lógica de processamento resolve apenas metade do problema ponta a ponta da construção de
um pipeline. Para um engenheiro de dados, cientista de dados ou analista de dados, o objetivo final da
construção de pipelines é consultar os dados processados e obter insights deles. A escolha da solução de
armazenamento determina a robustez e o desempenho de ponta a ponta (ou seja, dos dados brutos aos
insights) do pipeline de dados.

Neste capítulo, discutiremos primeiro os principais recursos de uma solução de armazenamento que você
precisa observar. Em seguida, discutiremos duas grandes classes de soluções de armazenamento, bancos
de dados e data lakes, e como usar o Apache Spark com eles. Por fim, apresentaremos a próxima onda de
soluções de armazenamento, chamadas lakehouses, e exploraremos alguns dos novos mecanismos de
processamento de código aberto neste espaço.

A importância de uma solução de armazenamento ideal

Aqui estão algumas das propriedades desejadas em uma solução de armazenamento:

Escalabilidade e desempenho
A solução de armazenamento deve ser capaz de se adaptar ao volume de dados e fornecer a taxa de
transferência e a latência de leitura/gravação exigidas pela carga de trabalho.

Suporte a transações
Cargas de trabalho complexas geralmente leem e gravam dados simultaneamente, portanto, o suporte
para transações ACID é essencial para garantir a qualidade dos resultados finais.

265
Machine Translated by Google

Suporte para diversos formatos de dados A


solução de armazenamento deve ser capaz de armazenar dados não estruturados (por exemplo, arquivos de texto
como logs brutos), dados semiestruturados (por exemplo, dados JSON) e dados estruturados (por exemplo, dados
tabulares).

Suporte para diversas cargas de trabalho


A solução de armazenamento deve ser capaz de suportar uma ampla gama de cargas de trabalho empresariais,
incluindo:

• Cargas de trabalho SQL, como análises de BI tradicionais •

Cargas de trabalho em lote, como trabalhos ETL tradicionais que processam dados não estruturados brutos •

Cargas de trabalho de streaming, como monitoramento e alertas em tempo real • Cargas

de trabalho de ML e IA, como recomendações e previsões de rotatividade

Abertura O
suporte a uma ampla variedade de cargas de trabalho geralmente exige que os dados sejam armazenados em
formatos de dados abertos. APIs padrão permitem que os dados sejam acessados a partir de uma variedade de
ferramentas e mecanismos. Isso permite que a empresa use as ferramentas mais adequadas para cada tipo de
carga de trabalho e tome as melhores decisões de negócios.

Ao longo do tempo, foram propostos diferentes tipos de soluções de armazenamento, cada uma com as suas vantagens
e desvantagens únicas no que diz respeito a estas propriedades. Neste capítulo, exploraremos como as soluções de
armazenamento disponíveis evoluíram de bancos de dados para data lakes e como usar o Apache Spark com cada um
deles. Em seguida, voltaremos nossa atenção para a próxima geração de soluções de armazenamento, muitas vezes
chamadas de data lakehouses, que podem fornecer o melhor dos dois mundos: a escalabilidade e a flexibilidade dos data
lakes com as garantias transacionais dos bancos de dados.

bancos de dados

Por muitas décadas, os bancos de dados têm sido a solução mais confiável para a construção de data warehouses para
armazenar dados críticos aos negócios. Nesta seção, exploraremos a arquitetura de bancos de dados e suas cargas de
trabalho e como usar o Apache Spark para cargas de trabalho analíticas em bancos de dados. Encerraremos esta seção
com uma discussão sobre as limitações dos bancos de dados no suporte a cargas de trabalho modernas não SQL.

Uma breve introdução aos bancos de dados

Os bancos de dados são projetados para armazenar dados estruturados como tabelas, que podem ser lidas por meio de
consultas SQL. Os dados devem aderir a um esquema estrito, que permite que um sistema de gerenciamento de banco
de dados co-otimize fortemente o armazenamento e processamento de dados. Ou seja, eles acoplam fortemente o layout
interno dos dados e índices em arquivos em disco com seus mecanismos de processamento de consulta altamente
otimizados, fornecendo assim cálculos muito rápidos em

266 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

os dados armazenados juntamente com fortes garantias ACID transacionais em todas as operações de leitura/gravação.

As cargas de trabalho SQL em bancos de dados podem ser amplamente classificadas em duas categorias, como segue:

Cargas de trabalho de processamento de transações on-line


(OLTP) Assim como as transações de contas bancárias, as cargas de trabalho de OLTP são normalmente consultas
simples de alta simultaneidade e baixa latência que leem ou atualizam alguns registros por vez.

Processamento analítico online (OLAP)


Cargas de trabalho OLAP, como relatórios periódicos, normalmente são consultas complexas (envolvendo
agregações e junções) que exigem varreduras de alto rendimento em muitos registros.

É importante observar que o Apache Spark é um mecanismo de consulta projetado principalmente para cargas de trabalho
OLAP, não para cargas de trabalho OLTP. Portanto, no restante do capítulo focaremos nossa discussão em soluções de
armazenamento para cargas de trabalho analíticas. A seguir, vamos ver como o Apache Spark pode ser usado para ler e
gravar em bancos de dados.

Lendo e gravando em bancos de dados usando o Apache Spark Graças ao crescente ecossistema

de conectores, o Apache Spark pode se conectar a uma ampla variedade de bancos de dados para ler e gravar dados.
Para bancos de dados que possuem drivers JDBC (por exemplo, PostgreSQL, MySQL), você pode usar a fonte de dados
JDBC integrada junto com os jars do driver JDBC apropriados para acessar os dados. Para muitos outros bancos de dados
modernos (por exemplo, Azure Cosmos DB, Snowflake), existem conectores dedicados que você pode invocar usando o
nome de formato apropriado. Vários exemplos foram discutidos em detalhes no Capítulo 5. Isso torna muito fácil aumentar
seus data warehouses e bancos de dados com cargas de trabalho e casos de uso baseados no Apache Spark.

Limitações de bancos de dados

Desde o século passado, bancos de dados e consultas SQL são conhecidos como excelentes soluções de construção
para cargas de trabalho de BI. No entanto, na última década assistimos a duas novas tendências importantes nas cargas
de trabalho analíticas:

Crescimento no tamanho

dos dados Com o advento do big data, tem havido uma tendência global na indústria de medir e coletar tudo
(visualizações de páginas, cliques, etc.) para entender tendências e comportamentos dos usuários. Como resultado,
a quantidade de dados recolhidos por qualquer empresa ou organização aumentou de gigabytes há algumas
décadas para terabytes e petabytes hoje.

Crescimento na diversidade de análises Junto


com o aumento na coleta de dados, há necessidade de insights mais profundos.
Isso levou a um crescimento explosivo de análises complexas, como aprendizado de máquina e aprendizado
profundo.

Bancos de dados | 267


Machine Translated by Google

As bases de dados revelaram-se bastante inadequadas para acomodar estas novas tendências, devido às seguintes
limitações:

Os bancos de dados são extremamente caros para serem


expandidos Embora os bancos de dados sejam extremamente eficientes no processamento de dados em uma
única máquina, a taxa de crescimento dos volumes de dados ultrapassou em muito o crescimento nas capacidades
de desempenho de uma única máquina. O único caminho a seguir para os mecanismos de processamento é
expandir, ou seja, usar várias máquinas para processar dados em paralelo.
No entanto, a maioria dos bancos de dados, especialmente os de código aberto, não são projetados para serem
dimensionados para realizar processamento distribuído. As poucas soluções de banco de dados industriais que
conseguem acompanhar remotamente os requisitos de processamento tendem a ser soluções proprietárias
executadas em hardware especializado e, portanto, são muito caras para adquirir e manter.

Os bancos de dados não suportam muito bem análises não baseadas em SQL. Os
bancos de dados armazenam dados em formatos complexos (geralmente proprietários) que normalmente são
altamente otimizados apenas para leitura pelo mecanismo de processamento SQL do banco de dados. Isso
significa que outras ferramentas de processamento, como aprendizado de máquina e sistemas de aprendizado
profundo, não conseguem acessar os dados de maneira eficiente (exceto pela leitura ineficiente de todos os
dados do banco de dados). Os bancos de dados também não podem ser facilmente estendidos para realizar
análises não baseadas em SQL, como aprendizado de máquina.

Essas limitações dos bancos de dados levaram ao desenvolvimento de uma abordagem completamente diferente para
armazenar dados, conhecida como data lakes.

Lagos de dados

Em contraste com a maioria dos bancos de dados, um data lake é uma solução de armazenamento distribuído que roda
em hardware comum e pode ser facilmente expandido horizontalmente. Nesta seção, começaremos com uma discussão
sobre como os data lakes satisfazem os requisitos das cargas de trabalho modernas e, em seguida, veremos como o
Apache Spark se integra aos data lakes para fazer com que as cargas de trabalho sejam dimensionadas para dados de
qualquer tamanho. Por fim, exploraremos o impacto dos sacrifícios arquitetônicos feitos pelos data lakes para alcançar
escalabilidade.

Uma breve introdução aos data lakes

A arquitetura do data lake, diferentemente da dos bancos de dados, separa o sistema de armazenamento distribuído do
sistema de computação distribuído. Isso permite que cada sistema seja ampliado conforme necessário pela carga de
trabalho. Além disso, os dados são salvos como arquivos com formatos abertos, de forma que qualquer mecanismo de
processamento possa lê-los e gravá-los usando APIs padrão. Essa ideia foi popularizada no final dos anos 2000 pelo
Hadoop File System (HDFS) do projeto Apache Hadoop, que foi fortemente inspirado no artigo de pesquisa “The Google
File System” de Sanjay Ghemawat, Howard Gobioff e Shun-Tak Leung.

268 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

As organizações constroem seus data lakes escolhendo independentemente o seguinte:

Sistema de
armazenamento Eles optam por executar o HDFS em um cluster de máquinas ou usar qualquer armazenamento de
objetos em nuvem (por exemplo, AWS S3, Azure Data Lake Storage ou Google Cloud Storage).

Formato de
arquivo Dependendo das cargas de trabalho posteriores, os dados são armazenados como arquivos em formatos
estruturados (por exemplo, Parquet, ORC), semiestruturados (por exemplo, JSON) ou às vezes até não estruturados (por
exemplo, texto, imagens, áudio, vídeo) .

Mecanismo(s) de processamento

Novamente, dependendo dos tipos de cargas de trabalho analíticas a serem executadas, um mecanismo de processamento
é escolhido. Pode ser um mecanismo de processamento em lote (por exemplo, Spark, Presto, Apache Hive), um
mecanismo de processamento de fluxo (por exemplo, Spark, Apache Flink) ou uma biblioteca de aprendizado de máquina
(por exemplo, Spark MLlib, scikit-learn, R).

Essa flexibilidade – a capacidade de escolher o sistema de armazenamento, o formato de dados abertos e o mecanismo de
processamento mais adequado à carga de trabalho em questão – é a maior vantagem dos data lakes em relação aos bancos
de dados. No geral, para as mesmas características de desempenho, os data lakes geralmente oferecem uma solução muito

mais barata que os bancos de dados. Esta vantagem fundamental levou ao crescimento explosivo do ecossistema de big data.
Na próxima seção, discutiremos como você pode usar o Apache Spark para ler e gravar formatos de arquivo comuns em
qualquer sistema de armazenamento.

Lendo e gravando em data lakes usando Apache Spark


Apache Spark é um dos melhores mecanismos de processamento para usar ao construir seu próprio data lake, porque fornece
todos os principais recursos necessários:

Suporte para diversas cargas de trabalho


O Spark fornece todas as ferramentas necessárias para lidar com uma ampla variedade de cargas de trabalho, incluindo
processamento em lote, operações ETL, cargas de trabalho SQL usando Spark SQL, processamento de fluxo usando
Streaming Estruturado (discutido no Capítulo 8) e aprendizado de máquina usando MLlib (discutido no Capítulo 10). ,
entre muitos outros.

Suporte para diversos formatos de arquivo


No Capítulo 4, exploramos em detalhes como o Spark possui suporte integrado para formatos de arquivo não estruturados,
semiestruturados e estruturados.

Suporte para diversos sistemas de arquivos


O Spark oferece suporte ao acesso a dados de qualquer sistema de armazenamento compatível com APIs FileSystem do
Hadoop. Como essa API se tornou o padrão de fato no ecossistema de big data, a maioria dos sistemas de armazenamento
em nuvem e locais fornece implementações para ela, o que significa que o Spark pode ler e gravar na maioria dos
sistemas de armazenamento.

Lagos de dados | 269


Machine Translated by Google

No entanto, para muitos sistemas de arquivos (especialmente aqueles baseados em armazenamento em nuvem,
como AWS S3), é necessário configurar o Spark para que ele possa acessar o sistema de arquivos de maneira segura.
Além disso, os sistemas de armazenamento em nuvem muitas vezes não têm a mesma semântica de operação de
arquivos esperada de um sistema de arquivos padrão (por exemplo, eventual consistência no S3), o que pode levar
a resultados inconsistentes se você não configurar o Spark adequadamente. Consulte a documentação sobre
integração na nuvem para obter detalhes.

Limitações dos lagos de dados

Os data lakes apresentam sua cota de falhas, a mais flagrante das quais é a falta de garantias transacionais.
Especificamente, os data lakes não fornecem garantias ACID
sobre:

Atomicidade e isolamento Os
mecanismos de processamento gravam dados em data lakes com tantos arquivos de maneira distribuída.
Se a operação falhar, não há nenhum mecanismo para reverter os arquivos já gravados, deixando assim para
trás dados potencialmente corrompidos (o problema é agravado quando cargas de trabalho simultâneas
modificam os dados porque é muito difícil fornecer isolamento entre arquivos sem arquivos de nível superior).
mecanismos).

Consistência A
falta de atomicidade em gravações com falha faz com que os leitores obtenham uma visão inconsistente dos
dados. Na verdade, é difícil garantir a qualidade dos dados, mesmo em dados escritos com sucesso. Por
exemplo, um problema muito comum com data lakes é a gravação acidental de arquivos de dados em um
formato e esquema inconsistentes com os dados existentes.

Para contornar essas limitações dos data lakes, os desenvolvedores empregam todos os tipos de truques.
Aqui estão alguns exemplos:

• Grandes coleções de arquivos de dados em data lakes são frequentemente “particionadas” por subdiretórios
com base no valor de uma coluna (por exemplo, uma grande tabela Hive no formato Parquet particionada por
data). Para obter modificações atômicas dos dados existentes, muitas vezes subdiretórios inteiros são
reescritos (ou seja, gravados em um diretório temporário e depois as referências trocadas) apenas para
atualizar ou excluir alguns registros. • Os cronogramas de trabalhos de

atualização de dados (por exemplo, trabalhos diários de ETL) e trabalhos de consulta de dados (por exemplo,
trabalhos de relatórios diários) são frequentemente escalonados para evitar acesso simultâneo aos dados e
quaisquer inconsistências causadas por eles.

As tentativas de eliminar tais questões práticas levaram ao desenvolvimento de novos sistemas, tais como
lakehouses.

270 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

Lakehouses: o próximo passo na evolução de


Soluções de armazenamento

O lakehouse é um novo paradigma que combina os melhores elementos de data lakes e data warehouses para cargas de
trabalho OLAP. Lakehouses são habilitados por um novo design de sistema que fornece recursos de gerenciamento de
dados semelhantes aos bancos de dados diretamente no armazenamento escalonável e de baixo custo usado para data
lakes. Mais especificamente, eles fornecem os seguintes recursos:

Suporte a transações
Semelhante aos bancos de dados, os lakehouses fornecem garantias ACID na presença de cargas de trabalho
simultâneas.

Aplicação e governança do esquema


Lakehouses evitam que dados com um esquema incorreto sejam inseridos em uma tabela e, quando necessário, o
esquema da tabela pode ser explicitamente evoluído para acomodar dados em constante mudança. O sistema deve
ser capaz de raciocinar sobre a integridade dos dados e deve ter mecanismos robustos de governação e auditoria.

Suporte para diversos tipos de dados em formatos abertos Ao


contrário dos bancos de dados, mas semelhantes aos data lakes, os lakehouses podem armazenar, refinar, analisar
e acessar todos os tipos de dados necessários para muitas novas aplicações de dados, sejam eles estruturados,
semiestruturados ou não estruturados. . Para permitir o acesso direto e eficiente de uma ampla variedade de
ferramentas, os dados devem ser armazenados em formatos abertos com APIs padronizadas para lê-los e gravá-los.

Suporte para diversas cargas de trabalho


Alimentados pela variedade de ferramentas de leitura de dados usando APIs abertas, os lakehouses permitem que
diversas cargas de trabalho operem com dados em um único repositório. Quebrar silos de dados isolados (ou seja,
vários repositórios para diferentes categorias de dados) permite que os desenvolvedores criem com mais facilidade
soluções de dados diversas e complexas, desde SQL tradicional e análise de streaming até aprendizado de máquina.

Suporte para upserts e exclusões


Casos de uso complexos, como operações de captura de dados de alteração (CDC) e de dimensão de mudança
lenta (SCD), exigem que os dados nas tabelas sejam atualizados continuamente. Lakehouses permitem que os
dados sejam excluídos e atualizados simultaneamente com garantias transacionais.

Governança de dados
Lakehouses fornecem as ferramentas com as quais você pode raciocinar sobre a integridade dos dados e auditar
todas as alterações de dados para conformidade com as políticas.

Atualmente, existem alguns sistemas de código aberto, como Apache Hudi, Apache Iceberg e Delta Lake, que podem ser
usados para construir lagos com essas propriedades. Em um momento muito

Lakehouses: o próximo passo na evolução das soluções de armazenamento | 271


Machine Translated by Google

alto nível, todos os três projetos têm uma arquitetura semelhante inspirada em princípios de banco de dados
bem conhecidos. Todos são formatos abertos de armazenamento de dados que fazem o seguinte:

• Armazene grandes volumes de dados em formatos de arquivos estruturados em sistemas de arquivos

escaláveis. • Manter um log de transações para registrar um cronograma de alterações atômicas nos dados
(muito parecido com bancos de dados).

• Use o log para definir versões dos dados da tabela e fornecer garantias de isolamento de snapshots entre
leitores e gravadores.

• Suporte à leitura e gravação em tabelas usando Apache Spark.

Dentro dessas linhas gerais, cada projeto tem características únicas em termos de APIs, desempenho e nível
de integração com APIs de fonte de dados do Apache Spark. Iremos explorá-los a seguir. Observe que todos
esses projetos estão evoluindo rapidamente e, portanto, algumas das descrições podem estar desatualizadas
no momento em que você as lê. Consulte a documentação on-line de cada projeto para obter as informações
mais atualizadas.

Apache Hudi

Inicialmente desenvolvido pela Uber Engineering, Apache Hudi — um acrônimo para Hadoop Update Delete
and Incremental — é um formato de armazenamento de dados projetado para upserts e exclusões incrementais
em dados de estilo chave/valor. Os dados são armazenados como uma combinação de formatos colunares (por
exemplo, arquivos Parquet) e formatos baseados em linhas (por exemplo, arquivos Avro para registrar alterações
incrementais em arquivos Parquet). Além dos recursos comuns mencionados anteriormente, ele suporta:

• Upserting com indexação rápida e conectável •

Publicação atômica de dados com suporte a rollback • Leitura de

alterações incrementais em uma tabela • Pontos de

salvamento para recuperação de dados

• Gerenciamento de tamanho de arquivo e layout usando

estatísticas • Compactação assíncrona de dados de linhas e colunas

Apache Iceberg

Originalmente desenvolvido na Netflix, o Apache Iceberg é outro formato de armazenamento aberto para
grandes conjuntos de dados. No entanto, ao contrário do Hudi, que se concentra na atualização de dados de
chave/valor, o Iceberg se concentra mais no armazenamento de dados de uso geral que pode ser dimensionado
para petabytes em uma única tabela e possui propriedades de evolução de esquema. Especificamente, fornece
os seguintes recursos adicionais (além dos comuns):

272 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

• Evolução do esquema adicionando, eliminando, atualizando, renomeando e reordenando colunas,


campos e/ou estruturas aninhadas

• Particionamento oculto, que secretamente cria os valores de partição para linhas


em uma mesa

• Evolução da partição, onde executa automaticamente uma operação de metadados para


atualizar o layout da tabela conforme o volume de dados ou os padrões de consulta mudam

• Viagem no tempo, que permite consultar um instantâneo de tabela específico por ID ou por
carimbo de data/hora

• Reverter para versões anteriores para corrigir erros •

Isolamento serializável, mesmo entre vários gravadores simultâneos

Lago Delta

Delta Lake é um projeto de código aberto hospedado pela Linux Foundation, desenvolvido pelos
criadores originais do Apache Spark. Semelhante aos outros, é um formato aberto de armazenamento
de dados que fornece garantias transacionais e permite a aplicação e evolução de esquemas. Ele
também oferece vários outros recursos interessantes, alguns dos quais são exclusivos.
Delta Lake suporta:

• Streaming de leitura e gravação em tabelas usando fontes de streaming estruturado


e afunda

• Operações de atualização, exclusão e mesclagem (para upserts), mesmo em Java, Scala e


APIs Python

• Evolução do esquema alterando explicitamente o esquema da tabela ou mesclando implicitamente


o esquema de um DataFrame com o da tabela durante a gravação do DataFrame. (Na verdade,
a operação de mesclagem no Delta Lake suporta sintaxe avançada para atualizações/inserções/
exclusões condicionais, atualização de todas as colunas juntas, etc., como você verá mais adiante
neste capítulo.)

• Viagem no tempo, que permite consultar um instantâneo de tabela específico por ID ou por
carimbo de data/hora

• Reverter para versões anteriores para corrigir erros •

Isolamento serializável entre vários gravadores simultâneos executando qualquer SQL,


operações em lote ou streaming

No restante deste capítulo, exploraremos como tal sistema, junto com o Apache Spark, pode ser usado
para construir uma casa no lago que forneça as propriedades mencionadas acima. Desses três
sistemas, até agora o Delta Lake tem a integração mais estreita com fontes de dados Apache Spark
(para cargas de trabalho em lote e streaming) e operações SQL (por exemplo, MERGE). Portanto,
usaremos Delta Lake como veículo para futuras explorações.

Lakehouses: o próximo passo na evolução das soluções de armazenamento | 273


Machine Translated by Google

Este projeto é denominado Delta Lake devido à sua analogia com o streaming.
Os riachos fluem para o mar para criar deltas – é aqui que todos os sedimentos
se acumulam e, portanto, onde são cultivadas as culturas valiosas. Jules S.
Damji (um de nossos co-autores) criou isso!

Construindo Lakehouses com Apache Spark e Delta Lake

Nesta seção, daremos uma olhada rápida em como Delta Lake e Apache Spark podem ser usados para
construir lakehouses. Especificamente, exploraremos o seguinte:

• Ler e gravar tabelas Delta Lake usando Apache Spark • Como Delta Lake

permite gravações simultâneas em lote e streaming com ACID


garantias

• Como Delta Lake garante melhor qualidade de dados aplicando esquema em todas as gravações,
ao mesmo tempo que permite a evolução explícita do esquema

• Construir pipelines de dados complexos usando operações de atualização, exclusão e mesclagem, todas
dos quais garantem garantias ACID

• Auditar o histórico de operações que modificaram uma tabela Delta Lake e viajar no tempo consultando
versões anteriores da tabela

Os dados que usaremos nesta seção são uma versão modificada (um subconjunto de colunas no formato
1
Parquet) dos dados públicos de empréstimos do Lending Club. Inclui todos os empréstimos financiados por

2012 a 2017. Cada registro de empréstimo inclui informações do solicitante fornecidas pelo solicitante, bem
como a situação atual do empréstimo (atual, atrasado, totalmente pago, etc.) e informações de pagamento
mais recentes.

Configurando o Apache Spark com Delta Lake

Você pode configurar o Apache Spark para vincular à biblioteca Delta Lake de uma das seguintes maneiras:

Configurar um shell interativo


Se estiver usando o Apache Spark 3.0, você poderá iniciar um shell PySpark ou Scala com Delta Lake
usando o seguinte argumento de linha de comando:

--packages io.delta:delta-core_2.12:0.7.0

Por exemplo:

pyspark --packages io.delta:delta-core_2.12:0.7.0

Se você estiver executando o Spark 2.4, deverá usar o Delta Lake 0.6.0.

1 Uma visão completa dos dados está disponível neste arquivo Excel.

274 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

Configure um projeto Scala/Java independente usando coordenadas Maven


Se quiser construir um projeto usando binários Delta Lake do repositório Maven Central, você pode
adicionar as seguintes coordenadas Maven às dependências do projeto:

<dependency>
<groupId>io.delta</groupId>
<artifactId>delta-core_2.12</artifactId>
<version>0.7.0</version>
</dependency>

Novamente, se você estiver executando o Spark 2.4, precisará usar o Delta Lake 0.6.0.

Consulte a documentação do Delta Lake para obter as informações mais


atualizadas.

Carregando dados em uma tabela Delta Lake Se você

está acostumado a construir data lakes com Apache Spark e qualquer um dos formatos de dados estruturados
(por exemplo, Parquet), então é muito fácil migrar cargas de trabalho existentes para usar o formato Delta
Lake. Tudo o que você precisa fazer é alterar todas as operações de leitura e gravação do DataFrame para
usar format("delta") em vez de format("parquet"). Vamos tentar fazer isso com alguns dos dados de
empréstimo mencionados acima, que estão disponíveis como arquivo Parquet. Primeiro vamos ler esses
dados e salvá-los como uma tabela Delta Lake:

// Em Scala //
Configurar caminho de dados de
origem val sourcePath = "/databricks-datasets/learning-spark-v2/loans/loan-
risks.snappy.parquet"

// Configurar caminho do Delta Lake


val deltaPath = "/tmp/loans_delta"

// Cria a tabela Delta com os mesmos dados de empréstimos

spark .read .format("parquet") .load(sourcePath) .write .format("delta") .save(deltaPath)

// Crie uma visualização dos dados chamada

empréstimos_delta spark .read .format("delta")

Construindo Lakehouses com Apache Spark e Delta Lake | 275


Machine Translated by Google

.load(deltaCaminho)
.createOrReplaceTempView("empréstimos_delta")

# Em Python
#Configurar o caminho dos dados de origem
sourcePath = "/databricks-datasets/learning-spark-v2/loans/
riscos de empréstimo.snappy.parquet"

# Configurar caminho do Delta Lake


deltaPath = "/tmp/loans_delta"

# Crie a tabela Delta Lake com os mesmos dados de empréstimos


(spark.read.format("parquet").load(sourcePath)
.write.format("delta").save(deltaPath))

# Crie uma visualização dos dados chamada empréstimos_delta


spark.read.format("delta").load(deltaPath).createOrReplaceTempView("loans_delta")

Agora podemos ler e explorar os dados tão facilmente quanto qualquer outra tabela:

// Em Scala/ Python

// Contagem de linhas de empréstimos

spark.sql("SELECIONE contagem(*) FROM empréstimos_delta").show()

+--------+
|contagem(1)|
+--------+
| 14705|
+--------+

// Primeiras 5 linhas da tabela de empréstimos


spark.sql("SELECIONE * FROM empréstimos_delta LIMIT 5").show()

+-------+-----------+---------+----------+
|loan_id|funded_amnt|paid_amnt|addr_state|
+-------+-----------+---------+----------+
| 0| 1000| 182,22| 1000| CA |
| 1| 361,19| 1000| 176,26| WA|
| 2| 1000| 1000,0| 1000| TX |
| 3| 249,98| OK|
| 4| PA|
+-------+-----------+---------+----------+

276 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

Carregando fluxos de dados em uma tabela Delta Lake Assim

como acontece com DataFrames estáticos, você pode modificar facilmente seus trabalhos de streaming
estruturado existentes para gravar e ler em uma tabela Delta Lake, definindo o formato como " delta".
Digamos que você tenha um fluxo de novos dados de empréstimos como um DataFrame chamado
newLoanStreamDF, que possui o mesmo esquema da tabela. Você pode anexar à tabela da seguinte maneira:

// No Scala
importar org.apache.spark.sql.streaming._

val newLoanStreamDF = ... // Streaming de DataFrame com dados de novos


...
empréstimos val checkpointDir = // Diretório para pontos de verificação de
streaming val streamingQuery = newLoanStreamDF.writeStream

.format("delta") .option("checkpointLocation",
checkpointDir) .trigger(Trigger.ProcessingTime("10
segundos")) .start(deltaPath)

# Em Python
newLoanStreamDF = #...Streaming DataFrame com dados de novos empréstimos
...
checkpointDir = # Diretório para pontos de verificação de streaming
streamingQuery =

(newLoanStreamDF.writeStream .format("delta") .option("checkpointLocation", checkpointDir) .trigger(processingTime


Com este formato, como qualquer outro, o Streaming Estruturado oferece garantias ponta a ponta
exatamente uma vez. No entanto, Delta Lake tem algumas vantagens adicionais sobre formatos tradicionais
como JSON, Parquet ou ORC:

Ele permite gravações de trabalhos em lote e de streaming na mesma tabela


Com outros formatos, os dados gravados em uma tabela a partir de um trabalho de streaming
estruturado substituirão quaisquer dados existentes na tabela. Isso ocorre porque os metadados
mantidos na tabela para garantir garantias exatamente uma vez para gravações de streaming não
levam em conta outras gravações que não sejam de streaming. O gerenciamento avançado de
metadados do Delta Lake permite a gravação de dados em lote e de streaming.

Ele permite que vários trabalhos de streaming anexem dados à mesma tabela.
A mesma limitação de metadados com outros formatos também evita que várias consultas de
streaming estruturado sejam anexadas à mesma tabela. Os metadados do Delta Lake mantêm
informações de transação para cada consulta de streaming, permitindo assim que qualquer número
de consultas de streaming gravem simultaneamente em uma tabela com garantias de exatamente
uma vez.

Ele fornece garantias ACID mesmo sob gravações simultâneas


Ao contrário dos formatos integrados, Delta Lake permite operações simultâneas em lote e streaming
para gravar dados com garantias ACID.

Construindo Lakehouses com Apache Spark e Delta Lake | 277


Machine Translated by Google

Aplicando esquema na gravação para evitar corrupção de dados Um problema comum

no gerenciamento de dados com Spark usando formatos comuns como JSON, Parquet e ORC é a corrupção
acidental de dados causada pela gravação de dados formatados incorretamente. Como esses formatos definem
o layout de dados de arquivos individuais e não de toda a tabela, não há mecanismo para impedir que qualquer
trabalho do Spark grave arquivos com esquemas diferentes em tabelas existentes. Isto significa que não há
garantias de consistência para toda a tabela de muitos arquivos Parquet.

O formato Delta Lake registra o esquema como metadados em nível de tabela. Conseqüentemente, todas as
gravações em uma tabela Delta Lake podem verificar se os dados que estão sendo gravados têm um esquema
compatível com o da tabela. Se não for compatível, o Spark gerará um erro antes que qualquer dado seja gravado
e confirmado na tabela, evitando assim a corrupção acidental de dados. Vamos testar isso tentando escrever
alguns dados com uma coluna adicional, fechada, que indica se o empréstimo foi rescindido. Observe que esta
coluna não existe na tabela:

// Em Scala
val empréstimoUpdates = Seq(
(1111111L, 1000, 1000,0, "TX", falso), (2222222L,
2000, 0,0, "CA", verdadeiro))
.toDF("loan_id", "funded_amnt", "paid_amnt", "addr_state", "fechado")

empréstimoUpdates.write.format("delta").mode("anexar").save(deltaPath)

# Em Python
da importação de pyspark.sql.functions *

cols = ['loan_id', 'funded_amnt', 'paid_amnt', 'addr_state', 'closed'] itens = [ (1111111, 1000,


1000,0,
'TX', True), (2222222, 2000, 0,0, 'CA' , Falso) ]

empréstimoUpdates = (spark.createDataFrame(itens, colunas)


.withColumn("funded_amnt", col("funded_amnt").cast("int")))
empréstimoUpdates.write.format("delta").mode("append").save(deltaPath)

Esta gravação falhará com a seguinte mensagem de erro:

org.apache.spark.sql.AnalysisException: uma incompatibilidade de esquema detectada ao gravar na


tabela Delta (ID da tabela: 48bfa949-5a09-49ce-96cb-34090ab7d695).
Para ativar a migração de esquema, defina:
'.option("mergeSchema", "true")'.

Esquema de tabela:
raiz
-- empréstimo_id: longo (anulável =
verdadeiro) -- funded_amnt: número inteiro (anulável
= verdadeiro) --paid_amnt: duplo (anulável =
verdadeiro) -- addr_state: string (anulável = verdadeiro)

278 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

Esquema de dados:
root
-- empréstimo_id: longo (anulável =
verdadeiro) -- funded_amnt: inteiro (anulável =
verdadeiro) --paid_amnt : duplo (anulável =
verdadeiro) -- addr_state: string (anulável =
verdadeiro) -- fechado: booleano (anulável = verdadeiro )
Isto ilustra como o Delta Lake bloqueia gravações que não correspondem ao esquema da
tabela. Entretanto, também dá uma dica sobre como realmente evoluir o esquema da tabela
usando a opção mergeSchema, conforme discutido a seguir.

Evolução de esquemas para acomodar dados em constante

mudança Em nosso mundo de dados em constante mudança, é possível que queiramos


adicionar esta nova coluna à tabela. Esta nova coluna pode ser adicionada explicitamente
definindo a opção "mergeSchema" como "true":

// Em Scala

empréstimoUpdates.write.format("delta").mode("append") .option("mergeSchema", "true") .save(deltaPath)

# Em Python
(loanUpdates.write.format("delta").mode("append") .option("mergeSchema",
"true") .save(deltaPath))

Com isso, a coluna fechada será adicionada ao esquema da tabela e novos dados serão
anexados. Quando as linhas existentes são lidas, o valor da nova coluna é considerado NULL.
No Spark 3.0, você também pode usar o comando SQL DDL ALTER TABLE para adicionar e
modificar colunas.

Transformando dados existentes

Delta Lake oferece suporte aos comandos DML UPDATE, DELETE e MERGE, que permitem
construir pipelines de dados complexos. Esses comandos podem ser invocados usando Java,
Scala, Python e SQL, dando aos usuários a flexibilidade de usar os comandos com qualquer
API com a qual estejam familiarizados, usando DataFrames ou tabelas. Além disso, cada uma
destas operações de modificação de dados garante garantias ACID.

Vamos explorar isso com alguns exemplos de casos de uso reais.

Construindo Lakehouses com Apache Spark e Delta Lake | 279


Machine Translated by Google

Atualizando dados para x

erros Um caso de uso comum ao gerenciar dados é corrigir erros nos dados. Suponha que, ao revisar os
dados, percebemos que todos os empréstimos atribuídos a addr_state = 'OR' deveriam ter sido atribuídos
a addr_state = 'WA'. Se a tabela de empréstimo fosse uma tabela Parquet, então para fazer tal atualização
precisaríamos:

1. Copie todas as linhas que não foram afetadas para uma nova tabela.

2. Copie todas as linhas afetadas em um DataFrame e execute os dados


modificação.

3. Insira as linhas do DataFrame anotadas anteriormente na nova tabela.


4. Remova a tabela antiga e renomeie a nova tabela com o nome da tabela antiga.

No Spark 3.0, que adicionou suporte direto para operações DML SQL como UPDATE, DELETE e MERGE,
em vez de executar manualmente todas essas etapas, você pode simplesmente executar o comando
SQL UPDATE. No entanto, com uma tabela Delta Lake, os usuários também podem executar esta
operação, usando as APIs programáticas do Delta Lake da seguinte maneira:

// No Scala
import io.delta.tables.DeltaTable import
org.apache.spark.sql.functions._

val deltaTable = DeltaTable.forPath(spark, deltaPath)

deltaTable.update( col("addr_state")
=== "OR", Map("addr_state" -> lit("WA")))

# Em Python
da importação delta.tables *

deltaTable = DeltaTable.forPath(spark, deltaPath)


deltaTable.update("addr_state = 'OR'", {"addr_state": "'WA'"})

Exclusão de dados

relacionados ao usuário Com a entrada em vigor de políticas de proteção de dados, como o Regulamento
Geral de Proteção de Dados (GDPR) da UE, é mais importante do que nunca poder excluir dados de
usuários de todas as suas tabelas. Digamos que seja obrigatório excluir os dados de todos os empréstimos
que foram totalmente quitados. Com Delta Lake, você pode fazer o seguinte:

// Em Scala
val deltaTable = DeltaTable.forPath(spark, deltaPath)
deltaTable.delete("funded_amnt >=paid_amnt")

# Em Python
deltaTable = DeltaTable.forPath(spark, deltaPath)
deltaTable.delete("funded_amnt >=paid_amnt")

280 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

Semelhante às atualizações, com Delta Lake e Apache Spark 3.0 você pode executar diretamente o comando
DELETE SQL na tabela.

Transferindo dados alterados para uma tabela usando merge()

Um caso de uso comum é a captura de dados alterados, em que é necessário replicar as alterações de linha feitas
em uma tabela OLTP para outra tabela para cargas de trabalho OLAP. Para continuar com o nosso exemplo de dados

de empréstimos, digamos que temos outra tabela de informações sobre novos empréstimos, algumas das quais são
novos empréstimos e outras são atualizações de empréstimos existentes. Além disso, digamos que esta tabela de
alterações tenha o mesmo esquema da tabela lend_delta. Você pode inserir essas alterações na tabela usando a
operação DeltaTable.merge(), que é baseada no comando MERGE SQL:

// Em Scala

deltaTable .alias("t") .merge(loanUpdates.alias("s"), "t.loan_id =

s.loan_id") .whenMatched.updateAll() .whenNotMatched.insertAll() .execute()

# Em Python

(deltaTable .alias("t") .merge(loanUpdates.alias("s"), "t.loan_id =

s.loan_id") .whenMatchedUpdateAll() .whenNotMatchedInsertAll() .execute())

Como lembrete, você pode executar isso como um comando SQL MERGE começando com Spark 3.0.
Além disso, se você tiver um fluxo dessas alterações capturadas, poderá aplicá-las continuamente usando uma
consulta de streaming estruturado. A consulta pode ler as alterações em microlotes (consulte o Capítulo 8) de qualquer
fonte de streaming e usar fore achBatch() para aplicar as alterações em cada microlote à tabela Delta Lake.

Desduplicando dados durante a inserção usando mesclagem somente inserção

A operação de mesclagem no Delta Lake oferece suporte a uma sintaxe estendida além daquela especificada pelo
padrão ANSI, incluindo recursos avançados como os seguintes:

Excluir ações Por

exemplo, MERGE ... QUANDO COMBINAR, EXCLUIR.

Condições da cláusula

Por exemplo, MERGE ... QUANDO COMBINADO E <condição> ENTÃO ....

Ações opcionais
Todas as cláusulas MATCHED e NOT MATCHED são opcionais.

Construindo Lakehouses com Apache Spark e Delta Lake | 281


Machine Translated by Google

Sintaxe
estrela Por exemplo, UPDATE * e INSERT * para atualizar/inserir todas as colunas na tabela de
destino com colunas correspondentes do conjunto de dados de origem. As APIs Delta Lake
equivalentes são updateAll() e insertAll(), que vimos no anterior
seção.

Isso permite expressar muitos casos de uso mais complexos com pouco código. Por exemplo,
digamos que você queira preencher a tabela lend_delta com dados históricos sobre empréstimos passados.
Mas alguns dos dados históricos podem já ter sido inseridos na tabela e você não deseja atualizar
esses registros porque eles podem conter informações mais atualizadas. Você pode desduplicar pelo
empréstimo_id durante a inserção executando a seguinte operação de mesclagem apenas com a
ação INSERT (já que a ação UPDATE é opcional):

// Em Scala

deltaTable .alias("t") .merge(historicalUpdates.alias("s"), "t.loan_id =

s.loan_id") .whenNotMatched.insertAll() .execute()

# Em Python

(deltaTable .alias("t") .merge(historicalUpdates.alias("s"), "t.loan_id =

s.loan_id") .whenNotMatchedInsertAll() .execute())

Existem casos de uso ainda mais complexos, como CDC com exclusões e tabelas SCD, que são
simplificados com a sintaxe de mesclagem estendida. Consulte a documentação para obter mais
detalhes e exemplos.

Auditando alterações de dados com histórico de operações

Todas as alterações em sua tabela Delta Lake são registradas como confirmações no log de
transações da tabela. À medida que você escreve em uma tabela ou diretório do Delta Lake, cada
operação é automaticamente versionada. Você pode consultar o histórico de operações da tabela
conforme observado no seguinte trecho de código:

// Em Scala/ Python
deltaTable.history().show()

Por padrão, isso mostrará uma tabela enorme com muitas versões e muitas colunas. Em vez disso,
vamos imprimir algumas das colunas principais das três últimas operações:

// Em Scala

deltaTable .history(3) .select("versão", "timestamp", "operação",


"operaçãoParâmetros") .show(false)

282 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

# Em Python

(deltaTable .history(3) .select("versão", "timestamp", "operação",


"operaçãoParâmetros") .show(truncate=False))

Isso irá gerar a seguinte saída:


+-------+-----------+---------+------------------- ------------------------+
|versão|timestamp |operação|operaçãoParâmetros |
+-------+-----------+---------+------------------- ------------------------+
|5 |2020-04-07 |MERGE | |[predicado -> (t.`loan_id` = s.`loan_id`)] | |[predicado ->
|4 2020-04-07 |MERGE | (t.`loan_id` = s.`loan_id`)] | |
|3 2020-04-07 |DELETE |[predicado -> ["(CAST(`funded_amnt` ...
+-------+-----------+---------+------------------- ------------------------+

Observe a operação e os parâmetros de operação que são úteis para auditar as alterações.

Consultando instantâneos anteriores de uma tabela com viagem no tempo Você pode

consultar instantâneos com versão anterior de uma tabela usando as opções "versionAsOf" e
"timestampAsOf" do DataFrameReader. Aqui estão alguns exemplos:

// Em Scala

spark.read .format("delta") .option("timestampAsOf", "2020-01-01") // carimbo de data/hora


após a criação da tabela .load(deltaPath)

spark.read.format("delta")
.option("versionAsOf",
"4") .load(deltaPath)

# Em Python

(spark.read .format("delta") .option("timestampAsOf", "2020-01-01") # timestamp após a


criação da tabela .load(deltaPath))

(spark.read.format("delta")
.option("versionAsOf",
"4") .load(deltaPath))

Isso é útil em diversas situações, como:

• Reproduzir experimentos e relatórios de aprendizado de máquina executando novamente o trabalho


uma versão específica da tabela

• Comparar as alterações de dados entre diferentes versões para auditoria • Reverter

alterações incorretas lendo um instantâneo anterior como um DataFrame


e sobrescrevendo a tabela com ela

Construindo Lakehouses com Apache Spark e Delta Lake | 283


Machine Translated by Google

Resumo
Este capítulo examinou as possibilidades de construção de data lakes confiáveis usando Apache Spark.
Recapitulando, os bancos de dados resolvem problemas de dados há muito tempo, mas não atendem
aos diversos requisitos dos casos de uso e cargas de trabalho modernos. Os data lakes foram
construídos para aliviar algumas das limitações dos bancos de dados, e o Apache Spark é uma das
melhores ferramentas para construí-los. No entanto, os data lakes ainda carecem de alguns dos
principais recursos fornecidos pelos bancos de dados (por exemplo, garantias ACID). Lakehouses são
a próxima geração de soluções de dados, que visam fornecer os melhores recursos de bancos de
dados e data lakes e atender a todos os requisitos de diversos casos de uso e cargas de trabalho.

Exploramos brevemente alguns sistemas de código aberto (Apache Hudi e Apache Iceberg) que podem
ser usados para construir lakehouses e, em seguida, examinamos mais de perto o Delta Lake, um
formato de armazenamento de código aberto baseado em arquivo que, junto com o Apache Spark, é
um excelente alicerce para lagos. Como você viu, ele fornece o seguinte:

• Garantias transacionais e gerenciamento de esquemas, como bancos de dados

• Escalabilidade e abertura, como data lakes •

Suporte para cargas de trabalho simultâneas em lote e streaming com garantias ACID • Suporte

para transformação de dados existentes usando operações de atualização, exclusão e mesclagem


que garantem garantias ACID • Suporte

para versionamento, auditoria de histórico de operações e consulta de dados anteriores


versões

No próximo capítulo, exploraremos como começar a construir modelos de ML usando o MLlib do Spark.

284 | Capítulo 9: Construindo Data Lakes Confiáveis com Apache Spark


Machine Translated by Google

CAPÍTULO 10

Aprendizado de máquina com MLlib

Até agora, nos concentramos em cargas de trabalho de engenharia de dados com Apache
Spark. A engenharia de dados costuma ser uma etapa precursora na preparação de seus
dados para tarefas de aprendizado de máquina (ML), que será o foco deste capítulo. Vivemos
numa era em que a aprendizagem automática e as aplicações de inteligência artificial são parte
integrante das nossas vidas. É provável que, quer percebamos ou não, todos os dias entremos
em contato com modelos de ML para fins como recomendações e anúncios de compras on-
line, detecção de fraudes, classificação, reconhecimento de imagem, correspondência de
padrões e muito mais. Esses modelos de ML orientam decisões de negócios importantes para muitas empresas.
De acordo com este estudo da McKinsey, 35% do que os consumidores compram na Amazon e 75%
do que veem na Netflix são impulsionados por recomendações de produtos baseadas em aprendizagem
automática. Construir um modelo com bom desempenho pode fazer ou quebrar empresas.

Neste capítulo, você começará a construir modelos de ML usando MLlib, a biblioteca de aprendizado
de máquina de fato no Apache Spark. Começaremos com uma breve introdução ao aprendizado de
máquina e, em seguida, abordaremos as práticas recomendadas para ML distribuído e engenharia de
recursos em escala (se você já estiver familiarizado com os fundamentos do aprendizado de máquina,
você pode pular direto para “Projetando pipelines de aprendizado de máquina” no página 289). Por
meio dos pequenos trechos de código apresentados aqui e dos notebooks disponíveis no repositório
GitHub do livro, você aprenderá como construir modelos básicos de ML e usar o MLlib.

Este capítulo aborda as APIs Scala e Python; se você estiver interessado


em usar R (sparklyr) com Spark para aprendizado de máquina, convidamos
você a conferir Mastering Spark with R de Javier Luraschi, Kevin Kuo e
Edgar Ruiz (O'Reilly).

285
Machine Translated by Google

O que é aprendizado de máquina?


O aprendizado de máquina está recebendo muita atenção atualmente – mas o que é exatamente? Em termos
gerais, o aprendizado de máquina é um processo para extrair padrões de seus dados, usando estatística,
álgebra linear e otimização numérica. O aprendizado de máquina pode ser aplicado a problemas como
previsão do consumo de energia, determinação se há ou não um gato em seu vídeo ou agrupamento de itens
com características semelhantes.

Existem alguns tipos de aprendizado de máquina, incluindo aprendizado supervisionado, semissupervisionado,


não supervisionado e por reforço. Este capítulo se concentrará principalmente no aprendizado de máquina
supervisionado e apenas abordará o aprendizado não supervisionado. Antes de começarmos, vamos discutir
brevemente as diferenças entre ML supervisionado e não supervisionado.

Aprendizado supervisionado

No aprendizado de máquina supervisionado, seus dados consistem em um conjunto de registros de entrada,


cada um com rótulos associados, e o objetivo é prever o(s) rótulo(s) de saída dada uma nova entrada não
rotulada. Esses rótulos de saída podem ser discretos ou contínuos, o que nos leva aos dois tipos de
aprendizado de máquina supervisionado: classificação e regressão.

Num problema de classificação, o objetivo é separar as entradas em um conjunto discreto de classes ou


rótulos. Com a classificação binária, há dois rótulos discretos que você deseja prever, como “cachorro” ou
“não cachorro”, como mostra a Figura 10-1 .

Figura 10-1. Exemplo de classificação binária: cachorro ou não cachorro

Com a classificação multiclasse, também conhecida como multinomial, pode haver três ou mais rótulos
distintos, como prever a raça de um cão (por exemplo, pastor australiano, golden retriever ou poodle, como
mostrado na Figura 10-2) .

286 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

Figura 10-2. Exemplo de classificação multinomial: pastor australiano, golden retriever ou poodle

Em problemas de regressão, o valor a prever é um número contínuo, não um rótulo. Isso significa que
você pode prever valores que seu modelo não viu durante o treinamento, conforme mostrado na Figura
10.3. Por exemplo, você pode construir um modelo para prever as vendas diárias de sorvete de acordo
com a temperatura. Seu modelo pode prever o valor US$ 77,67, mesmo que nenhum dos pares de
entrada/saída em que foi treinado contenha esse valor.

Figura 10-3. Exemplo de regressão: previsão de vendas de sorvete com base na temperatura

A Tabela 10-1 lista alguns algoritmos de ML supervisionados comumente usados que estão disponíveis
no Spark MLlib, com uma observação sobre se eles podem ser usados para regressão, classificação
ou ambos.

O que é aprendizado de máquina? | 287


Machine Translated by Google

Tabela 10-1. Algoritmos populares de classificação e regressão

Algoritmo Classificação de

Regressão linear regressão de

regressão logística uso típico (sabemos, tem regressão no nome!)

Árvores de decisão Ambos

Árvores com gradiente aumentado Ambos

Florestas aleatórias Ambos

Baías ingénuas Classificação

Classificação de máquinas de vetores de suporte (SVMs)

Aprendizado não supervisionado Obter os dados

rotulados exigidos pelo aprendizado de máquina supervisionado pode ser muito caro e/ou inviável. É aqui que entra em jogo o aprendizado de máquina não

supervisionado. Em vez de prever um rótulo, o ML não supervisionado ajuda você a entender melhor a estrutura dos seus dados.

Como exemplo, considere os dados originais não agrupados à esquerda na Figura 10-4.

Não existe um rótulo verdadeiro conhecido para cada um desses pontos de dados (x1, x2), mas aplicando aprendizado de máquina não supervisionado aos nossos

dados, podemos encontrar os clusters que se formam naturalmente, conforme mostrado à direita.

Figura 10-4. Exemplo de agrupamento

O aprendizado de máquina não supervisionado pode ser usado para detecção de valores discrepantes ou como uma etapa de pré-processamento para aprendizado

de máquina supervisionado – por exemplo, para reduzir a dimensionalidade.

288 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

(ou seja, número de dimensões por dado) do conjunto de dados, o que é útil para reduzir requisitos de
armazenamento ou simplificar tarefas posteriores. Alguns algoritmos de aprendizado de máquina não
supervisionados no MLlib incluem k-means, Latent Dirichlet Allocation (LDA) e modelos de mistura gaussiana.

Por que Spark para aprendizado de máquina?

Spark é um mecanismo de análise unificado que fornece um ecossistema para ingestão de dados,
engenharia de recursos, treinamento de modelo e implantação. Sem o Spark, os desenvolvedores
precisariam de muitas ferramentas diferentes para realizar esse conjunto de tarefas e ainda poderiam ter
dificuldades com a escalabilidade.

Spark tem dois pacotes de aprendizado de máquina: spark.mllib e spark.ml. spark.mllib é a API original de
aprendizado de máquina, baseada na API RDD (que está em modo de manutenção desde o Spark 2.0),
enquanto spark.ml é a API mais recente, baseada em DataÿFrames. O restante deste capítulo se concentrará
no uso do pacote spark.ml e em como projetar pipelines de aprendizado de máquina no Spark. No entanto,
usamos “MLlib” como um termo abrangente para nos referirmos a ambos os pacotes de biblioteca de
aprendizado de máquina no Apache Spark.

Com spark.ml, os cientistas de dados podem usar um ecossistema para a preparação de dados e construção
de modelos, sem a necessidade de reduzir a resolução de seus dados para caber em uma única máquina.
spark.ml se concentra na expansão O(n), onde o modelo é dimensionado linearmente com o número de
pontos de dados que você possui, para que possa ser dimensionado para grandes quantidades de dados.
No capítulo seguinte, discutiremos algumas das compensações envolvidas na escolha entre uma estrutura
distribuída, como spark.ml , e uma estrutura de nó único, como o scikit-learn (sklearn). Se você já usou o
scikit-learn, muitas das APIs em spark.ml parecerão bastante familiares, mas há algumas diferenças sutis
que discutiremos.

Projetando pipelines de aprendizado de máquina


Nesta seção, abordaremos como criar e ajustar pipelines de ML. O conceito de pipelines é comum em
muitas estruturas de ML como uma forma de organizar uma série de operações a serem aplicadas aos seus
dados. No MLlib, a API Pipeline fornece uma API de alto nível construída sobre DataFrames para organizar
seu fluxo de trabalho de aprendizado de máquina. A API Pipeline é composta por uma série de
transformadores e estimadores, que discutiremos em profundidade mais tarde.

Ao longo deste capítulo, usaremos o conjunto de dados habitacionais de São Francisco do Inside Airbnb.
Ele contém informações sobre aluguéis do Airbnb em São Francisco, como número de quartos, localização,
pontuações de avaliações, etc., e nosso objetivo é construir um modelo para prever os preços de aluguel
noturno para listagens naquela cidade. Este é um problema de regressão porque o preço é uma variável
contínua. Iremos guiá-lo através do fluxo de trabalho que um cientista de dados passaria para abordar esse
problema, incluindo engenharia de recursos,

Projetando pipelines de aprendizado de máquina | 289


Machine Translated by Google

construção de modelos, ajuste de hiperparâmetros e avaliação do desempenho do modelo. Esse conjunto de


dados é bastante confuso e pode ser difícil de modelar (como a maioria dos conjuntos de dados do mundo
real!), portanto, se você estiver experimentando por conta própria, não se sinta mal se seus modelos iniciais
não forem bons.

A intenção deste capítulo não é mostrar todas as APIs do MLlib, mas sim equipá-lo com as habilidades e o
conhecimento para começar a usar o MLlib para construir pipelines de ponta a ponta. Antes de entrar em
detalhes, vamos definir alguma terminologia do MLlib:

Transformer
Aceita um DataFrame como entrada e retorna um novo DataFrame com uma ou mais colunas anexadas
a ele. Os transformadores não aprendem nenhum parâmetro dos seus dados e simplesmente aplicam
transformações baseadas em regras para preparar dados para treinamento de modelo ou gerar previsões
usando um modelo MLlib treinado. Eles têm um método .transform().

Estimador

Aprende (ou “se ajusta”) parâmetros do seu DataFrame por meio de um método .fit() e retorna um Modelo,
que é um transformador.

Pipeline
Organiza uma série de transformadores e estimadores em um único modelo. Embora os próprios pipelines
sejam estimadores, a saída de pipeline.fit() retorna um Pipe lineModel, um transformador.

Embora esses conceitos possam parecer um tanto abstratos no momento, os trechos de código e exemplos
deste capítulo ajudarão você a entender como todos eles se unem. Mas antes de podermos construir nossos
modelos de ML e usar transformadores, estimadores e pipelines, precisamos carregar nossos dados e realizar
alguma preparação de dados.

Ingestão e exploração de dados Pré-processamos

levemente os dados em nosso conjunto de dados de exemplo para remover valores discrepantes (por exemplo,
Airbnbs postados por US$ 0/noite), convertemos todos os números inteiros em duplos e selecionamos um
subconjunto informativo de mais de cem campos. Além disso, para quaisquer valores numéricos ausentes em
nossas colunas de dados, imputamos o valor mediano e adicionamos uma coluna indicadora (o nome da coluna
seguido por _na, como rooms_na). Dessa forma, o modelo de ML ou o analista humano podem interpretar
qualquer valor nessa coluna como um valor imputado, não como um valor verdadeiro. Você pode ver o caderno
de preparação de dados no repositório GitHub do livro. Observe que há muitas outras maneiras de lidar com
valores ausentes, que estão fora do escopo deste livro.

Vamos dar uma olhada rápida no conjunto de dados e no esquema correspondente (com a saída mostrando
apenas um subconjunto das colunas):

290 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

# Em Python
filePath = """/databricks-datasets/learning-spark-v2/sf-airbnb/
sf-airbnb-clean.parquet/"""
airbnbDF = spark.read.parquet(filePath)
airbnbDF.select("neighbourhood_cleansed", "room_type", "quartos", "banheiros",
"número_de_avaliações", "preço").show(5)

// Em Scala
val caminho do arquivo =

"/databricks-datasets/learning-spark-v2/sf-airbnb/sf-airbnb-clean.parquet/"
val airbnbDF = spark.read.parquet(filePath)
airbnbDF.select("neighbourhood_cleansed", "room_type", "quartos", "banheiros",
"número_de_avaliações", "preço").show(5)

+----------------------+---------------+--------+- --------+----------+-----+
|bairro_limpo| room_type|quartos|banheiros|número_...|preço|
+----------------------+---------------+--------+- --------+----------+-----+
| Adição Ocidental| Casa/apto inteiro| 1,0| 1,0| 180,0|170,0|
| Bernal Heights| Casa/apto inteiro| 2,0| 1,0| 111,0|235,0|
| Haight Ashbury| Quarto privado| 1,0| 4,0| 17,0| 65,0|
| Haight Ashbury| Quarto privado| 1,0| 4,0| 8,0| 65,0|
| Adição Ocidental| Casa/apto inteiro| 2,0| 1,5| 27,0|785,0|
+----------------------+---------------+--------+- --------+----------+-----+

Nosso objetivo é prever o preço por noite de um imóvel alugado, dadas as nossas características.

Antes que os cientistas de dados possam começar a construir modelos, eles precisam
explorar e compreender seus dados. Eles costumam usar o Spark para
agrupar seus dados e, em seguida, usar bibliotecas de visualização de dados, como mat-
plotlib para visualizar os dados. Deixaremos a exploração de dados como uma
exercício para o leitor.

Criando conjuntos de dados de treinamento e teste

Antes de começarmos a engenharia e modelagem de recursos, dividiremos nosso conjunto de dados em


dois grupos: treinar e testar. Dependendo do tamanho do seu conjunto de dados, sua proporção de treinamento/teste
pode variar, mas muitos cientistas de dados usam 80/20 como uma divisão padrão de treinamento/teste. você pode
estar se perguntando: “Por que não usar todo o conjunto de dados para treinar o modelo?” O problema é
que se construíssemos um modelo em todo o conjunto de dados, é possível que o modelo se lembrasse
orizemos ou “ajustemos demais” aos dados de treinamento que fornecemos e não teríamos mais dados
com o qual avaliar quão bem ele generaliza para dados anteriormente não vistos. O modelo
o desempenho no conjunto de teste é um proxy de quão bem ele funcionará em dados não vistos
(ou seja, em estado selvagem ou em produção), assumindo que os dados seguem distribuições semelhantes.
Essa divisão é ilustrada na Figura 10-5.

Projetando pipelines de aprendizado de máquina | 291


Machine Translated by Google

Figura 10-5. Divisão de treinamento/teste

Nosso conjunto de treinamento consiste em um conjunto de recursos, X, e um rótulo, y. Aqui usamos X maiúsculo para
denotar uma matriz com dimensões nxd, onde n é o número de pontos de dados (ou exemplos) e d é o número de
recursos (é assim que chamamos os campos ou colunas em nosso DataFrame). Usamos y minúsculo para denotar um
vetor, com dimensões nx 1; para cada exemplo, existe um rótulo.

Diferentes métricas são usadas para medir o desempenho do modelo. Para problemas de classificação, uma métrica
padrão é a precisão, ou porcentagem, de previsões corretas. Assim que o modelo tiver desempenho satisfatório no
conjunto de treinamento usando essa métrica, aplicaremos o modelo ao nosso conjunto de teste. Se tiver um bom
desempenho em nosso conjunto de testes de acordo com nossas métricas de avaliação, podemos nos sentir confiantes
de que construímos um modelo que irá “generalizar” para dados invisíveis.

Para o nosso conjunto de dados do Airbnb, manteremos 80% para o conjunto de treinamento e reservaremos 20% dos
nossos dados para o conjunto de teste. Além disso, definiremos uma semente aleatória para reprodutibilidade, de modo
que, se executarmos novamente esse código, obteremos os mesmos pontos de dados para nossos conjuntos de dados
de treinamento e teste, respectivamente. O valor da semente em si não deveria importar, mas os cientistas de dados
muitas vezes gostam de defini-lo como 42, pois essa é a resposta para a Questão Fundamental da Vida:

# Em Python
trainDF, testDF = airbnbDF.randomSplit([.8, .2], seed=42) print(f"""Existem
{trainDF.count()} linhas no conjunto de treinamento e {testDF.count( )} no
conjunto de teste""")

// Em Scala
val Array(trainDF, testDF) = airbnbDF.randomSplit(Array(.8, .2), seed=42) println(f"""Existem
${trainDF.count} linhas no conjunto de treinamento, e ${testDF.count} no conjunto de
teste""")

Isso produz a seguinte saída:

Existem 5.780 linhas no conjunto de treinamento e 1.366 no conjunto de teste

292 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

Mas o que acontece se alterarmos o número de executores em nosso cluster Spark? O otimizador
Catalyst determina a maneira ideal de particionar seus dados em função dos recursos do cluster e do
tamanho do conjunto de dados. Dado que os dados em um Spark DataFrame são particionados por
linha e cada trabalhador executa sua divisão independentemente dos outros trabalhadores, se os
dados nas partições mudarem, o resultado da divisão (por Split() aleatório) não será o mesmo .

Embora você possa corrigir a configuração do cluster e a semente para garantir resultados
consistentes, nossa recomendação é dividir os dados uma vez e, em seguida, gravá-los em sua
própria pasta de treinamento/teste para que você não tenha esses problemas de reprodutibilidade.

Durante a análise exploratória, você deve armazenar em cache o


conjunto de dados de treinamento, pois o acessará muitas vezes
durante o processo de aprendizado de máquina. Consulte a seção
“Cache e persistência de dados” na página 183 no Capítulo 7.

Preparando recursos com transformadores Agora

que dividimos nossos dados em conjuntos de treinamento e teste, vamos preparar os dados para
construir um modelo de regressão linear prevendo o preço dado o número de quartos. Em um exemplo
posterior, incluiremos todos os recursos relevantes, mas por enquanto vamos garantir que a mecânica
esteja instalada. A regressão linear (como muitos outros algoritmos no Spark) requer que todos os
recursos de entrada estejam contidos em um único vetor em seu DataFrame. Portanto, precisamos
transformar nossos dados.

Os transformadores no Spark aceitam um DataFrame como entrada e retornam um novo DataFrame


com uma ou mais colunas anexadas a ele. Eles não aprendem com seus dados, mas aplicam
transformações baseadas em regras usando o método transform() .

Para a tarefa de colocar todos os nossos recursos em um único vetor, usaremos o transformador
VectorAs sembler . VectorAssembler pega uma lista de colunas de entrada e cria um novo DataFrame
com uma coluna adicional, que chamaremos de recursos. Ele combina os valores dessas colunas de
entrada em um único vetor:

# Em Python
de pyspark.ml.feature import VectorAssembler
vecAssembler = VectorAssembler(inputCols=["bedrooms"], outputCol="features") vecTrainDF
= vecAssembler.transform(trainDF)
vecTrainDF.select("bedrooms", "features", " preço").mostrar(10)

// Em Scala
importar org.apache.spark.ml.feature.VectorAssembler
val vecAssembler = new

VectorAssembler() .setInputCols(Array("quartos")) .setOutputCol("features")


val vecTrainDF = vecAssembler.transform(trainDF)
vecTrainDF.select("quartos", "recursos", "preço").show(10)

Projetando pipelines de aprendizado de máquina | 293


Machine Translated by Google

+--------+--------+-----+
|quartos|características|preço|
+--------+--------+-----+
| 1,0| [1,0]|200,0|
| 1,0| [1,0]|130,0|
| 1,0| [1.0]| 95,0|
| 1,0| [1,0]|250,0|
| 3,0| [3,0]|250,0|
| 1,0| [1,0]|115,0|
| 1,0| [1,0]|105,0|
| 1,0| [1.0]| 86,0|
| 1,0| [1,0]|100,0|
| 2,0| [2,0]|220,0|
+--------+--------+-----+

Você notará que no código Scala tivemos que instanciar o novo VectorAssembler
objeto, bem como usar métodos setter para alterar as colunas de entrada e saída. Em
Python, você tem a opção de passar os parâmetros diretamente para o construtor do Vec
torAssembler ou usar os métodos setter, mas no Scala você só pode usar o setter
métodos.

A seguir abordaremos os fundamentos da regressão linear, mas se você já estiver familiarizado


com o algoritmo, vá para “Usando estimadores para construir modelos” na página 295.

Compreendendo a regressão linear

A regressão linear modela uma relação linear entre sua variável dependente (ou
rótulo) e uma ou mais variáveis independentes (ou recursos). No nosso caso, queremos ajustar
um modelo de regressão linear para prever o preço de um aluguel do Airbnb dado o número de
quartos de dormir.

Na Figura 10-6, temos um único recurso x e uma saída y (esta é nossa variável dependente).
capaz). A regressão linear busca ajustar uma equação para uma reta em x e y, que para escalar
variáveis podem ser expressas como y = mx + b, onde m é a inclinação eb é o deslocamento ou
interceptar.

Os pontos indicam os pares verdadeiros (x, y) do nosso conjunto de dados, e a linha sólida indica
a linha de melhor ajuste para este conjunto de dados. Os pontos de dados não estão perfeitamente alinhados, então
geralmente pensamos na regressão linear como ajustar um modelo para y ÿ mx + b + ÿ, onde ÿ (epsi-
lon) é um erro extraído independentemente por registro x de alguma distribuição. Estes são
os erros entre as previsões do nosso modelo e os valores verdadeiros. Muitas vezes pensamos em ÿ como
sendo gaussiano ou normalmente distribuído. As linhas verticais acima da linha de regressão
indicam ÿ positivo (ou resíduos), onde seus valores verdadeiros estão acima do valor previsto
valores, e as linhas verticais abaixo da linha de regressão indicam resíduos negativos. O
o objetivo da regressão linear é encontrar uma linha que minimize o quadrado desses resíduos.
Você notará que a linha pode extrapolar previsões para pontos de dados que não viu.

294 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

Figura 10-6. Regressão linear univariada

A regressão linear também pode ser estendida para lidar com múltiplas variáveis independentes.
Se tivéssemos três recursos como entrada, x = [x1 , x2 , x3 ], então poderíamos modelar y
como y ÿ w0 + w1x1 + w2x2 + w3x3 + ÿ. Nesse caso, há um coeficiente (ou peso) separado
para cada recurso e um único intercepto (w0 em vez de b aqui). O processo de estimar os
coeficientes e interceptos para o nosso modelo é chamado de aprendizagem (ou ajuste) dos
parâmetros do modelo. Por enquanto, vamos nos concentrar no exemplo de regressão
univariada de previsão de preço dado o número de quartos, e voltaremos à regressão linear
multivariada daqui a pouco.

Usando Estimadores para Construir

Modelos Após configurar nosso vectorAssembler, temos nossos dados preparados e


transformados no formato que nosso modelo de regressão linear espera. No Spark,
LinearRegression é um tipo de estimador – ele recebe um DataFrame e retorna um modelo.
Os estimadores aprendem parâmetros de seus dados, têm um método estimator_name.fit() e
são avaliados avidamente (ou seja, iniciam trabalhos do Spark), enquanto os transformadores
são avaliados preguiçosamente. Alguns outros exemplos de estimadores incluem Imputer,
DecisionTreeClassifier e Random ForestRegressor.

Você notará que nossa coluna de entrada para regressão linear (recursos) é a saída de nosso
vectorAssembler:

# Em Python
de pyspark.ml.regression import LinearRegression lr =
LinearRegression(featuresCol="features", labelCol="price") lrModel =
lr.fit(vecTrainDF)

// Em Scala
importar org.apache.spark.ml.regression.LinearRegression
val lr = new
LinearRegression() .setFeaturesCol("features")

Projetando pipelines de aprendizado de máquina | 295


Machine Translated by Google

.setLabelCol("preço")

val lrModel = lr.fit(vecTrainDF)

lr.fit() retorna um LinearRegressionModel (lrModel), que é um transformador. Em outras


palavras, a saída do método fit() de um estimador é um transformador. Depois que o
estimador tiver aprendido os parâmetros, o transformador poderá aplicá-los a novos
pontos de dados para gerar previsões. Vamos inspecionar os parâmetros aprendidos:
# Em Python
m = round(lrModel.coefficients[0], 2) b =
round(lrModel.intercept, 2) print(f"""A
fórmula para a linha de regressão linear é price = {m}*bedrooms +
{b }""")

// Em Scala
val m = lrModel.coefficients(0) val b =
lrModel.intercept println(f"""A
fórmula para a linha de regressão linear é price = $m%1.2f*bedrooms +
$b%1.2f"" ")

Isso imprime:

A fórmula para a linha de regressão linear é preço = 123,68*quartos + 47,51

Criando um pipeline Se

quisermos aplicar nosso modelo ao nosso conjunto de teste, precisaremos preparar esses dados da mesma forma
que o conjunto de treinamento (ou seja, passá-los pelo montador vetorial). Muitas vezes, os pipelines de preparação
de dados terão várias etapas e torna-se complicado lembrar não apenas quais etapas aplicar, mas também a ordem
das etapas. Esta é a motivação para a API Pipeline: você simplesmente especifica os estágios pelos quais deseja
que seus dados passem, em ordem, e o Spark cuida do processamento para você. Eles fornecem ao usuário
melhor capacidade de reutilização e organização do código. No Spark, Pipelines são estimadores, enquanto
PipelineModels – Pipelines ajustados – são transformadores.

Vamos construir nosso pipeline agora:

# Em Python
de pyspark.ml import Pipeline pipeline
= Pipeline(stages=[vecAssembler, lr]) pipelineModel =
pipeline.fit(trainDF)

// Em Scala
importar org.apache.spark.ml.Pipeline val
pipeline = new Pipeline().setStages(Array(vecAssembler, lr)) val pipelineModel =
pipeline.fit(trainDF)

Outra vantagem de usar a API Pipeline é que ela determina quais estágios são estimadores/transformadores para
você, então você não precisa se preocupar em especificar name.fit() versus name.transform() para cada um dos
estágios.

296 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

Como pipelineModel é um transformador, é simples aplicá-lo aos nossos dados de teste


defina também:

# Em Python
predDF = pipelineModel.transform(testDF)
predDF.select("quartos", "recursos", "preço", "previsão").show(10)

// Em Scala
val predDF = pipelineModel.transform(testDF)
predDF.select("quartos", "recursos", "preço", "previsão").show(10)

+--------+--------+------+------------------+
|quartos|recursos| preço| previsão |
+--------+--------+------+------------------+
| 1,0| [1.0]| 85,0|171,18598011578285|
| 1,0| [1.0]| 45,0|171,18598011578285|
| 1,0| [1.0]| 70,0|171,18598011578285|
| 1,0| [1.0]| 128,0|171,18598011578285|
| 1,0| [1.0]| 159,0|171,18598011578285|
| 2,0| [2.0]| 250,0|294,86172649777757|
| 1,0| [1.0]| 99,0|171,18598011578285|
| 1,0| [1.0]| 95,0|171,18598011578285|
| 1,0| [1.0]| 100,0|171,18598011578285|
| 1,0| [1.0]|2010.0|171.18598011578285|
+--------+--------+------+------------------+

Neste código construímos um modelo usando apenas um único recurso, quartos (você pode encontrar o
caderno para este capítulo no repositório GitHub do livro). No entanto, você pode querer
construa um modelo usando todos os seus recursos, alguns dos quais podem ser categóricos, como
host_is_superhost. Os traços categóricos assumem valores discretos e não têm valores intrínsecos.
ordenação sica – os exemplos incluem ocupações ou nomes de países. Na próxima seção
consideraremos uma solução de como tratar esses tipos de variáveis, conhecidas como one-hot

codificação.

Codificação one-hot

No pipeline que acabamos de criar, tínhamos apenas dois estágios, e nossa regressão linear
modelo usou apenas um recurso. Vamos dar uma olhada em como construir um pouco mais complexo
pipeline que incorpora todos os nossos recursos numéricos e categóricos.

A maioria dos modelos de aprendizado de máquina no MLlib espera valores numéricos como entrada, representando
enviados como vetores. Para converter valores categóricos em valores numéricos, podemos usar um
técnica chamada codificação one-hot (OHE). Suponha que temos uma coluna chamada Animal
e temos três tipos de animais: Cão, Gato e Peixe. Não podemos passar os tipos de string
diretamente em nosso modelo de ML, então precisamos atribuir um mapeamento numérico, como este:

Animal = {"Cão", "Gato", "Peixe"}


"Cachorro" = 1, "Gato" = 2, "Peixe" = 3

Projetando pipelines de aprendizado de máquina | 297


Machine Translated by Google

No entanto, usando essa abordagem, introduzimos alguns relacionamentos espúrios em nosso conjunto
de dados que não existiam antes. Por exemplo, por que atribuímos ao Gato o dobro do valor do
Cachorro? Os valores numéricos que usamos não devem introduzir nenhum relacionamento em nosso
conjunto de dados. Em vez disso, queremos criar uma coluna separada para cada valor distinto em
nossa coluna Animal:

"Cachorro" = [1, 0, 0]
"Gato" = [0, 1, 0]
"Peixe" = [0, 0, 1]

Se o animal for um cachorro, ele terá um na primeira coluna e zeros em outras partes. Se for um gato,
terá um na segunda coluna e zeros em outros lugares. A ordem das colunas é irrelevante. Se você já
usou pandas antes, notará que isso faz a mesma coisa que pandas.get_dummies().

Se tivéssemos um zoológico com 300 animais, o OHE aumentaria enormemente o consumo de


recursos de memória/computação? Não com Spark! O Spark usa internamente um SparseVector
quando a maioria das entradas é 0, como costuma acontecer após OHE, portanto, não desperdiça
espaço armazenando valores 0. Vamos dar uma olhada em um exemplo para entender melhor como
funcionam os SparseVectors:

DensoVetor(0, 0, 0, 7, 0, 2, 0, 0, 0, 0)
Vetor esparso(10, [3, 5], [7, 2])

O DenseVector neste exemplo contém 10 valores, todos, exceto 2, são 0. Para criar um SparseVector,
precisamos acompanhar o tamanho do vetor, os índices dos elementos diferentes de zero e os valores
correspondentes nesses índices. . Neste exemplo, o tamanho do vetor é 10, existem dois valores
diferentes de zero nos índices 3 e 5, e os valores correspondentes nesses índices são 7 e 2.

Existem algumas maneiras de codificar seus dados com o Spark. Uma abordagem comum é usar
StringIndexer e OneHotEncoder. Com esta abordagem, o primeiro passo é aplicar o estimador
StringIndexer para converter valores categóricos em índices de categoria. Esses índices de categoria
são ordenados por frequências de rótulo, de modo que o rótulo mais frequente recebe o índice 0, o que
nos fornece resultados reproduzíveis em várias execuções dos mesmos dados.

Depois de criar seus índices de categoria, você pode passá-los como entrada para o OneHotEncoder
(OneHotEncoderEstimator se estiver usando Spark 2.3/2.4). O codificador OneHotEn mapeia uma
coluna de índices de categoria para uma coluna de vetores binários. Dê uma olhada na Tabela 10-2
para ver as diferenças nas APIs StringIndexer e OneHotEncoder do Spark 2.3/2.4 a 3.0.

298 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

Tabela 10-2. Mudanças em StringIndexer e OneHotEncoder no Spark 3.0

Spark 2.3 e 2.4 Spark 3.0

StringIndexer Coluna única como entrada/saída Múltiplas colunas como entrada/saída

OneHotEncoder Descontinuada Várias colunas como entrada/saída

OneHotEncoderEstimator Múltiplas colunas como entrada/saída N/A

O código a seguir demonstra como codificar nossos recursos categóricos. Em nosso conjunto de dados, qualquer
coluna do tipo string é tratada como um recurso categórico, mas às vezes você pode ter recursos numéricos que
deseja tratar como categóricos ou vice-versa.
Você precisará identificar cuidadosamente quais colunas são numéricas e quais são categóricas:

# Em Python
de pyspark.ml.feature import OneHotEncoder, StringIndexer

categoricalCols = [campo para (campo, dataType) em trainDF.dtypes if dataType == "string"]


indexOutputCols = [x + "Índice"
para x em categoricalCols] oheOutputCols = [x + "OHE" para x em categoricalCols]

stringIndexer = StringIndexer(inputCols=categoricalCols,
outputCols=indexOutputCols,
handleInvalid="skip")
oheEncoder = OneHotEncoder(inputCols=indexOutputCols,
outputCols=oheOutputCols)

numericCols = [campo para (campo, dataType) em trainDF.dtypes if ((dataType


== "duplo") & (campo! = "preço"))]
assemblerInputs = oheOutputCols + numericCols
vecAssembler = VectorAssembler(inputCols=assemblerInputs,
outputCol="recursos")

// No Scala
importar org.apache.spark.ml.feature.{OneHotEncoder, StringIndexer}

val categoricalCols = trainDF.dtypes.filter(_._2 == "StringType").map(_._1) val indexOutputCols =


categoricalCols.map(_ + "Index") val oheOutputCols = categoricalCols.map(_
+ "OHE" )

val stringIndexer = new

StringIndexer() .setInputCols(categoricalCols) .setOutputCols(indexOutputCols) .setHandleInvalid("skip")

val oheEncoder = novo

OneHotEncoder() .setInputCols(indexOutputCols) .setOutputCols(oheOutputCols)

val numericCols = trainDF.dtypes.filter{ case (field, dataType) => dataType == "DoubleType"


&& field != "price"}.map(_._1)

Projetando pipelines de aprendizado de máquina | 299


Machine Translated by Google

val assemblerInputs = oheOutputCols ++ numericCols val


vecAssembler = novo

VectorAssembler() .setInputCols(assemblerInputs) .setOutputCol("recursos")

Agora você deve estar se perguntando: “Como o StringIndexer lida com novas categorias
que aparecem no conjunto de dados de teste, mas não no conjunto de dados de
treinamento?” Existe um parâmetro handleInvalid que especifica como você deseja tratá-
los. As opções são skip (filtrar linhas com dados inválidos), error (lançar um erro) ou keep
(colocar dados inválidos em um bucket adicional especial, no índice numLabels). Neste
exemplo, apenas ignoramos os registros inválidos.

Uma dificuldade com essa abordagem é que você precisa informar explicitamente ao
StringIndexer quais recursos devem ser tratados como recursos categóricos. Você poderia
usar o VectorIndexer para detectar automaticamente todas as variáveis categóricas, mas é
computacionalmente caro, pois precisa iterar sobre cada coluna e detectar se ela tem
menos que valores distintos de maxCategories . maxCategories é um parâmetro especificado
pelo usuário e determinar esse valor também pode ser difícil.

Outra abordagem é usar RFormula. A sintaxe para isso é inspirada na linguagem de


programação R. Com o RFormula, você fornece sua etiqueta e quais recursos deseja incluir.
Ele oferece suporte a um subconjunto limitado de operadores R, incluindo ~, ., :, + e -. Por
exemplo, você pode especificar formula = "y ~ quartos + banheiros", que significa prever y
considerando apenas quartos e banheiros, ou formula = "y ~.", que significa usar todos os
recursos disponíveis (e exclui automaticamente y dos recursos). RFormula irá
automaticamente StringIndex e OHE todas as suas colunas de string , converterá suas
colunas numéricas em tipo duplo e combinará tudo isso em um único vetor usando
VectorAssembler nos bastidores. Assim, podemos substituir todo o código anterior por uma
única linha e obteremos o mesmo resultado:
# Em Python
de pyspark.ml.feature import RFormula

rFormula = RFormula(formula="preço ~ .",


featuresCol="recursos",
labelCol="preço",
handleInvalid="skip")

// No Scala
importar org.apache.spark.ml.feature.RFormula

val rFormula = new


RFormula() .setFormula("preço

~ .") .setFeaturesCol("recursos") .setLabelCol("preço") .setHandleInvalid("pular")

300 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

A desvantagem de RFormula combinar automaticamente StringIndexer e OneHotEncoder é que a codificação


one-hot não é necessária ou recomendada para todos os algoritmos. Por exemplo, algoritmos baseados em
árvore podem lidar diretamente com variáveis categóricas se você usar apenas o StringIndexer para os
recursos categóricos. Você não precisa codificar recursos categóricos a quente para métodos baseados
em árvore, e isso muitas vezes piorará seus modelos baseados em árvore. Infelizmente, não existe uma
solução única para a engenharia de recursos, e a abordagem ideal está intimamente relacionada aos
algoritmos downstream que você planeja aplicar ao seu conjunto de dados.

Se outra pessoa realizar a engenharia de recursos para você,


certifique-se de documentar como gerou esses recursos.

Depois de escrever o código para transformar seu conjunto de dados, você pode adicionar um modelo de
regressão linear usando todos os recursos como entrada.

Aqui, colocamos toda a preparação de recursos e construção de modelo no pipeline e aplicamos ao nosso
conjunto de dados:

# Em Python
lr = LinearRegression(labelCol="price", featuresCol="features") pipeline =
Pipeline(stages = [stringIndexer, oheEncoder, vecAssembler, lr])
# Ou use RFormula #
pipeline = Pipeline(stages = [rFormula, lr])

pipelineModel = pipeline.fit(trainDF) predDF =


pipelineModel.transform(testDF)
predDF.select("recursos", "preço", "previsão").show(5)

// Em Scala
val lr = new

LinearRegression() .setLabelCol("price") .setFeaturesCol("features") val pipeline = new Pipeline()


.setStages(Array(stringIndexer, oheEncoder, vecAssembler, lr))
// Ou use RFormula //
val pipeline = new Pipeline().setStages(Array(rFormula, lr))

val pipelineModel = pipeline.fit(trainDF) val predDF


= pipelineModel.transform(testDF) predDF.select("recursos",
"preço", "predição").show(5)

+--------------------+-----+------------------+
| características | preço | previsão |
+--------------------+-----+------------------+
|(98,[0,3,6,7,23,4...| 85,0| 55,80250714362137| |(98,
[0,3,6,7,23,4...| 45,0| 22,74720286761658| |( 98,
[0,3,6,7,23,4...| 70,0|27,115811183814913|

Projetando pipelines de aprendizado de máquina | 301


Machine Translated by Google

|(98,[0,3,6,7,13,4...|128,0|-91,60763412465076| |(98,
[0,3,6,7,13,4...|159,0| 94,70374072351933|
+--------------------+-----+------------------+

Como você pode ver, a coluna de recursos é representada como um SparseVector. Existem 98 recursos
após a codificação one-hot, seguidos pelos índices diferentes de zero e, em seguida, pelos próprios
valores. Você pode ver toda a saída se passar truncate=False para show().

Como está o desempenho do nosso modelo? Você pode ver que, embora algumas das previsões
possam ser consideradas “próximas”, outras estão distantes (um preço negativo para um aluguel!?). A
seguir, avaliaremos numericamente o desempenho do nosso modelo em todo o conjunto de testes.

Avaliando modelos Agora

que construímos um modelo, precisamos avaliar seu desempenho. Em spark.ml existem avaliadores de
classificação, regressão, clustering e classificação (introduzidos no Spark 3.0). Dado que este é um
problema de regressão, usaremos a raiz do erro quadrático médio (RMSE) e o desempenho R.
2
(pronuncia-se “R-quadrado”) para avaliar o nosso modelo

REQM

RMSE é uma métrica que varia de zero ao infinito. Quanto mais próximo estiver de zero, melhor.

Vamos percorrer a fórmula matemática passo a passo:

1. Calcule a diferença (ou erro) entre o valor verdadeiro yi e o valor previsto ÿi (pronuncia-se y-hat,
onde o “chapéu” indica que é um valor previsto da quantidade sob o chapéu):

Erro = yi - yi

2. Eleve ao quadrado a diferença entre yi e ÿi para que nossos resíduos positivos e negativos não se
anulem. Isso é conhecido como erro quadrático:

2
Erro Quadrado (SE) = yi - yi

3. Em seguida, somamos o erro quadrático para todos os n pontos de dados, conhecido como soma
dos erros quadráticos (SSE) ou soma dos resíduos quadrados:

n
2
Soma dos Erros Quadrados (SSE) = ÿ sim - sim
eu = 1

302 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

4. Porém, o SSE cresce com o número de registros n no conjunto de dados, então queremos normalizá-
lo pelo número de registros. Isso nos dá o erro quadrático médio (MSE), uma métrica de regressão
muito comumente usada:

n
1 2
Erro Quadrático Médio (MSE) = ÿ sim - sim
ni = 1

5. Se pararmos no MSE, então nosso termo de erro está na escala de unit2. Freqüentemente,
calcularemos a raiz quadrada do MSE para recuperar o erro na escala da unidade original, o que
nos dá a raiz do erro quadrático médio (RMSE):

n
1 2
Erro quadrático médio raiz (RMSE) = ÿ sim - sim
ni = 1

Vamos avaliar nosso modelo usando RMSE:

# Em Python
de pyspark.ml.evaluation import RegressionEvaluator
regressãoEvaluator = RegressionEvaluator(
prediçãoCol="predição",
labelCol="preço",
metricName="rmse")
rmse = regressãoEvaluator.evaluate(predDF)
print(f"RMSE é {rmse:.1f}")

// Em Scala
importar org.apache.spark.ml.evaluation.RegressionEvaluator val
regressionEvaluator = new

RegressionEvaluator() .setPredictionCol("prediction") .setLabelCol("price") .setMetricName("rmse") val rmse = regressEvaluato

Isso produz a seguinte saída:


REQM é 220,6

Interpretando o valor de RMSE. Então, como sabemos se 220,6 é um bom valor para o RMSE? Existem
várias maneiras de interpretar esse valor, uma das quais é construir um modelo de linha de base simples
e calcular seu RMSE para comparação. Um modelo de linha de base comum para tarefas de regressão é
calcular o valor médio do rótulo no conjunto de treinamento ÿ (pronuncia-se barra y), então prever ÿ para
cada registro no conjunto de dados de teste e calcular o RMSE resultante (o código de exemplo é
disponível no repositório GitHub do livro). Se você tentar isso, verá que nosso modelo de linha de base
tem um RMSE de 240,7, então superamos nosso

Projetando pipelines de aprendizado de máquina | 303


Machine Translated by Google

linha de base. Se você não superar a linha de base, provavelmente algo deu errado no processo de construção do modelo.

Se este fosse um problema de classificação, você poderia querer prever a classe mais
prevalente como seu modelo de linha de base.

Lembre-se de que a unidade do seu rótulo impacta diretamente o seu RMSE. Por exemplo, se o seu rótulo for altura, seu RMSE será

maior se você usar centímetros em vez de metros como unidade de medida. Você poderia diminuir arbitrariamente o RMSE usando

uma unidade diferente, e é por isso que é importante comparar seu RMSE com uma linha de base.

Existem também algumas métricas que naturalmente dão uma ideia de como você está se saindo em relação a uma linha de base,
2
como R , que discutiremos a seguir.

R2

2
Apesar do nome R, vamos contendo “quadrado”, os valores de R 2 variam de infinito negativo a 1.
2
dar uma olhada na matemática por trás dessa métrica. R é calculado da seguinte forma:

2 SSres
R=1ÿ
SStot

onde SStot é a soma total dos quadrados se você sempre prever ÿ:

SStot = ÿ 2 yi ÿ y
eu = 1

e SSres é a soma dos resíduos ao quadrado das previsões do seu modelo (também conhecida como a soma dos erros ao quadrado,

que usamos para calcular o RMSE):

n
2
SSres = ÿ sim - sim
eu = 1

2
Se o seu modelo prevê perfeitamente todos os pontos de dados, então seu SSres = 0, fazendo com que seu R = 1. E se seu SSres
2
= SStot, então a fração é 1/1, então seu R é 0. Isso é o que acontece se seu o modelo tem o mesmo desempenho de sempre prever

o valor médio, ÿ.

Mas e se o seu modelo tiver um desempenho pior do que sempre prevendo ÿ e seu SSres for realmente grande? Então seu R é
2 2
negativo, você deveria pode realmente ser negativo! Se o seu R

304 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

2
reavalie seu processo de modelagem. O bom de usar R é necessariamente é que você não
definir um modelo de linha de base para comparação.
2,
Se quisermos alterar nosso avaliador de regressão para usar o em vez de redefinir o
avaliador de regressão R, podemos definir o nome da métrica usando a propriedade setter:

# Em Python r2
= regressEvaluator.setMetricName("r2").evaluate(predDF) print(f"R2 is {r2}")

// Em Scala val
r2 = regressEvaluator.setMetricName("r2").evaluate(predDF) println(s"R2 is $r2")

A saída é:

R2 é 0,159854

Nosso R2 é positivo, mas está muito próximo de 0. Uma das razões pelas quais nosso modelo não

tem um desempenho muito bom é porque nosso rótulo, preço, parece ser distribuído log-normalmente.
Se uma distribuição for log-normal, significa que se tomarmos o logaritmo do valor, o resultado
parecerá uma distribuição normal. O preço é frequentemente distribuído log-normalmente. Se você
pensar nos preços de aluguel em São Francisco, a maioria custa cerca de US$ 200 por noite, mas
há alguns que alugam por milhares de dólares por noite! Você pode ver a distribuição dos preços do
Airbnb para nosso conjunto de dados de treinamento na Figura 10-7.

Figura 10-7. Distribuição de preços de habitação em São Francisco

Vamos dar uma olhada na distribuição resultante se, em vez disso, olharmos para o logaritmo do
preço (Figura 10-8).

Projetando pipelines de aprendizado de máquina | 305


Machine Translated by Google

Figura 10-8. Distribuição de preços logísticos de habitação em São Francisco

Você pode ver aqui que nossa distribuição logarítmica de preços se parece um pouco mais com uma distribuição normal.
Como exercício, tente construir um modelo para prever o preço na escala logarítmica e, em seguida, exponenciar a
previsão para tirá-la da escala logarítmica e avaliar seu modelo. O código também pode ser encontrado no caderno
deste capítulo no repositório GitHub do livro. Você deverá ver que seu RMSE diminui e seu R
2 aumenta para este conjunto de dados.

Salvando e Carregando Modelos Agora que

construímos e avaliamos um modelo, vamos salvá-lo em armazenamento persistente para reutilização posterior (ou no
caso de nosso cluster ficar inativo, não precisamos recalcular o modelo). Salvar modelos é muito semelhante a escrever
DataFrames – a API é model.write().save(path). Opcionalmente, você pode fornecer o comando overwrite() para
substituir quaisquer dados contidos nesse caminho:

# Em Python
pipelinePath = "/tmp/lr-pipeline-model"
pipelineModel.write().overwrite().save(pipelinePath)

// Em Scala
val pipelinePath = "/tmp/lr-pipeline-model"
pipelineModel.write.overwrite().save(pipelinePath)

Ao carregar seus modelos salvos, você precisa especificar o tipo de modelo que está carregando novamente (por
exemplo, foi um LinearRegressionModel ou um LogisticRegressionMo del?). Por esse motivo, recomendamos que você
sempre coloque seus transformadores/estimadores em um Pipeline, para que para todos os seus modelos você carregue
um PipelineModel e precise apenas alterar o caminho do arquivo para o modelo:

306 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

# Em Python
de pyspark.ml import PipelineModel
salvoPipelineModel = PipelineModel.load(pipelinePath)

// No Scala
importar org.apache.spark.ml.PipelineModel
val savePipelineModel = PipelineModel.load(pipelinePath)

Após o carregamento, você pode aplicá-lo a novos pontos de dados. No entanto, você não pode usar
os pesos deste modelo como parâmetros de inicialização para treinar um novo modelo (em vez de
começar com pesos aleatórios), pois o Spark não tem o conceito de “inicializações a quente”. Se o seu
conjunto de dados mudar ligeiramente, você terá que treinar novamente todo o modelo de regressão
linear do zero.

Com nosso modelo de regressão linear construído e avaliado, vamos explorar o desempenho de alguns
outros modelos em nosso conjunto de dados. Na próxima seção, exploraremos modelos baseados em
árvore e veremos alguns hiperparâmetros comuns para ajustar a fim de melhorar o desempenho do
modelo.

Ajuste de hiperparâmetros
Quando os cientistas de dados falam sobre o ajuste de seus modelos, eles geralmente discutem o
ajuste de hiperparâmetros para melhorar o poder preditivo do modelo. Um hiperparâmetro é um atributo
que você define sobre o modelo antes do treinamento e não é aprendido durante o processo de
treinamento (não deve ser confundido com parâmetros, que são aprendidos no processo de treinamento).
O número de árvores em sua floresta aleatória é um exemplo de hiperparâmetro.

Nesta seção, focaremos no uso de modelos baseados em árvore como exemplo para procedimentos
de ajuste de hiperparâmetros, mas os mesmos conceitos também se aplicam a outros modelos.
Depois de configurar a mecânica para fazer o ajuste de hiperparâmetros com spark.ml, discutiremos
maneiras de otimizar o pipeline. Vamos começar com uma breve introdução às árvores de decisão,
seguida de como podemos usá-las em spark.ml.

Modelos baseados em árvore

Modelos baseados em árvores, como árvores de decisão, árvores com gradiente aumentado e florestas
aleatórias, são modelos relativamente simples, mas poderosos, fáceis de interpretar (ou seja, é fácil
explicar as previsões que fazem). Conseqüentemente, eles são bastante populares para tarefas de
aprendizado de máquina. Chegaremos às florestas aleatórias em breve, mas primeiro precisamos cobrir
os fundamentos das árvores de decisão.

Ajuste de hiperparâmetros | 307


Machine Translated by Google

Árvores de decisão

Como uma solução pronta para uso, as árvores de decisão são adequadas para mineração de dados. Eles
são relativamente rápidos de construir, altamente interpretáveis e invariáveis em escala (ou seja, padronizar
ou dimensionar os recursos numéricos não altera o desempenho da árvore). Então, o que é uma árvore de
decisão?

Uma árvore de decisão é uma série de regras if-then-else aprendidas com seus dados para tarefas de
classificação ou regressão. Suponha que estamos tentando construir um modelo para prever se alguém
aceitará ou não uma oferta de emprego, e os recursos incluem salário, tempo de deslocamento, café grátis,
etc. Se ajustarmos uma árvore de decisão a esse conjunto de dados, poderemos obter um modelo isso se
parece com a Figura 10-9.

Figura 10-9. Exemplo de árvore de decisão

O nó no topo da árvore é chamado de “raiz” da árvore porque é o primeiro recurso em que “dividimos”. Esse
recurso deve fornecer a divisão mais informativa – nesse caso, se o salário for inferior a US$ 50.000, a
maioria dos candidatos recusará a oferta de emprego. O nó “Recusar oferta” é conhecido como “nó folha”,
pois não há outras divisões saindo desse nó; está no final de um galho. (Sim, é um pouco engraçado
chamarmos isso de “árvore” de decisão, mas desenhe a raiz da árvore no topo e as folhas na parte inferior!)

Porém, se o salário oferecido for superior a US$ 50.000, passamos para o próximo recurso mais informativo
da árvore de decisão, que neste caso é o tempo de deslocamento. Mesmo que o salário seja superior a
50.000 dólares, se a viagem for superior a uma hora, a maioria das pessoas recusará a oferta de emprego.

308 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

Não entraremos em detalhes sobre como determinar quais recursos


proporcionarão o maior ganho de informação aqui, mas se você estiver
interessado, confira o Capítulo 9 de The Elements of Statistical Learning,
de Trevor Hastie, Robert Tibshirani, e Jerome Friedman (Springer).

A última característica do nosso modelo é o café grátis. Neste caso a árvore de decisão mostra que se
Se o salário for superior a US$ 50.000, o deslocamento diário for inferior a uma hora e houver café
grátis, a maioria das pessoas aceitará nossa oferta de emprego (se fosse tão simples!). Como recurso
de acompanhamento, o R2D3 possui uma ótima visualização de como funcionam as árvores de decisão.

É possível dividir o mesmo recurso várias vezes em uma única árvore


de decisão, mas cada divisão ocorrerá com um valor diferente.

A profundidade de uma árvore de decisão é o caminho mais longo do nó raiz até qualquer nó folha. Na
Figura 10-9, a profundidade é três. Árvores muito profundas são propensas a overfitting ou memorização
de ruído em seu conjunto de dados de treinamento, mas árvores muito rasas se ajustarão mal ao seu
conjunto de dados (ou seja, poderiam ter captado mais sinal dos dados).

Com a essência de uma árvore de decisão explicada, vamos retomar o tópico da preparação de
recursos para árvores de decisão. Para árvores de decisão, você não precisa se preocupar em
padronizar ou dimensionar seus recursos de entrada, porque isso não tem impacto nas divisões – mas
você precisa ter cuidado ao preparar seus recursos categóricos.

Os métodos baseados em árvore podem lidar naturalmente com variáveis categóricas. No spark.ml,
você só precisa passar as colunas categóricas para o StringIndexer, e a árvore de decisão cuidará do
resto. Vamos ajustar uma árvore de decisão ao nosso conjunto de dados:

# Em Python
de pyspark.ml.regression import DecisionTreeRegressor

dt = DecisionTreeRegressor(labelCol="preço")

# Filtrar apenas colunas numéricas (e excluir preço, nosso rótulo) numericCols


= [field for (field, dataType) in trainDF.dtypes if ((dataType == "double") &
(field != "price"))]

# Combine a saída de StringIndexer definida acima e colunas numéricas


assemblerInputs = indexOutputCols + numericCols
vecAssembler = VectorAssembler(inputCols=assemblerInputs, outputCol="features")

# Combine estágios em estágios de


pipeline = [stringIndexer, vecAssembler, dt]

Ajuste de hiperparâmetros | 309


Machine Translated by Google

pipeline = Pipeline(stages=stages)
pipelineModel = pipeline.fit(trainDF) # Esta linha deve apresentar erro

// No Scala
importar org.apache.spark.ml.regression.DecisionTreeRegressor

val dt = novo
DecisionTreeRegressor() .setLabelCol("preço")

// Filtre apenas colunas numéricas (e exclua o preço, nosso rótulo) val


numericCols = trainDF.dtypes.filter{ case (field, dataType) => dataType ==
"DoubleType" && field != "price"}.map(_ ._1)

// Combina a saída de StringIndexer definida acima e colunas numéricas val


assemblerInputs = indexOutputCols ++ numericCols val
vecAssembler = new

VectorAssembler() .setInputCols(assemblerInputs) .setOutputCol("features")

// Combina os estágios no pipeline val


stage = Array(stringIndexer, vecAssembler, dt) val pipeline =
new Pipeline() .setStages(stages)

val pipelineModel = pipeline.fit(trainDF) // Esta linha deve apresentar erro

Isso produz o seguinte erro:

java.lang.IllegalArgumentException: requisito falhou: DecisionTree requer que maxBins (= 32)


seja pelo menos tão grande quanto o número de valores em cada recurso categórico,
mas o recurso categórico 3 tem 36 valores. Considere remover este e outros recursos categóricos
com um grande número de valores ou adicionar mais exemplos de treinamento.

Podemos ver que há um problema com o parâmetro maxBins . O que esse parâmetro faz?
maxBins determina o número de compartimentos nos quais seus recursos contínuos são
discretizados ou divididos. Esta etapa de discretização é crucial para a realização de
treinamento distribuído. Não há parâmetro maxBins no scikit-learn porque todos os dados e
o modelo residem em uma única máquina. No Spark, entretanto, os trabalhadores possuem
todas as colunas dos dados, mas apenas um subconjunto das linhas. Assim, ao comunicar
sobre quais recursos e valores dividir, precisamos ter certeza de que todos estão falando
sobre os mesmos valores de divisão, que obtemos da discretização comum configurada no
momento do treinamento. Vamos dar uma olhada na Figura 10.10, que mostra a
implementação PLANET de árvores de decisão distribuídas, para obter uma melhor
compreensão do aprendizado de máquina distribuído e ilustrar o parâmetro maxBins .

310 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

Figura 10-10. Implementação PLANET de árvores de decisão distribuídas (fonte: https://


oreil.ly/RAvvP)

Cada trabalhador deve calcular estatísticas resumidas para cada recurso e cada ponto de divisão
possível, e essas estatísticas serão agregadas entre os trabalhadores. MLlib exige que maxBins
seja grande o suficiente para lidar com a discretização das colunas categóricas.
O valor padrão para maxBins é 32 e tínhamos uma coluna categórica com 36 valores distintos,
e é por isso que recebemos o erro anteriormente. Embora pudéssemos aumentar maxBins para
64 para representar com mais precisão nossos recursos contínuos, isso dobraria o número de
divisões possíveis para variáveis contínuas, aumentando muito nosso tempo de cálculo.
Em vez disso, vamos definir maxBins como 40 e treinar novamente o pipeline. Você notará aqui
que estamos usando o método setter setMaxBins() para modificar a árvore de decisão em vez
de redefini-la completamente:

# Em Python
dt.setMaxBins(40)
pipelineModel = pipeline.fit(trainDF)

// Em Scala
dt.setMaxBins(40)
val pipelineModel = pipeline.fit(trainDF)

Ajuste de hiperparâmetros | 311


Machine Translated by Google

Devido a diferenças na implementação, muitas vezes você não obterá


exatamente os mesmos resultados ao construir um modelo com scikit-
learn versus MLlib. No entanto, tudo bem. A chave é entender por que
eles são diferentes e ver quais parâmetros estão sob seu controle para
que eles funcionem da maneira que você precisa. Se você estiver
migrando cargas de trabalho do scikit-learn para o MLlib, recomendamos
que você dê uma olhada na documentação spark.ml e scikit-learn para
ver quais parâmetros diferem e ajustá-los para obter resultados
comparáveis para os mesmos dados. Quando os valores estiverem
próximos o suficiente, você poderá ampliar seu modelo MLlib para
tamanhos de dados maiores que o scikit-learn não consegue suportar.

Agora que construímos nosso modelo com sucesso, podemos extrair as regras if-then-else aprendidas
pela árvore de decisão:

# Em Python
dtModel = pipelineModel.stages[-1]
print(dtModel.toDebugString)

// Em Scala
val dtModel =
pipelineModel.stages.last .asInstanceOf[org.apache.spark.ml.regression.DecisionTreeRegressionModel]
println(dtModel.toDebugString)

DecisionTreeRegressionModel: uid=dtr_005040f1efac, profundidade=5, numNodes=47,...


Se (recurso 12 <= 2,5)
Se (recurso 12 <= 1,5)
Se (recurso 5 em {1.0,2.0})
Se (recurso 4 em {0.0,1.0,3.0,5.0,9.0,10.0,11.0,13.0,14.0,16.0,18.0,24.0})
If (recurso 3 em
{0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,...})
Prever: 104.23992784125075
Caso contrário (recurso 3 não em {0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,...})
Prever: 250.7111111111111
...

Este é apenas um subconjunto da impressão, mas você notará que é possível dividir o mesmo recurso
mais de uma vez (por exemplo, recurso 12), mas com valores de divisão diferentes. Observe também a
diferença entre como a árvore de decisão se divide em recursos numéricos e recursos categóricos: para
recursos numéricos, ela verifica se o valor é menor ou igual ao limite e, para recursos categóricos,
verifica se o valor está naquele conjunto ou não.

Também podemos extrair as pontuações de importância dos recursos de nosso modelo para ver os
recursos mais importantes:

# Em Python
importe pandas como pd

featureImp =
pd.DataFrame( lista(zip(vecAssembler.getInputCols(), dtModel.featureImportances)),

312 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

colunas=["recurso", "importância"])
featureImp.sort_values(by="importância", ascendente=Falso)

//Em Scala
val featureImp = vecAssembler
.getInputCols.zip(dtModel.featureImportances.toArray)
val colunas = Array("recurso", "Importância")
val featureImpDF = spark.createDataFrame(featureImp).toDF(colunas: _*)

featureImpDF.orderBy($"Importância".desc).show()

Recurso Importância
quartos de dormir 0,283406

cancel_policyIndex 0,167893

instant_bookableIndex 0,140081

property_typeIndex 0,128179

number_of_reviews 0,126233

bairro_cleansedIndex 0,056200

longitude 0,038810

mínima_noites 0,029473

camas 0,015218

room_typeIndex 0,010905

acomoda 0,003603

Embora as árvores de decisão sejam muito flexíveis e fáceis de usar, nem sempre são as mais adequadas.
2
modelo preciso. Se calculássemos nosso R no conjunto de dados de teste, na verdade

obter uma pontuação negativa! Isso é pior do que apenas prever a média. (Você pode ver isso em

caderno deste capítulo no repositório GitHub do livro.)

Vejamos como melhorar este modelo usando uma abordagem de conjunto que combina diferentes
modelos diferentes para alcançar um melhor resultado: florestas aleatórias.

Florestas aleatórias

Os conjuntos funcionam adotando uma abordagem democrática. Imagine que há muitos M&Ms em

uma jarra. Você pede a cem pessoas que adivinhem o número de M&Ms e depois pega o

média de todas as suposições. A média está provavelmente mais próxima do valor verdadeiro do que a maioria

dos palpites individuais. Esse mesmo conceito se aplica aos modelos de aprendizado de máquina. Se

você constrói muitos modelos e combina / calcula a média de suas previsões, eles serão mais

robusto do que aqueles produzidos por qualquer modelo individual.

As florestas aleatórias são um conjunto de árvores de decisão com dois ajustes principais:

Ajuste de hiperparâmetros | 313


Machine Translated by Google

Inicializando amostras por linhas


Bootstrapping é uma técnica para simular novos dados por amostragem com substituição de seus
dados originais. Cada árvore de decisão é treinada em uma amostra de inicialização diferente do
seu conjunto de dados, o que produz árvores de decisão ligeiramente diferentes, e então você
agrega suas previsões. Essa técnica é conhecida como agregação de bootstrap ou empacotamento.
Em uma implementação típica de floresta aleatória, cada árvore amostra o mesmo número de
pontos de dados com substituição do conjunto de dados original, e esse número pode ser controlado
por meio do parâmetro subsamplingRate .

Seleção aleatória de recursos por colunas


A principal desvantagem do bagging é que todas as árvores são altamente correlacionadas e,
portanto, aprendem padrões semelhantes em seus dados. Para atenuar esse problema, cada vez
que você quiser fazer uma divisão, considere apenas um subconjunto aleatório das colunas (1/3
dos recursos para RandomForestRegressor e #features para RandomForestClas sifier). Devido a
essa aleatoriedade que você introduz, normalmente você deseja que cada árvore seja bem rasa.
Você pode estar pensando: cada uma dessas árvores terá um desempenho pior do que qualquer
árvore de decisão isolada, então como essa abordagem poderia ser melhor? Acontece que cada
uma das árvores aprende algo diferente sobre o seu conjunto de dados, e combinar essa coleção
de aprendizes “fracos” em um conjunto torna a floresta muito mais robusta do que uma única árvore
de decisão.

A Figura 10.11 ilustra uma floresta aleatória em tempo de treinamento. Em cada divisão, ele considera 3
dos 10 recursos originais para divisão; finalmente, ele escolhe o melhor entre eles.

Figura 10-11. Treinamento florestal aleatório

314 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

As APIs para florestas aleatórias e árvores de decisão são semelhantes e ambas podem ser aplicadas a tarefas
de regressão ou classificação:

# Em Python
de pyspark.ml.regression import RandomForestRegressor rf =
RandomForestRegressor(labelCol="price", maxBins=40, seed=42)

// Em Scala
importar org.apache.spark.ml.regression.RandomForestRegressor val rf
= new

RandomForestRegressor() .setLabelCol("price") .setMaxBins(40) .setSeed(42)

Depois de treinar sua floresta aleatória, você poderá passar novos pontos de dados pelas diferentes árvores
treinadas no conjunto.

Como mostra a Figura 10.12, se você construir uma floresta aleatória para classificação, ela passará no ponto de
teste por cada uma das árvores da floresta e terá uma votação majoritária entre as previsões das árvores
individuais. (Por outro lado, na regressão, a floresta aleatória simplesmente calcula a média dessas previsões.)
Embora cada uma dessas árvores tenha um desempenho inferior ao de qualquer árvore de decisão individual, a
coleção (ou conjunto) na verdade fornece um modelo mais robusto.

Figura 10-12. Previsões florestais aleatórias

As florestas aleatórias demonstram verdadeiramente o poder do aprendizado de máquina distribuído com o


Spark, pois cada árvore pode ser construída independentemente das outras árvores (por exemplo, você não
precisa construir a árvore 3 antes de construir a árvore 10). Além disso, dentro de cada nível da árvore, você
pode paralelizar o trabalho para encontrar as divisões ideais.

Ajuste de hiperparâmetros | 315


Machine Translated by Google

Então, como determinamos qual deveria ser o número ideal de árvores em nossa floresta aleatória ou a
profundidade máxima dessas árvores? Esse processo é chamado de ajuste de hiperparâmetros.
Ao contrário de um parâmetro, um hiperparâmetro é um valor que controla o processo de aprendizagem
ou a estrutura do seu modelo e não é aprendido durante o treinamento. Tanto o número de árvores quanto
a profundidade máxima são exemplos de hiperparâmetros que você pode ajustar para florestas aleatórias.
Vamos agora mudar nosso foco para como podemos descobrir e avaliar o melhor modelo de floresta
aleatória ajustando alguns hiperparâmetros.

Validação cruzada k-Fold

Qual conjunto de dados devemos usar para determinar os valores ideais dos hiperparâmetros? Se usarmos
o conjunto de treinamento, é provável que o modelo se ajuste demais ou memorize as nuances de nossos
dados de treinamento. Isso significa que será menos provável generalizar para dados não vistos. Mas se
usarmos o conjunto de testes, então ele não representará mais dados “invisíveis”, portanto não poderemos
usá-lo para verificar quão bem nosso modelo generaliza. Assim, precisamos de outro conjunto de dados
para nos ajudar a determinar os hiperparâmetros ideais: o conjunto de dados de validação.

Por exemplo, em vez de dividir nossos dados em uma divisão de treinamento/teste 80/20, como fizemos
anteriormente, podemos fazer uma divisão 60/20/20 para gerar conjuntos de dados de treinamento,
validação e teste, respectivamente. Podemos então construir nosso modelo no conjunto de treinamento,
avaliar o desempenho no conjunto de validação para selecionar a melhor configuração de hiperparâmetros
e aplicar o modelo ao conjunto de teste para ver seu desempenho em novos dados. No entanto, uma das
desvantagens desta abordagem é que perdemos 25% dos nossos dados de treinamento (80% -> 60%),
que poderiam ter sido usados para ajudar a melhorar o modelo. Isso motiva o uso da técnica de validação
cruzada k-fold para resolver este problema.

Com essa abordagem, em vez de dividir o conjunto de dados em conjuntos separados de treinamento,
validação e teste, nós o dividimos em conjuntos de treinamento e teste como antes – mas usamos os
dados de treinamento tanto para treinamento quanto para validação. Para conseguir isso, dividimos nossos
dados de treinamento em k subconjuntos, ou “dobras” (por exemplo, três). Então, para uma determinada
configuração de hiperparâmetros, treinamos nosso modelo em k –1 dobras e avaliamos na dobra restante,
repetindo esse processo k vezes. A Figura 10-13 ilustra essa abordagem.

Figura 10-13. validação cruzada k-fold

Como mostra esta figura, se dividirmos nossos dados em três dobras, nosso modelo será primeiro treinado
na primeira e segunda dobras (ou divisões) dos dados e avaliado na terceira dobra. Em seguida,
construímos o mesmo modelo com os mesmos hiperparâmetros na primeira e terceira dobras

316 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

dos dados e avaliar seu desempenho na segunda dobra. Por fim, construímos o modelo na segunda e
terceira dobras e avaliamos na primeira dobra. Em seguida, calculamos a média do desempenho desses
três (ou k) conjuntos de dados de validação como um proxy do desempenho desse modelo em dados
invisíveis, já que cada ponto de dados teve a chance de fazer parte do conjunto de dados de validação
exatamente uma vez. A seguir, repetimos esse processo para todas as nossas diferentes configurações
de hiperparâmetros para identificar a configuração ideal.

Determinar o espaço de pesquisa de seus hiperparâmetros pode ser difícil e, muitas vezes, fazer uma
pesquisa aleatória de hiperparâmetros supera uma pesquisa de grade estruturada.
Existem bibliotecas especializadas, como o Hyperopt, para ajudá-lo a identificar as configurações ideais
de hiperparâmetros, que abordaremos no Capítulo 11.

Para realizar uma pesquisa de hiperparâmetros no Spark, siga as seguintes etapas:

1. Defina o estimador que deseja avaliar.

2. Especifique quais hiperparâmetros deseja variar, bem como seus respectivos valores, utilizando o
ParamGridBuilder.

3. Defina um avaliador para especificar qual métrica usar para comparar os vários
modelos.

4. Use o CrossValidator para realizar a validação cruzada, avaliando cada uma das variáveis
nossos modelos.

Vamos começar definindo nosso estimador de pipeline:

# No pipeline
Python = Pipeline(stages = [stringIndexer, vecAssembler, rf])

// No Scala val
pipeline = new
Pipeline() .setStages(Array(stringIndexer, vecAssembler, rf))

Para nosso ParamGridBuilder, variaremos nosso maxDepth para 2, 4 ou 6 e numTrees (o número de


árvores em nossa floresta aleatória) para 10 ou 100. Isso nos dará uma grade de 6 (3 x 2) diferentes.
configurações de hiperparâmetros no total:

(maxDepth=2, numTrees=10)
(maxDepth=2, numTrees=100)
(maxDepth=4, numTrees=10)
(maxDepth=4, numTrees=100)
(maxDepth=6, numTrees=10)
(maxDepth=6, numÁrvores=100)

# Em Python de
pyspark.ml.tuning import ParamGridBuilder paramGrid =
(ParamGridBuilder()
.addGrid(rf.maxDepth, [2, 4,
6]) .addGrid(rf.numTrees, [10, 100]) .build())

Ajuste de hiperparâmetros | 317


Machine Translated by Google

// Em Scala
importar org.apache.spark.ml.tuning.ParamGridBuilder
val paramGrid = new
ParamGridBuilder() .addGrid(rf.maxDepth,
Array(2, 4, 6)) .addGrid(rf.numTrees,
Array(10, 100)) .construir()

Agora que configuramos nossa grade de hiperparâmetros, precisamos definir como avaliar cada
um dos modelos para determinar qual deles teve melhor desempenho. Para esta tarefa usaremos
o RegressionEvaluator e usaremos o RMSE como nossa métrica de interesse:

# Em Python
avaliador = RegressionEvaluator(labelCol="price",
previsãoCol="predição",
metricName="rmse")

// Em Scala
val avaliador = new RegressionEvaluator()

.setLabelCol("preço") .setPredictionCol("predição") .setMetricName("rmse")

Realizaremos nossa validação cruzada k-fold usando o CrossValidator, que aceita um estimador,
um avaliador e um estimatorParamMaps para saber qual modelo usar, como avaliar o modelo e
quais hiperparâmetros definir para o modelo. Também podemos definir o número de dobras em
que queremos dividir nossos dados (numFolds=3), bem como definir uma semente para que
tenhamos divisões reproduzíveis entre as dobras (seed=42). Vamos então ajustar este validador
cruzado ao nosso conjunto de dados de treinamento:

# Em Python
de pyspark.ml.tuning import CrossValidator

cv = CrossValidator(estimador=pipeline,
avaliador=avaliador,
estimadorParamMaps=paramGrid,
numFolds=3,
seed=42)
cvModel = cv.fit(trainDF)

// No Scala
importe org.apache.spark.ml.tuning.CrossValidator

val cv = novo

CrossValidator() .setEstimator(pipeline) .setEvaluator(avaliador) .setEstimatorParamMaps(paramGrid) .setNumFold

318 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

A saída nos informa quanto tempo a operação demorou:

O comando demorou 1,07 minutos

Então, quantos modelos acabamos de treinar? Se você respondeu 18 (6 configurações de hiperparâmetros


x validação cruzada tripla), você está perto. Depois de identificar a configuração ideal de hiperparâmetros,
como combinar esses três (ou k) modelos? Embora alguns modelos possam ser fáceis de calcular em
média, outros não o são. Portanto, o Spark treina novamente seu modelo em todo o conjunto de dados de
treinamento depois de identificar a configuração ideal do hiperparâmetro, portanto, no final, treinamos 19
modelos. Se quiser manter os modelos intermediários treinados, você pode definir collect SubModels=True
no CrossValidator.

Para inspecionar os resultados do validador cruzado, você pode dar uma olhada no avgMetrics:

# Na lista
Python (zip(cvModel.getEstimatorParamMaps(), cvModel.avgMetrics))

// Em Scala
cvModel.getEstimatorParamMaps.zip(cvModel.avgMetrics)

Aqui está o resultado:

res1: Array[(org.apache.spark.ml.param.ParamMap, Double)] =

Array(({ rfr_a132fb1ab6c8-maxDepth: 2,
rfr_a132fb1ab6c8-numTrees:
10 },303.99522869739343),
({ rfr_a132fb1ab6c8- profundidade
máxima: 2, rfr_a132fb1ab6c8 -numTrees:
100 },299.56501993529474),
({ rfr_a132fb1ab6c8-maxDepth: 4,
rfr_a132fb1ab6c8-numTrees:
10 },310.63687030886894),
({ rfr_a132fb1ab6c8- profundidade
máxima: 4, rfr_a132fb1ab6c8-numTrees:
100 },294.7369599168999),
({ rfr_a132fb1ab6c8-maxDepth : 6,
rfr_a132fb1ab6c8-numTrees:
10 },312.6678169109293),
({ rfr_a132fb1ab6c8-maxDepth: 6,
rfr_a132fb1ab6c8-numTrees:
100 },292.101039874209))

Podemos ver que o melhor modelo do nosso CrossValidator (aquele com o menor RMSE) tinha
maxDepth=6 e numTrees=100. No entanto, isso demorou muito para ser executado. Na próxima seção,
veremos como podemos diminuir o tempo de treinamento de nosso modelo, mantendo o mesmo
desempenho do modelo.

Ajuste de hiperparâmetros | 319


Machine Translated by Google

Otimizando Pipelines Se seu

código demorar o suficiente para você pensar em melhorá-lo, então você deve otimizá-lo. No código anterior,
embora cada um dos modelos no validador cruzado seja tecnicamente independente, spark.ml na verdade treina
a coleção de modelos sequencialmente, em vez de em paralelo. No Spark 2.3, um parâmetro de paralelismo foi
introduzido para resolver este problema. Este parâmetro determina o número de modelos a serem treinados em
paralelo, os quais são ajustados em paralelo. Do Guia de ajuste do Spark:

O valor do paralelismo deve ser escolhido cuidadosamente para maximizar o paralelismo sem
exceder os recursos do cluster, e valores maiores nem sempre podem levar a um melhor
desempenho. De modo geral, um valor até 10 deve ser suficiente para a maioria dos clusters.

Vamos definir esse valor como 4 e ver se conseguimos treinar mais rápido:

# Em Python
cvModel = cv.setParallelism(4).fit(trainDF)

// Em Scala
val cvModel = cv.setParallelism(4).fit(trainDF)

A resposta é sim:

O comando demorou 31,45 segundos

Reduzimos o tempo de treino pela metade (de 1,07 minutos para 31,45 segundos), mas ainda podemos melhorá-lo
ainda mais! Há outro truque que podemos usar para acelerar o treinamento do modelo: colocar o validador cruzado
dentro do pipeline (por exemplo, Pipeline(stages=[..., cv]) em vez de colocar o pipeline dentro do validador cruzado
(por exemplo, CrossValidator( estimador=pipeline, ...)). Cada vez que o validador cruzado avalia o pipeline, ele
percorre todas as etapas do pipeline para cada modelo, mesmo que algumas das etapas não mudem, como o
StringIndexer . reavaliando cada etapa do pipeline, estamos aprendendo o mesmo mapeamento StringIndexer
repetidamente, mesmo que ele não esteja mudando.

Se, em vez disso, colocarmos nosso validador cruzado dentro de nosso pipeline, não estaremos reavaliando o
StringIndexer (ou qualquer outro estimador) cada vez que tentarmos um modelo diferente:

# Em Python
cv = CrossValidator(estimador=rf,
avaliador=avaliador,
estimadorParamMaps=paramGrid,
numFolds=3,
paralelismo=4,
seed=42)

pipeline = Pipeline(stages=[stringIndexer, vecAssembler, cv]) pipelineModel


= pipeline.fit(trainDF)

// Em Scala
val cv = new CrossValidator()

320 | Capítulo 10: Aprendizado de Máquina com MLlib


Machine Translated by Google

.setEstimator(rf) .setEvaluator(avaliador) .setEstimatorParamMaps(paramGrid) .setNumFolds(3) .setParallelism(4) .se

val pipeline = novo pipeline()


.setStages(Array(stringIndexer, vecAssembler, cv)) val
pipelineModel = pipeline.fit(trainDF)

Isso reduz cinco segundos do nosso tempo de treinamento:

O comando demorou 26,21 segundos

Graças ao parâmetro de paralelismo e à reorganização da ordem do nosso pipeline, a última


execução foi a mais rápida – e se você aplicá-la ao conjunto de dados de teste, verá que obterá
os mesmos resultados. Embora estes ganhos tenham sido da ordem de segundos, as mesmas
técnicas aplicam-se a conjuntos de dados e modelos muito maiores, com poupanças de tempo
correspondentemente maiores. Você pode tentar executar esse código acessando o notebook no
repositório GitHub do livro.

Resumo

Neste capítulo, abordamos como construir pipelines usando Spark MLlib — em particular, seu
pacote API baseado em DataFrame, spark.ml. Discutimos as diferenças entre transformadores e
estimadores, como compô-los usando a API Pipeline e algumas métricas diferentes para avaliação
de modelos. Em seguida, exploramos como usar a validação cruzada para realizar o ajuste de
hiperparâmetros para entregar o melhor modelo, bem como dicas para otimizar a validação
cruzada e o treinamento do modelo no Spark.

Tudo isso define o contexto para o próximo capítulo, no qual discutiremos estratégias de
implantação e formas de gerenciar e dimensionar pipelines de aprendizado de máquina com Spark.

Resumo | 321
Machine Translated by Google
Machine Translated by Google

CAPÍTULO 11

Gerenciando, implantando e dimensionando a máquina


Pipelines de aprendizagem com Apache Spark

No capítulo anterior, abordamos como construir pipelines de aprendizado de máquina com MLlib.
Este capítulo se concentrará em como gerenciar e implantar os modelos que você treina. Ao final
deste capítulo, você será capaz de usar o MLflow para rastrear, reproduzir e implantar seus modelos
MLlib, discutir as dificuldades e compensações entre vários cenários de implantação de modelos e
arquitetar soluções escalonáveis de aprendizado de máquina. Mas antes de discutirmos a
implantação de modelos, vamos primeiro discutir algumas práticas recomendadas para
gerenciamento de modelos para deixar seus modelos prontos para implantação.

Gerenciamento de modelo
Antes de implantar seu modelo de aprendizado de máquina, você deve garantir que pode reproduzir
e acompanhar o desempenho do modelo. Para nós, a reprodutibilidade ponta a ponta das soluções
de aprendizado de máquina significa que precisamos ser capazes de reproduzir o código que gerou
um modelo, o ambiente usado no treinamento, os dados nos quais foi treinado e o próprio modelo.
Todo cientista de dados adora lembrá-lo de definir suas sementes para que possa reproduzir seus
experimentos (por exemplo, para a divisão de treinamento/teste, ao usar modelos com aleatoriedade
inerente, como florestas aleatórias). No entanto, existem muitos mais aspectos que contribuem
para a reprodutibilidade do que apenas estabelecer sementes, e alguns deles são muito mais sutis.
Aqui estão alguns exemplos:

Controle de versão
de biblioteca Quando um cientista de dados lhe entrega seu código, ele pode ou não mencionar
as bibliotecas dependentes. Embora você seja capaz de descobrir quais bibliotecas são
necessárias analisando as mensagens de erro, você não terá certeza de quais versões de
biblioteca elas usaram, então provavelmente instalará as mais recentes. Mas se o código deles
foi construído em uma versão anterior de uma biblioteca, o que pode estar aproveitando algum padrão

323
Machine Translated by Google

comportamento diferente da versão que você instalou, usar a versão mais recente pode fazer com
que o código seja quebrado ou os resultados sejam diferentes (por exemplo, considere como o
XGBoost mudou a forma como ele lida com valores ausentes na v0.90).

Evolução de
dados Suponha que você construa um modelo em 1º de junho de 2020 e acompanhe todos os
seus hiperparâmetros, bibliotecas etc. Você então tenta reproduzir o mesmo modelo em 1º de
julho de 2020 - mas o pipeline quebra ou os resultados diferem porque os dados subjacentes
foram alterados, o que poderia acontecer se alguém adicionasse uma coluna extra ou uma ordem
de magnitude a mais de dados após a construção inicial.

Ordem de execução
Se um cientista de dados lhe entregar seu código, você poderá executá-lo de cima para baixo
sem erros. No entanto, os cientistas de dados são conhecidos por executar as coisas fora de
ordem ou por executar a mesma célula com estado várias vezes, tornando seus resultados muito
difíceis de reproduzir. (Eles também podem fazer check-in de uma cópia do código com
hiperparâmetros diferentes daqueles usados para treinar o modelo final!)

Operações paralelas
Para maximizar o rendimento, as GPUs executarão muitas operações em paralelo. No entanto, a
ordem de execução nem sempre é garantida, o que pode levar a resultados não determinísticos.
Este é um problema conhecido com funções como tf.reduce_sum() e ao agregar números de
ponto flutuante (que têm precisão limitada): a ordem em que você os adiciona pode gerar
resultados ligeiramente diferentes, o que pode ser exacerbado em muitas iterações.

A incapacidade de reproduzir seus experimentos muitas vezes pode ser um obstáculo para que as
unidades de negócios adotem seu modelo ou o coloquem em produção. Embora você possa criar suas
próprias ferramentas internas para rastrear seus modelos, dados, versões de dependência, etc., elas
podem se tornar obsoletas, frágeis e exigir um esforço de desenvolvimento significativo para serem
mantidas. Igualmente importante é ter padrões de gerenciamento de modelos em todo o setor, para
que possam ser facilmente compartilhados com parceiros. Existem ferramentas de código aberto e
proprietárias que podem nos ajudar a reproduzir nossos experimentos de aprendizado de máquina,
abstraindo muitas dessas dificuldades comuns. Esta seção se concentrará no MLflow, pois ele tem a
integração mais estreita com o MLlib das ferramentas de gerenciamento de modelos de código aberto
atualmente disponíveis.

M Baixo
MLflow é uma plataforma de código aberto que ajuda os desenvolvedores a reproduzir e compartilhar
experimentos, gerenciar modelos e muito mais. Ele fornece interfaces em Python, R e Java/Scala, bem
como uma API REST. Conforme mostrado na Figura 11-1, o MLflow tem quatro componentes principais:

324 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google

Rastreamento
Fornece APIs para registrar parâmetros, métricas, versões de código, modelos e artefatos, como gráficos e texto.

Projetos
Um formato padronizado para empacotar seus projetos de ciência de dados e suas dependências para execução em
outras plataformas. Ajuda você a gerenciar o processo de treinamento do modelo.

modelos

Um formato padronizado para empacotar modelos para implantar em diversos ambientes de execução. Ele fornece
uma API consistente para carregar e aplicar modelos, independentemente do algoritmo ou biblioteca usada para
construir o modelo.

Registro
Um repositório para acompanhar a linhagem do modelo, versões do modelo, transições de estágio e anotações.

Figura 11-1. Componentes MBaixos

Vamos acompanhar os experimentos do modelo MLlib que executamos no Capítulo 10 para verificar a reprodutibilidade.
Veremos então como os outros componentes do MLflow entram em ação quando discutirmos a implantação do modelo.

Para começar a usar o MLflow, basta executar pip install mlflow em seu host local.

Tracking

MLflow Tracking é uma API de registro independente das bibliotecas e ambientes que realmente realizam o treinamento.
Está organizado em torno do conceito de execuções, que são execuções de código de ciência de dados. As execuções
são agregadas em experimentos, de modo que muitas execuções possam fazer parte de um determinado experimento.

O servidor de rastreamento MLflow pode hospedar muitos experimentos. Você pode fazer login no servidor de rastreamento
usando um notebook, um aplicativo local ou um trabalho na nuvem, conforme mostrado na Figura 11-2.

Gestão de Modelos | 325


Machine Translated by Google

Figura 11-2. Servidor de rastreamento Mlow

Vamos examinar algumas coisas que podem ser registradas no servidor de rastreamento:

Parâmetros

Entradas de chave/valor para seu código – por exemplo, hiperparâmetros como num_trees ou
max_profundidade em sua floresta aleatória

Métricas

Valores numéricos (podem ser atualizados ao longo do tempo) — por exemplo, RMSE ou valores de precisão

Artefatos
Arquivos, dados e modelos – por exemplo, imagens matplotlib ou arquivos Parquet

Metadados
Informações sobre a execução, como o código-fonte que executou a execução ou a versão do código (por
exemplo, a string hash de commit do Git para a versão do código)

modelos

O(s) modelo(s) que você treinou

Por padrão, o servidor de rastreamento registra tudo no sistema de arquivos, mas você pode especificar um banco
de dados para consultas mais rápidas, como parâmetros e métricas. Vamos adicionar o rastreamento do MLflow
ao nosso código florestal aleatório do Capítulo 10:

326 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google

# Em Python de
pyspark.ml import Pipeline de
pyspark.ml.feature import StringIndexer, VectorAssembler de pyspark.ml.regression
import RandomForestRegressor de pyspark.ml.evaluation import
RegressionEvaluator

filePath = """/databricks-datasets/learning-spark-v2/sf-airbnb/ sf-airbnb-clean.parquet"""


airbnbDF = spark.read.parquet(filePath)
(trainDF, testDF) = airbnbDF.randomSplit( [0,8, 0,2],
semente = 42)

categoricalCols = [campo para (campo, dataType) em trainDF.dtypes if dataType == "string"]


indexOutputCols = [x + "Índice"
para x em categoricalCols] stringIndexer = StringIndexer(inputCols=categoricalCols,
outputCols=indexOutputCols, handleInvalid=" pular")

numericCols = [campo para (campo, dataType) em trainDF.dtypes if ((dataType ==


"duplo") & (campo ! = "preço"))]
assemblerInputs = indexOutputCols + numericCols vecAssembler =
VectorAssembler(inputCols=assemblerInputs, outputCol="recursos")

rf = RandomForestRegressor(labelCol="preço", maxBins=40, maxDepth=5,


numTrees=100, semente=42)

pipeline = Pipeline(estágios=[stringIndexer, vecAssembler, rf])

Para iniciar o registro com MLflow, você precisará iniciar uma execução usando mlflow.start_run().
Em vez de chamar explicitamente mlflow.end_run(), os exemplos neste capítulo usarão uma
cláusula with para encerrar automaticamente a execução no final do bloco with :

# Em Python
import mlflow
import mlflow.spark import
pandas as pd

com mlflow.start_run(run_name="random-forest") como executado:


# Parâmetros de log: num_trees e max_profundidade
mlflow.log_param("num_trees", rf.getNumTrees())
mlflow.log_param("max_profundidade", rf.getMaxDepth())

# Modelo de log
pipelineModel = pipeline.fit(trainDF)
mlflow.spark.log_model(pipelineModel, "model")

# Métricas de log: RMSE e R2 predDF


= pipelineModel.transform(testDF) regressEvaluator =
RegressionEvaluator(predictionCol="prediction",
labelCol="preço")
rmse = regressãoEvaluator.setMetricName("rmse").avaliar(predDF)

Gestão de Modelos | 327


Machine Translated by Google

r2 = regressãoEvaluator.setMetricName("r2").evaluate(predDF)
mlflow.log_metrics({"rmse": rmse, "r2": r2})

# Artefato de log: pontuações de importância do


recurso rfModel = pipelineModel.stages[-1]
pandasDF = (pd.DataFrame(list(zip(vecAssembler.getInputCols(),
rfModel.featureImportances)),
columns=["feature",
"importance"] ) .sort_values(by="importância", ascendente=Falso))

# Primeiro escreva no sistema de arquivos local e depois diga ao MLflow onde encontrar esse
arquivo pandasDF.to_csv("feature-importance.csv", index=False)
mlflow.log_artifact("feature-importance.csv")

Vamos examinar a UI do MLflow, que você pode acessar executando mlflow ui em seu terminal e
navegando até http://localhost:5000/. A Figura 11-3 mostra uma captura de tela da UI.

Figura 11-3. A IU Mlow

A IU armazena todas as execuções de um determinado experimento. Você pode pesquisar todas as


execuções, filtrar aquelas que atendem a critérios específicos, comparar execuções lado a lado, etc. Se
desejar, você também pode exportar o conteúdo como um arquivo CSV para analisar localmente. Clique
na execução na IU chamada "floresta aleatória". Você deverá ver uma tela como a da Figura 11-4.

328 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google

Figura 11-4. Corrida aleatória na floresta

Você notará que ele acompanha o código-fonte usado para esta execução do MLflow, bem como armazena
todos os parâmetros, métricas correspondentes, etc. Você pode adicionar notas sobre esta execução em texto
livre, bem como tags. Você não pode modificar os parâmetros ou métricas após a conclusão da execução.

Você também pode consultar o servidor de rastreamento usando MlflowClient ou API REST:

# Em Python
de mlflow.tracking importar MlflowClient

cliente = MlflowClient() é
executado = client.search_runs(run.info.experiment_id,
order_by=["attributes.start_time desc"],
max_results=1)

run_id = executa[0].info.run_id
executa[0].data.metrics

Gestão de Modelos | 329


Machine Translated by Google

Isso produz a seguinte saída:

{'r2': 0,22794251914574226, 'rmse': 211,5096898777315}


Hospedamos esse código como um projeto MLflow no repositório GitHub deste livro, para que você
possa experimentar executá-lo com diferentes valores de hiperparâmetro para max_profundidade
e num_trees. O arquivo YAML dentro do projeto MLflow especifica as dependências da biblioteca
para que este código possa ser executado em outros ambientes:

# Em Python

mlflow.run( "https://github.com/databricks/LearningSparkV2/#mlflow-project-example",
parâmetros={"max_profundidade": 5, "num_trees": 100})

#Ou na linha de comando


mlflow executado https://github.com/databricks/LearningSparkV2/#mlflow-project-example -P
max_profundidade=5 -P num_trees=100
Agora que você acompanhou e reproduziu seus experimentos, vamos discutir as diversas opções
de implantação disponíveis para seus modelos MLlib.

Opções de implantação de modelo com MLlib


A implantação de modelos de aprendizado de máquina significa algo diferente para cada
organização e caso de uso. As restrições de negócios imporão diferentes requisitos de latência,
taxa de transferência, custo, etc., que determinam qual modo de implantação do modelo é adequado
para a tarefa em questão – seja em lote, streaming, tempo real ou móvel/incorporado. A implantação
de modelos em sistemas móveis/embarcados está fora do escopo deste livro, por isso nos
concentraremos principalmente nas outras opções. A Tabela 11-1 mostra as compensações de
rendimento e latência para essas três opções de implantação para gerar previsões. Nós nos
preocupamos tanto com o número de solicitações simultâneas quanto com o tamanho dessas
solicitações, e as soluções resultantes serão bem diferentes.

Tabela 11-1. Comparação em lote, streaming e em tempo real

Latência de rendimento Aplicação de exemplo

Lote Alto Alto (horas a dias) Previsão de rotatividade de clientes

Meio de streaming Preços dinâmicos médios (segundos a minutos)

Baixa em tempo real Baixo (milissegundos) Lance de anúncios online

O processamento em lote gera previsões regularmente e grava os resultados em armazenamento


persistente para serem servidos em outro lugar. Normalmente é a opção de implantação mais
barata e fácil, pois você só precisa pagar pela computação durante a execução programada.
O processamento em lote é muito mais eficiente por ponto de dados porque você acumula menos
sobrecarga quando amortizado em todas as previsões feitas. Este é particularmente o caso do
Spark, devido à sobrecarga de comunicação entre o driver e os executores - você não gostaria de
fazer previsões em um ponto de dados por vez.

330 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google

tempo! Porém, sua principal desvantagem é a latência, pois normalmente é agendado com um
período de horas ou dias para gerar o próximo lote de previsões.

O streaming oferece uma boa compensação entre taxa de transferência e latência. Você fará
previsões continuamente em microlotes de dados e obterá suas previsões em segundos ou
minutos. Se você estiver usando streaming estruturado, quase todo o seu código parecerá idêntico
ao caso de uso em lote, facilitando a alternância entre essas duas opções. Com o streaming, você
terá que pagar pelas VMs ou pelos recursos de computação usados para permanecer
continuamente em funcionamento e garantir que configurou o stream corretamente para ser
tolerante a falhas e fornecer buffer se houver picos nos dados recebidos.

A implantação em tempo real prioriza a latência em relação ao rendimento e gera previsões em


alguns milissegundos. Sua infraestrutura precisará suportar balanceamento de carga e ser capaz
de escalar para muitas solicitações simultâneas se houver um grande aumento na demanda (por
exemplo, para varejistas on-line na época dos feriados). Às vezes, quando as pessoas dizem
“implantação em tempo real”, elas querem dizer extrair previsões pré-computadas em tempo real,
mas aqui estamos nos referindo à geração de previsões de modelos em tempo real. A implantação
em tempo real é a única opção para a qual o Spark não consegue atender aos requisitos de
latência, portanto, para usá-la, você precisará exportar seu modelo para fora do Spark. Por
exemplo, se você pretende usar um endpoint REST para inferência de modelo em tempo real
(digamos, calcular previsões em menos de 50 ms), o MLlib não atende aos requisitos de latência
necessários para esta aplicação, conforme mostrado na Figura 11-5. Você precisará obter a
preparação de recursos e o modelo do Spark, o que pode ser demorado e difícil.

Figura 11-5. Opções de implantação para MLlib

Antes de iniciar o processo de modelagem, você precisa definir os requisitos de implantação do


modelo. MLlib e Spark são apenas algumas ferramentas em sua caixa de ferramentas e você
precisa entender quando e onde elas devem ser aplicadas. O restante desta seção

Opções de implantação de modelo com MLlib | 331


Machine Translated by Google

discute as opções de implantação para MLlib com mais detalhes e, em seguida, consideraremos as opções de
implantação com Spark para modelos não MLlib.

Lote

As implantações em lote representam a maioria dos casos de uso para implantação de modelos de aprendizado
de máquina e esta é sem dúvida a opção mais fácil de implementar. Você executará um trabalho regular para
gerar previsões e salvará os resultados em uma tabela, banco de dados, data lake, etc., para consumo posterior.
Na verdade, você já viu como gerar previsões em lote no Capítulo 10 com MLlib. model.transform() do MLlib
aplicará o modelo em paralelo a todas as partições do seu DataFrame:

# Em Python
# Carregar modelo salvo com MLflow
import mlflow.spark
pipelineModel = mlflow.spark.load_model(f"runs:/{run_id}/model")

# Gerar previsões inputDF


= spark.read.parquet("/databricks-datasets/learning-spark-v2/
sf-airbnb/sf-airbnb-clean.parquet")

predDF = pipelineModel.transform(inputDF)

Algumas coisas que você deve ter em mente nas implantações em lote são:

Com que frequência você gerará previsões?


Existe uma compensação entre latência e taxa de transferência. Você obterá um rendimento mais alto
agrupando muitas previsões, mas o tempo necessário para receber quaisquer previsões individuais será
muito mais longo, atrasando sua capacidade de agir de acordo com essas previsões.

Com que frequência você treinará novamente o modelo?

Ao contrário de bibliotecas como sklearn ou TensorFlow, MLlib não oferece suporte a atualizações online
ou inicializações a quente. Se quiser retreinar seu modelo para incorporar os dados mais recentes, você
terá que retreinar todo o modelo do zero, em vez de aproveitar os parâmetros existentes. Em termos da
frequência da reciclagem, algumas pessoas estabelecerão um trabalho regular para reciclar o modelo (por
exemplo, uma vez por mês), enquanto outras monitorizarão activamente a variação do modelo para
identificar quando precisam de reciclar.

Como você fará a versão do modelo?


Você pode usar o Registro de Modelo MLflow para acompanhar os modelos que você está usando e
controlar como eles são transferidos de/para preparação, produção e arquivamento. Você pode ver uma
captura de tela do Registro de Modelo na Figura 11-6. Você também pode usar o Registro de Modelo com
outras opções de implantação.

332 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google

Figura 11-6. Registro de modelo Mlow

Além de usar a UI do MLflow para gerenciar seus modelos, você também pode gerenciá-los
programaticamente. Por exemplo, depois de registrar seu modelo de produção, ele terá um
URI consistente que você pode usar para recuperar a versão mais recente:

# Recuperar o modelo de produção mais


recente model_production_uri = f"models:/{model_name}/production"
model_production = mlflow.spark.load_model(model_production_uri)

Streaming

Em vez de esperar por um trabalho de hora em hora ou noturno para processar seus dados e
gerar previsões, o Streaming Estruturado pode realizar continuamente inferências nos dados
recebidos. Embora essa abordagem seja mais cara do que uma solução em lote, pois você
precisa pagar continuamente pelo tempo de computação (e obter menor rendimento), você obtém
o benefício adicional de gerar previsões com mais frequência para poder agir de acordo com elas
mais cedo. As soluções de streaming em geral são mais complicadas de manter e monitorar do
que as soluções em lote, mas oferecem menor latência.

Com o Spark é muito fácil converter suas previsões em lote em previsões de streaming e
praticamente todo o código é o mesmo. A única diferença é que ao ler os dados, você precisa
usar spark.readStream() em vez de spark.read() e alterar a fonte dos dados. No exemplo a seguir
vamos simular a leitura de dados de streaming por meio de streaming em um diretório de arquivos
Parquet. Você notará que estamos especificando um esquema mesmo trabalhando com arquivos
Parquet. Isso ocorre porque precisamos definir o esquema a priori ao trabalhar com streaming de
dados. Neste exemplo, usaremos o modelo de floresta aleatório treinado em nosso conjunto de
dados do Airbnb do capítulo anterior para realizar essas previsões de streaming. Carregaremos o
modelo salvo usando MLflow. Particionamos o arquivo de origem em cem pequenos arquivos
Parquet para que você possa ver a saída mudando a cada intervalo de disparo:

# Em Python
# Carregar modelo salvo com MLflow
pipelineModel = mlflow.spark.load_model(f"runs:/{run_id}/model")

Opções de implantação de modelo com MLlib | 333


Machine Translated by Google

# Configurar dados de streaming


simulados repartitionedPath = "/databricks-datasets/learning-spark-v2/sf-airbnb/ sf-
airbnb-clean-100p.parquet"
esquema = spark.read.parquet(repartitionedPath).schema

streamingData =

(spark .readStream .schema(schema) # Pode definir o


esquema desta
forma .option("maxFilesPerTrigger", 1) .parquet(repartitionedPath))

# Gerar previsões
streamPred = pipelineModel.transform(streamingData)

Depois de gerar essas previsões, você pode gravá-las em qualquer local de destino para
recuperação posterior (consulte o Capítulo 8 para dicas de streaming estruturado). Como você
pode ver, o código permanece praticamente inalterado entre os cenários de lote e de streaming,
tornando o MLlib uma ótima solução para ambos. No entanto, dependendo das exigências de
latência da sua tarefa, o MLlib pode não ser a melhor escolha. Com o Spark, há uma sobrecarga
significativa envolvida na geração do plano de consulta e na comunicação da tarefa e dos
resultados entre o driver e o trabalhador. Assim, se você precisar de previsões de latência
realmente baixa, precisará exportar seu modelo para fora do Spark.

Quase em tempo real

Se o seu caso de uso exigir previsões da ordem de centenas de milissegundos a segundos, você poderá
construir um servidor de previsão que use MLlib para gerar as previsões.
Embora este não seja um caso de uso ideal para o Spark porque você está processando quantidades
muito pequenas de dados, você obterá latência menor do que com soluções de streaming ou em lote.

Padrões de exportação de modelos para inferência em tempo

real Existem alguns domínios onde a inferência em tempo real é necessária, incluindo detecção
de fraude, recomendação de anúncios e assim por diante. Embora fazer previsões com um
pequeno número de registros possa atingir a baixa latência necessária para inferência em tempo
real, você precisará enfrentar o balanceamento de carga (lidando com muitas solicitações
simultâneas), bem como a geolocalização em tarefas críticas de latência. Existem soluções
gerenciadas populares, como AWS SageMaker e Azure ML, que fornecem soluções de atendimento
de modelos de baixa latência. Nesta seção mostraremos como exportar seus modelos MLlib para
que possam ser implantados nesses serviços.

Uma maneira de exportar seu modelo do Spark é reimplementar o modelo nativamente em Python,
C, etc. Embora possa parecer simples extrair os coeficientes do modelo, exportando todas as
etapas de engenharia de recursos e pré-processamento junto com eles (OneHo tEncoder ,
VectorAssembler, etc.) rapidamente se torna problemático e é muito sujeito a erros.

334 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google

Existem algumas bibliotecas de código aberto, como MLeap e ONNX, que podem ajudá-lo a
exportar automaticamente um subconjunto compatível dos modelos MLlib para remover sua
dependência do Spark. No entanto, no momento da redação deste artigo, a empresa que
desenvolveu o MLeap não o oferece mais suporte. O MLeap ainda não suporta Scala 2.12/Spark 3.0.

ONNX (Open Neural Network Exchange), por outro lado, tornou-se o padrão aberto de fato para
interoperabilidade de aprendizado de máquina. Alguns de vocês devem se lembrar de outros
formatos de interoperabilidade de ML, como PMML (Predictive Model Markup Language), mas
eles nunca ganharam a mesma força que o ONNX tem agora. ONNX é muito popular na
comunidade de aprendizagem profunda como uma ferramenta que permite aos desenvolvedores
alternar facilmente entre bibliotecas e linguagens e, no momento em que este artigo foi escrito,
tinha suporte experimental para MLlib.

Em vez de exportar modelos MLlib, existem outras bibliotecas de terceiros que se integram ao
Spark e são convenientes para implantar em cenários em tempo real, como XGBoost e Sparkling
Water da H2O.ai (cujo nome é derivado de uma combinação de H2O e Spark) .

XGBoost é um dos algoritmos de maior sucesso em competições Kaggle para problemas de


dados estruturados e é uma biblioteca muito popular entre cientistas de dados. Embora o
XGBoost não seja tecnicamente parte do MLlib, a biblioteca XGBoost4J-Spark permite integrar
o XGBoost distribuído em seus pipelines MLlib. Um benefício do XGBoost é a facilidade de
implantação: depois de treinar seu pipeline MLlib, você pode extrair o modelo XGBoost e salvá-
lo como um modelo não Spark para servir em Python, conforme demonstrado aqui:

// Em Scala
val xgboostModel =
xgboostPipelineModel.stages.last.asInstanceOf[XGBoostRegressionModel]
xgboostModel.nativeBooster.saveModel(nativeModelPath)

# Em Python,
importe xgboost como
xgb bst = xgb.Booster({'nthread': 4})
bst.load_model("xgboost_native_model")

No momento em que este artigo foi escrito, a API XGBoost distribuída estava
disponível apenas em Java/Scala. Um exemplo completo está incluído no repositório
Git-Hub do livro.

Agora que você aprendeu sobre as diferentes maneiras de exportar modelos MLlib para uso em
ambientes de atendimento em tempo real, vamos discutir como podemos aproveitar o Spark
para modelos não MLlib.

Opções de implantação de modelo com MLlib | 335


Machine Translated by Google

Aproveitando o Spark para modelos não MLlib

Conforme mencionado anteriormente, o MLlib nem sempre é a melhor solução para suas
necessidades de aprendizado de máquina. Ele pode não atender aos requisitos de inferência de
latência superbaixa ou ter suporte integrado para o algoritmo que você deseja usar. Para esses
casos, você ainda pode aproveitar o Spark, mas não o MLlib. Nesta seção, discutiremos como você
pode usar o Spark para realizar inferência distribuída de modelos de nó único usando Pandas UDFs,
realizar ajuste de hiperparâmetros e dimensionar engenharia de recursos.

UDFs de pandas

Embora o MLlib seja fantástico para treinamento distribuído de modelos, você não está limitado a
apenas usar o MLlib para fazer previsões em lote ou streaming com o Spark – você pode criar
funções personalizadas para aplicar seus modelos pré-treinados em escala, conhecidas como
funções definidas pelo usuário (UDFs, abordadas no Capítulo 5). Um caso de uso comum é construir
um modelo scikit-learn ou TensorFlow em uma única máquina, talvez em um subconjunto de seus
dados, mas realizar inferência distribuída em todo o conjunto de dados usando Spark.

Se você definir sua própria UDF para aplicar um modelo a cada registro de seu DataFrame em
Python, opte por UDFs pandas para serialização e desserialização otimizadas, conforme discutido
no Capítulo 5. No entanto, se seu modelo for muito grande, então há alta sobrecarga para o Pandas
UDF carregar repetidamente o mesmo modelo para cada lote no mesmo processo de trabalho
Python. No Spark 3.0, os Pandas UDFs podem aceitar um iterador pan das.Series ou
pandas.DataFrame para que você possa carregar o modelo apenas uma vez, em vez de carregá-lo
para cada série no iterador. Para obter mais detalhes sobre o que há de novo no Apache Spark 3.0
com Pandas UDFs, consulte o Capítulo 12.

Se os trabalhadores armazenarem em cache os pesos do modelo após carregá-lo pela


primeira vez, as chamadas subsequentes da mesma UDF com o mesmo carregamento do
modelo se tornarão significativamente mais rápidas.

No exemplo a seguir, usaremos mapInPandas(), introduzido no Spark 3.0, para aplicar um


modelo scikit-learn ao nosso conjunto de dados Airbnb. mapInPandas() pega um iterador
de pandas.DataFrame como entrada e gera outro iterador de pandas.DataFrame. É flexível
e fácil de usar se o seu modelo exigir todas as suas colunas como entrada, mas requer
serialização/desserialização de todo o DataFrame (conforme é passado para sua entrada).
Você pode controlar o tamanho de cada pandas.DataFrame com a configuração
spark.sql.execu tion.arrow.maxRecordsPerBatch. Uma cópia completa do código para gerar
o modelo está disponível no repositório GitHub deste livro, mas aqui vamos nos concentrar
apenas em carregar o modelo scikit-learn salvo do MLflow e aplicá-lo ao nosso Spark
DataFrame:

336 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google

# Em Python
importe mlflow.sklearn
importe pandas como pd

def prever (iterador):


model_path = f"runs:/{run_id}/random-forest-model" model =
mlflow.sklearn.load_model(model_path) # Carrega o modelo para recursos
no iterador:
rendimento pd.DataFrame(model.predict(recursos))

df.mapInPandas(prever, "predição dupla").show(3)

+-----------------+
| previsão |
+-----------------+
| 90.4355866254844| |
255.3459534312323| |
499.625544914651|
+-----------------+

Além de aplicar modelos em escala usando uma UDF Pandas, você também pode usá-los para
paralelizar o processo de construção de vários modelos. Por exemplo, talvez você queira criar
um modelo para cada tipo de dispositivo IoT para prever o tempo até a falha. Você pode usar
pyspark.sql.GroupedData.applyInPandas() (introduzido no Spark 3.0) para esta tarefa.
A função pega um pandas.DataFrame e retorna outro pandas.DataFrame. O repositório GitHub
do livro contém um exemplo completo do código para construir um modelo por tipo de dispositivo
IoT e rastrear os modelos individuais com MLflow; apenas um trecho está incluído aqui por
questões de brevidade:

# Em Python
df.groupBy("device_id").applyInPandas(build_model, esquema=trainReturnSchema)

O groupBy() causará uma confusão completa do seu conjunto de dados, e você precisa garantir
que seu modelo e os dados de cada grupo cabem em uma única máquina. Alguns de vocês
podem estar familiarizados com pyspark.sql.GroupedData.apply() (por exemplo,
df.groupBy("device_id").apply(build_model)), mas essa API será descontinuada em versões
futuras do Spark em favor do pyspark. sql.GroupedData.applyInPandas().

Agora que você viu como aplicar UDFs para realizar inferência distribuída e paralelizar a
construção de modelos, vamos ver como usar o Spark para ajuste de hiperparâmetros distribuídos.

Spark para ajuste de hiperparâmetros distribuídos Mesmo

que você não pretenda fazer inferência distribuída ou não precise dos recursos de treinamento
distribuído do MLlib, você ainda pode aproveitar o Spark para ajuste de hiperparâmetros
distribuídos. Esta seção cobrirá duas bibliotecas de código aberto em particular: Joblib e Hyperopt.

Aproveitando o Spark para modelos não MLlib | 337


Machine Translated by Google

Joblib

De acordo com sua documentação, Joblib é “um conjunto de ferramentas para fornecer pipeline
leve em Python”. Possui um backend Spark para distribuir tarefas em um cluster Spark. Joblib
pode ser usado para ajuste de hiperparâmetros, pois transmite automaticamente uma cópia de
seus dados para todos os seus trabalhadores, que então criam seus próprios modelos com
diferentes hiperparâmetros em suas cópias dos dados. Isso permite treinar e avaliar vários
modelos em paralelo. Você ainda tem a limitação fundamental de que um único modelo e todos
os dados precisam caber em uma única máquina, mas você pode paralelizar trivialmente a busca
por hiperparâmetros, como mostrado na Figura 11-7.

Figura 11-7. Pesquisa distribuída de hiperparâmetros

Para usar o Joblib, instale-o via pip install joblibspark. Certifique-se de usar o scikit learn versão
0.21 ou posterior e o pyspark versão 2.4.4 ou posterior. Um exemplo de como fazer validação
cruzada distribuída é mostrado aqui, e a mesma abordagem também funcionará para ajuste de
hiperparâmetros distribuídos:

# Em Python
de sklearn.utils importar paralelo_backend de
sklearn.ensemble importar RandomForestRegressor de
sklearn.model_selection importar train_test_split de
sklearn.model_selection importar GridSearchCV importar
pandas como pd de
joblibspark importar registrar_spark

register_spark() # Registra back-end do Spark

df = pd.read_csv("/dbfs/databricks-datasets/learning-spark-v2/sf-airbnb/
sf-airbnb-numeric.csv")
X_train, X_test, y_train, y_test = train_test_split(df.drop(["preço"], eixo=1),
df[["preço"]].values.ravel(), estado_aleatório=42)

rf = RandomForestRegressor (random_state = 42)


param_grid = {"profundidade máxima": [2, 5, 10], "n_estimadores": [20, 50, 100]}

338 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google

gscv = GridSearchCV(rf, param_grid, cv=3)

com paralelo_backend("faísca", n_jobs=3):


gscv.fit(X_train, y_train)

imprimir(gscv.cv_results_)

Consulte a documentação GridSearchCV do scikit-learn para obter uma explicação dos parâmetros
retornados do validador cruzado.

Hyperopt

Hyperopt é uma biblioteca Python para “otimização serial e paralela em espaços de pesquisa estranhos,
que podem incluir dimensões de valor real, discretas e condicionais”.
Você pode instalá-lo via pip install hyperopt. Existem duas maneiras principais de dimensionar o Hyperopt
com Apache Spark:

• Usando Hyperopt de máquina única com um algoritmo de treinamento distribuído (por exemplo,
MLlib)

• Usando Hyperopt distribuído com algoritmos de treinamento de máquina única com o


Classe SparkTrials

Para o primeiro caso, não há nada especial que você precise configurar para usar MLlib com Hyperopt
em comparação com qualquer outra biblioteca. Então, vamos dar uma olhada no último caso: Hyperopt
distribuído com modelos de nó único. Infelizmente, você não pode combinar avaliação distribuída de
hiperparâmetros com modelos de treinamento distribuído no momento em que este artigo foi escrito. O
exemplo de código completo para paralelizar a pesquisa de hiperparâmetros para um modelo Keras pode
ser encontrado no repositório GitHub do livro; apenas um trecho está incluído aqui para ilustrar os
principais componentes do Hyperopt:

# Em Python
importe hyperopt

best_hyperparameters = hyperopt.fmin(fn =
training_function, espaço =
search_space, algo =
hyperopt.tpe.suggest, max_evals
= 64, tentativas =
hyperopt.SparkTrials(parallelism=4))

fmin() gera novas configurações de hiperparâmetros para usar em sua training_func tion e as passa para
SparkTrials. SparkTrials executa lotes dessas tarefas de treinamento em paralelo como um trabalho
Spark de tarefa única em cada executor Spark. Quando a tarefa Spark é concluída, ela retorna os
resultados e a perda correspondente ao driver. O Hyperopt usa esses novos resultados para calcular
melhores configurações de hiperparâmetros para tarefas futuras. Isso permite uma expansão massiva do
ajuste de hiperparâmetros. MLflow também

Aproveitando o Spark para modelos não MLlib | 339


Machine Translated by Google

integra-se ao Hyperopt, para que você possa acompanhar os resultados de todos os modelos que treinou como parte
do ajuste do hiperparâmetro.

Um parâmetro importante para SparkTrials é o paralelismo. Isso determina o número máximo de tentativas a serem
avaliadas simultaneamente. Se paralelismo = 1, então você está treinando cada modelo sequencialmente, mas poderá
obter modelos melhores fazendo uso total de algoritmos adaptativos. Se você definir parallelism=max_evals (o número
total de modelos a serem treinados), estará apenas fazendo uma pesquisa aleatória. Qualquer número entre 1 e
max_evals permite que você tenha uma compensação entre escalabilidade e adaptabilidade. Por padrão, o paralelismo
é definido como o número de executores do Spark. Você também pode especificar um tempo limite para limitar o
número máximo de segundos que fmin() pode durar.

Mesmo que o MLlib não seja adequado para o seu problema, esperamos que você possa ver o valor de usar o Spark
em qualquer uma das suas tarefas de aprendizado de máquina.

Coalas

Pandas é uma biblioteca de análise e manipulação de dados muito popular em Python, mas está limitada à
execução em uma única máquina. Koalas é uma biblioteca de código aberto que implementa a API Pandas
DataFrame no Apache Spark, facilitando a transição do Pandas para o Spark. Você pode instalá-lo com pip install
koalas e simplesmente substituir qualquer lógica pd (Pandas) em seu código por ks (Koalas). Dessa forma, você
pode ampliar suas análises com Pandas sem precisar reescrever totalmente sua base de código no PySpark.

Aqui está um exemplo de como alterar seu código Pandas para Koalas (você precisará ter o PySpark já instalado):

# Em pandas,
importe pandas como
pd pdf = pd.read_csv(csv_path, header=0, sep=";", quotechar='"')
pdf["duration_new"] = pdf["duration"] + 100
# Em coalas,
importe databricks.koalas como ks
kdf = ks.read_csv(file_path, header=0, sep=";", quotechar='"')
kdf["duration_new"] = kdf["duration"] + 100

Embora o Koalas pretenda implementar todos os recursos do Pandas eventualmente, nem todos eles foram
implementados ainda. Se houver alguma funcionalidade necessária que o Koalas não fornece, você sempre pode
mudar para o uso das APIs Spark chamando kdf.to_spark(). Alternativamente, você pode trazer os dados para o
driver chamando kdf.to_pandas() e usar a API Pandas (tenha cuidado para que o conjunto de dados não seja
muito grande ou você irá travar o driver!).

340 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google

Resumo
Neste capítulo, abordamos diversas práticas recomendadas para gerenciar e implantar pipelines
de aprendizado de máquina. Você viu como o MLflow pode ajudá-lo a rastrear e reproduzir
experimentos e empacotar seu código e suas dependências para implantar em outro lugar.
Também discutimos as principais opções de implantação – lote, streaming e tempo real – e suas
compensações associadas. MLlib é uma solução fantástica para treinamento de modelo em
grande escala e casos de uso de lote/streaming, mas não superará um modelo de nó único para
inferência em tempo real em pequenos conjuntos de dados. Seus requisitos de implantação
impactam diretamente os tipos de modelos e estruturas que você pode usar, e é fundamental
discutir esses requisitos antes de iniciar o processo de construção de modelo.

No próximo capítulo, destacaremos alguns novos recursos importantes do Spark 3.0 e como
você pode incorporá-los às suas cargas de trabalho do Spark.

Resumo | 341
Machine Translated by Google
Machine Translated by Google

CAPÍTULO 12

Epílogo: Apache Spark 3.0

No momento em que escrevíamos este livro, o Apache Spark 3.0 ainda não havia sido lançado
oficialmente; ainda estava em desenvolvimento e começamos a trabalhar com o Spark 3.0.0-
preview2. Todos os exemplos de código neste livro foram testados no Spark 3.0.0-preview2 e não
devem funcionar de maneira diferente com a versão oficial do Spark 3.0.
Sempre que possível nos capítulos, quando relevante, mencionamos quando os recursos eram
novas adições ou comportamentos no Spark 3.0. Neste capítulo, examinamos as mudanças.

As correções de bugs e melhorias de recursos são numerosas, portanto, para resumir, destacamos
apenas uma seleção das mudanças e recursos notáveis pertencentes aos componentes do Spark.
Alguns dos novos recursos são, nos bastidores, avançados e estão além do escopo deste livro,
mas os mencionamos aqui para que você possa explorá-los quando o lançamento estiver disponível.

Spark Core e Spark SQL


Vamos primeiro considerar o que há de novo nos bastidores. Várias alterações foram introduzidas
no Spark Core e no mecanismo Spark SQL para ajudar a acelerar as consultas. Uma maneira de
agilizar as consultas é ler menos dados usando a remoção dinâmica de partições. Outra é adaptar
e otimizar os planos de consulta durante a execução.

Remoção dinâmica de partições A

ideia por trás da remoção dinâmica de partições (DPP) é ignorar os dados desnecessários nos
resultados de uma consulta. O cenário típico em que o DPP é ideal é quando você une duas
tabelas: uma tabela de fatos (particionada em diversas colunas) e uma tabela de dimensões (não
particionada), conforme mostrado na Figura 12-1. Normalmente, o filtro está no lado não
particionado da tabela (Data, no nosso caso). Por exemplo, considere esta consulta comum em
duas tabelas, Vendas e Data:

343
Machine Translated by Google

-- No SQL
SELECT * FROM Vendas JOIN ON Vendas.data = Data.data

Figura 12-1. O filtro dinâmico é injetado da tabela de dimensões na tabela de fatos

A principal técnica de otimização no DPP é pegar o resultado do filtro da tabela de dimensões e injetá-lo
na tabela de fatos como parte da operação de varredura para limitar a leitura dos dados, conforme
mostrado na Figura 12-1 .

Considere um caso em que a tabela de dimensões é menor que a tabela de fatos e realizamos uma
junção, conforme mostrado na Figura 12-2. Nesse caso, o Spark provavelmente fará uma junção de
transmissão (discutida no Capítulo 7). Durante esta junção, o Spark conduzirá as seguintes etapas para
minimizar a quantidade de dados verificados na tabela de fatos maior:

1. No lado da dimensão da junção, o Spark construirá uma tabela hash a partir da tabela de dimensões,
também conhecida como relação de construção, como parte desta consulta de filtro.

2. O Spark inserirá o resultado desta consulta na tabela hash e o atribuirá a uma variável de transmissão,
que será distribuída a todos os executores envolvidos nesta operação de junção.

3. Em cada executor, o Spark testará a tabela hash transmitida para determinar o que
linhas correspondentes para ler na tabela de fatos.

4. Por fim, o Spark injetará esse filtro dinamicamente na operação de verificação de arquivos da tabela
de fatos e reutilizará os resultados da variável de transmissão. Dessa forma, como parte da operação
de varredura de arquivos na tabela de fatos, apenas as partições que correspondem ao filtro são
varridas e apenas os dados necessários são lidos.

344 | Capítulo 12: Epílogo: Apache Spark 3.0


Machine Translated by Google

Figura 12-2. Spark injeta um filtro de tabela de dimensões na tabela de fatos durante uma junção de
transmissão

Habilitado por padrão para que você não precise configurá-lo explicitamente, tudo isso acontece de forma
dinâmica quando você realiza junções entre duas tabelas. Com a otimização DPP, o Spark 3.0 pode funcionar
muito melhor com consultas de esquema em estrela.

Execução de consulta adaptável

Outra maneira pela qual o Spark 3.0 otimiza o desempenho da consulta é adaptando seu plano de execução
física em tempo de execução. O Adaptive Query Execution (AQE) reotimiza e ajusta os planos de consulta
com base nas estatísticas de tempo de execução coletadas no processo de execução da consulta. Ele tenta
fazer o seguinte em tempo de execução:

• Reduza o número de redutores no estágio de shuffle diminuindo o número de partições de shuffle. •


Otimize o plano de

execução física da consulta, por exemplo, convertendo um SortMergeJoin em um BroadcastHashJoin


quando apropriado. • Lidar com a distorção de dados durante uma junção.

Todas essas medidas adaptativas ocorrem durante a execução do plano em tempo de execução, conforme
mostrado na Figura 12-3. Para usar o AQE no Spark 3.0, defina a configuração spark.sql.adaptive.enabled
como true.

Spark Core e Spark SQL | 345


Machine Translated by Google

Figura 12-3. AQE reexamina e reotimiza o plano de execução em tempo de execução

O quadro AQE
As operações do Spark em uma consulta são pipeline e executadas em processos paralelos, mas um
shuffle ou uma troca de transmissão interrompe esse pipeline, porque a saída de um estágio é
necessária como entrada para o próximo estágio (consulte “Etapa 3: Compreendendo os conceitos
do aplicativo Spark ” na página 25 do Capítulo 2). Esses pontos de ruptura são chamados de pontos
de materialização em um estágio de consulta e apresentam uma oportunidade para reotimizar e
reexaminar a consulta, conforme ilustrado na Figura 12-4.

346 | Capítulo 12: Epílogo: Apache Spark 3.0


Machine Translated by Google

Figura 12-4. Um plano de consulta reotimizado na estrutura AQE

Spark Core e Spark SQL | 347


Machine Translated by Google

Aqui estão as etapas conceituais que a estrutura AQE percorre, conforme ilustrado nesta figura:

1. Todos os nós folha, como operações de varredura, de cada estágio são executados.

2. Assim que o ponto de materialização terminar a execução, ele será marcado como concluído e todas as estatísticas
relevantes obtidas durante a execução serão atualizadas em seu plano lógico.

3. Com base nessas estatísticas, como número de partições lidas, bytes de dados lidos, etc., a estrutura executa o
otimizador Catalyst novamente para entender se ele
pode:

a. Agrupe o número de partições para reduzir o número de redutores para leitura


embaralhar dados.

b. Substitua uma junção de mesclagem de classificação, com base no tamanho das tabelas lidas, por uma transmissão
juntar.

c. Tente remediar uma junção distorcida.

d. Crie um novo plano lógico otimizado, seguido por um novo plano físico otimizado
plano.

Este processo se repete até que todas as etapas do plano de consulta sejam executadas.

Resumindo, essa reotimização é feita dinamicamente, conforme mostrado na Figura 12-3, e o objetivo é unir dinamicamente
as partições shuffle, diminuir o número de redutores necessários para ler os dados de saída do shuffle, mudar as estratégias
de junção, se apropriado, e remediar qualquer problema. inclinar junções.

Duas configurações do Spark SQL determinam como o AQE reduzirá o número de redutores:

• spark.sql.adaptive.coalescePartitions.enabled (definido como verdadeiro)

• spark.sql.adaptive.skewJoin.enabled (definido como verdadeiro)

No momento em que este artigo foi escrito, o blog, a documentação e os exemplos da comunidade Spark 3.0 não haviam
sido publicados publicamente, mas no momento da publicação deveriam ter sido. Esses recursos permitirão que você obtenha
informações mais detalhadas se desejar ver como esses recursos funcionam nos bastidores — inclusive sobre como você
pode injetar dicas de junção SQL, discutidas a seguir.

Dicas de junção SQL

Adicionando às dicas BROADCAST existentes para junções, o Spark 3.0 adiciona dicas de junção para todas as estratégias
de junção do Spark (consulte “Uma família de junções do Spark” na página 187 no Capítulo 7). Exemplos são fornecidos
aqui para cada tipo de junção.

348 | Capítulo 12: Epílogo: Apache Spark 3.0


Machine Translated by Google

Junção de mesclagem de classificação aleatória (SMJ)

Com essas novas dicas, você pode sugerir ao Spark que ele execute um SortMergeJoin ao unir as tabelas
aeb ou clientes e pedidos, conforme mostrado nos exemplos a seguir. Você pode adicionar uma ou mais
dicas a uma instrução SELECT dentro de /*+ ... */ blocos de comentários:

SELECT /*+ MERGE(a, b) */ id FROM a JOIN b ON a.key = b.key SELECT /*+


MERGE(clientes, pedidos) */ * FROM clientes, pedidos WHERE
pedidos.custId = clientes.custId

Junção de hash de transmissão (BHJ)

Da mesma forma, para uma junção de hash de transmissão, você pode fornecer uma dica ao Spark de que
prefere uma junção de transmissão. Por exemplo, aqui transmitimos a tabela a para juntar-se à mesa b e a
mesa clientes para juntar-se aos pedidos da mesa:

SELECT /*+ BROADCAST(a) */ id FROM a JOIN b ON a.key = b.key SELECT /*+


BROADCAST(clientes) */ * FROM clientes, pedidos WHERE
pedidos.custId = clientes.custId

Junção aleatória de hash (SHJ)

Você pode oferecer dicas de maneira semelhante para realizar junções de hash aleatórias, embora isso seja
menos comum do que as duas estratégias de junção suportadas anteriores:

SELECT /*+ SHUFFLE_HASH(a, b) */ id FROM a JOIN b ON a.key = b.key SELECT /*+


SHUFFLE_HASH(clientes, pedidos) */ * FROM clientes, pedidos WHERE
pedidos.custId = clientes.custId

Junção de loop aninhado aleatório e replicado (SNLJ)

Finalmente, a junção de loop aninhado embaralhar e replicar segue uma forma e sintaxe semelhantes:

SELECT /*+ SHUFFLE_REPLICATE_NL(a, b) */ id FROM a JOIN b

API de plug-in de catálogo e DataSourceV2 Não se

limitando apenas ao metastore e ao catálogo do Hive, a API experimental DataSourceV2 do Spark 3.0 estende
o ecossistema Spark e oferece aos desenvolvedores três recursos principais. Especificamente, ele:

• Permite conectar uma fonte de dados externa para gerenciamento de catálogo e tabela. • Suporta

pushdown de predicado para fontes de dados adicionais com formatos de arquivo suportados, como ORC,
Parquet, Kafka, Cassandra, Delta Lake e Apache Iceberg. • Fornece APIs unificadas para

streaming e processamento em lote de fontes de dados para


sumidouros e fontes

Spark Core e Spark SQL | 349


Machine Translated by Google

Voltada para desenvolvedores que desejam estender a capacidade do Spark de usar fontes e coletores
externos, a API de Catálogo fornece APIs SQL e programáticas para criar, alterar, carregar e descartar tabelas
do catálogo conectável especificado. O catálogo fornece uma abstração hierárquica de funcionalidades e
operações realizadas em diferentes níveis, conforme mostrado na Figura 12-5.

Figura 12-5. Nível hierárquico de funcionalidade da API do plug-in de catálogo

A interação inicial entre o Spark e um conector específico é resolver uma relação com seu objeto Table real. O
catálogo define como procurar tabelas neste conector.
Além disso, o Catalog pode definir como modificar seus próprios metadados, permitindo assim operações como
CREATE TABLE, ALTER TABLE, etc.

Por exemplo, em SQL agora você pode emitir comandos para criar namespaces para seu catálogo. Para usar
um catálogo conectável, habilite as seguintes configurações em seu arquivo spark defaults.conf:

spark.sql.catalog.ndb_catalog com.ndb.ConnectorImpl # implementação do conector


spark.sql.catalog.ndb_catalog.option1 valor1
spark.sql.catalog.ndb_catalog.option2 valor2

Aqui, o conector para o catálogo de fontes de dados tem duas opções: opção1->valor1 e opção2->valor2.
Depois de definidos, os usuários do aplicativo no Spark ou SQL podem usar os métodos API DataFrameReader
e DataFrameWriter ou comandos Spark SQL com essas opções definidas como métodos para manipulação de
fontes de dados. Por exemplo:

-- No SQL
SHOW TABLES ndb_catalog;
CRIAR TABELA ndb_catalog.table_1;
SELECIONE * em ndb_catalog.table_1;
ALTERAR TABELA ndb_catalog.table_1

// Em Scala
df.writeTo("ndb_catalog.table_1") val
dfNBD = spark.read.table("ndb_catalog.table_1")
.option("opção1",
"valor1") .option("opção2", "valor2")

350 | Capítulo 12: Epílogo: Apache Spark 3.0


Machine Translated by Google

Embora essas APIs de plug-in de catálogo ampliem a capacidade do Spark de utilizar fontes de dados
externas como coletores e fontes, elas ainda são experimentais e não devem ser usadas em produção.
Um guia detalhado para seu uso está além do escopo deste livro, mas recomendamos que você verifique
a documentação de lançamento para obter informações adicionais se desejar gravar um conector
personalizado para uma fonte de dados externa como um catálogo para gerenciar suas tabelas externas
e suas tabelas associadas. metadados.

Os trechos de código anteriores são exemplos da aparência do seu código


depois de você ter definido e implementado seus conectores de catálogo e
preenchido-os com dados.

Agendador com reconhecimento de acelerador

O Projeto Hydrogen, uma iniciativa comunitária para unir IA e big data, tem três objetivos principais:
implementar o modo de execução de barreira, agendamento com reconhecimento de acelerador e troca
de dados otimizada. Uma implementação básica do modo de execução de barreira foi introduzida no
Apache Spark 2.4.0. No Spark 3.0, um agendador básico foi implementado para aproveitar as vantagens
de aceleradores de hardware, como GPUs, em plataformas de destino onde o Spark é implantado em
modo autônomo, YARN ou Kubernetes.

Para que o Spark aproveite essas GPUs de forma organizada para cargas de trabalho especializadas que
as utilizam, é necessário especificar os recursos de hardware disponíveis por meio de configurações.
Seu aplicativo pode então descobri-los com a ajuda de um script de descoberta. Habilitar o uso de GPU é
um processo de três etapas em seu aplicativo Spark:

1. Escreva um script de descoberta que descubra os endereços das GPUs subjacentes disponíveis em
cada executor Spark. Este script é definido na seguinte configuração do Spark:

spark.worker.resource.gpu.discoveryScript=/caminho/para/script.sh

2. Defina a configuração para que seus executores Spark usem estas GPUs descobertas:
spark.executor.resource.gpu.amount=2
spark.task.resource.gpu.amount=1

3. Escreva o código RDD para aproveitar essas GPUs para sua tarefa:
importar org.apache.spark.BarrierTaskContext val rdd
= ...
rdd.barrier.mapPartitions { it =>
val context = BarrierTaskContext.getcontext.barrier() val gpus =
context.resources().get("gpu").get.addresses // inicia processo externo
que aproveita GPU launchProcess(gpus)

Spark Core e Spark SQL | 351


Machine Translated by Google

Essas etapas ainda são experimentais e o desenvolvimento continuará em


versões futuras do Spark 3.x para oferecer suporte à descoberta contínua
de recursos de GPU, tanto na linha de comando (com spark- submit)
quanto no nível de tarefa do Spark.

Streaming Estruturado
Para inspecionar como seus trabalhos de Streaming Estruturado se comportam com o fluxo e refluxo de
dados durante o curso da execução, a UI do Spark 3.0 tem uma nova guia Streaming Estruturado junto
com outras guias que exploramos no Capítulo 7. Esta guia oferece dois conjuntos de estatísticas : agrega
informações sobre trabalhos de consulta de streaming concluídos (Figura 12-6) e estatísticas detalhadas
sobre as consultas de streaming, incluindo taxa de entrada, taxa de processo, número de linhas de entrada,
duração do lote e duração da operação (Figura 12-7).

Figura 12-6. Guia Streaming estruturado mostrando estatísticas agregadas de um trabalho de


streaming concluído

A captura de tela da Figura 12-7 foi tirada com Spark 3.0.0-preview2; com
a versão final, você deverá ver o nome e o ID da consulta no identificador
de nome na página da IU.

352 | Capítulo 12: Epílogo: Apache Spark 3.0


Machine Translated by Google

Figura 12-7. Mostrando estatísticas detalhadas de um trabalho de streaming concluído

Nenhuma configuração é necessária; todas as configurações funcionam diretamente da


instalação do Spark 3.0, com os seguintes padrões:

• spark.sql.streaming.ui.enabled=true

• spark.sql.streaming.ui.retainedProgressUpdates=100

Streaming Estruturado | 353


Machine Translated by Google

• spark.sql.streaming.ui.retainedQueries=100

PySpark, Pandas UDFs e APIs de função Pandas

O Spark 3.0 requer o pandas versão 0.23.2 ou superior para empregar qualquer método relacionado ao
pandas, como DataFrame.toPandas() ou SparkSession.createDataFrame(pan das.DataFrame).

Além disso, requer PyArrow versão 0.12.1 ou posterior para usar a funcionalidade PyArrow, como
pandas_udf(), DataFrame.toPandas() e SparkSession.createData Frame(pandas.DataFrame) com
spark.sql.execution.arrow.enabled configuração definida como verdadeira. A próxima seção apresentará
novos recursos no Pandas
UDFs.

UDFs Pandas reprojetados com dicas de tipo Python Os UDFs Pandas no

Spark 3.0 foram reprojetados aproveitando dicas de tipo Python. Isso permite expressar UDFs naturalmente
sem exigir o tipo de avaliação. Os UDFs do Pandas agora são mais “Pythônicos” e podem definir o que o
UDF deve inserir e gerar, em vez de você especificá-lo via, por exemplo, @pan das_udf("long",
PandasUDFType.SCALAR) como você fez no Spark 2.4 .

Aqui está um exemplo:

# Pandas UDFs no Spark 3.0


importam pandas como
pd de pyspark.sql.functions import pandas_udf

@pandas_udf("long")
def pandas_plus_one(v: pd.Series) -> pd.Series:
retornar v + 1

Este novo formato oferece vários benefícios, como análise estática mais fácil. Você pode aplicar as novas
UDFs da mesma forma que antes:

354 | Capítulo 12: Epílogo: Apache Spark 3.0


Machine Translated by Google

df = faísca.intervalo(3)
df.withColumn("plus_one", pandas_plus_one("id")).show()

+---+--------+
| id|mais_um|
+---+--------+
| 0| | 1|
1| | 2| 2|
3|
+---+--------+

Suporte ao iterador em UDFs do Pandas

Pandas UDFs são muito comumente usados para carregar um modelo e executar
inferência para aprendizado de máquina de nó único e modelos de aprendizado profundo. No entanto, se um
modelo for muito grande, então há uma grande sobrecarga para o Pandas UDF repetir repetidamente
carregue o mesmo modelo para cada lote no mesmo processo de trabalho Python.

No Spark 3.0, os UDFs do Pandas podem aceitar um iterador de pandas.Series ou pandas.Data


Quadro, conforme mostrado aqui:

digitando import Iterator

@pandas_udf('longo')
def pandas_plus_one(iterador: Iterador[pd.Series]) -> Iterador[pd.Series]:
mapa de retorno (lambda s: s + 1, iterador)

df.withColumn("plus_one", pandas_plus_one("id")).show()

+---+--------+
| id|mais_um|
+---+--------+
| 0| | 1|
1| | 2| 2|
3|
+---+--------+

Com este suporte, você pode carregar o modelo apenas uma vez, em vez de carregá-lo a cada
série no iterador. O pseudocódigo a seguir ilustra como fazer isso:

@pandas_udf(...)
def prever (iterador):
...
modelo = # modelo de carga
para recursos no iterador:
modelo de rendimento.predict (recursos)

PySpark, Pandas UDFs e APIs de função Pandas | 355


Machine Translated by Google

Novas APIs de função Pandas

O Spark 3.0 apresenta alguns novos tipos de UDFs do Pandas que são úteis quando você deseja
aplicar uma função em um DataFrame inteiro em vez de em colunas, como mapInPandas(),
apresentado no Capítulo 11. Eles usam um iterador de pandas.Data Frame como entrada e saída
outro iterador de pandas.DataFrame:

def pandas_filter( iterador:


Iterador[pd.DataFrame]) -> Iterador[pd.DataFrame]:
para pdf no iterador:
rendimento pdf[pdf.id == 1]

df.mapInPandas(pandas_filter, esquema=df.schema).show()

+---+
| identificação|

+---+
| 1|
+---+

Você pode controlar o tamanho do pandas.DataFrame especificando-o na configuração


spark.sql.execution.arrow.maxRecordsPerBatch . Observe que o tamanho da entrada e o tamanho
da saída não precisam ser iguais, ao contrário da maioria dos UDFs do Pandas.

Todos os dados de um cogrupo serão carregados na memória, o que significa que se


houver distorção de dados ou se determinados grupos forem grandes demais para caber
na memória, você poderá ter problemas de OOM.

O Spark 3.0 também apresenta UDFs de Pandas de mapas coagrupados. A função applyInPandas()
pega dois pandas.DataFrames que compartilham uma chave comum e aplica uma função a cada
cogrupo. Os pandas.DataFrames retornados são então combinados como um único DataÿFrame.
Tal como acontece com mapInPandas(), não há restrição quanto ao comprimento do
pandas.DataFrame retornado. Aqui está um exemplo:

df1 = spark.createDataFrame( [(1201,


1, 1,0), (1201, 2, 2,0), (1202, 1, 3,0), (1202, 2, 4,0)], ("tempo", "id", " v1")) df2 =
spark.createDataFrame(

[(1201, 1, "x"), (1201, 2, "y")], ("hora", "id", "v2"))

def asof_join(esquerda: pd.DataFrame, direita: pd.DataFrame) -> pd.DataFrame: return


pd.merge_asof(esquerda, direita, on="time", by="id")

df1.groupby("id").cogroup(df2.groupby("id")
).applyInPandas(asof_join, "time int, id int, v1 double, v2 string").show()

356 | Capítulo 12: Epílogo: Apache Spark 3.0


Machine Translated by Google

+----+---+---+---+
|tempo| identificação| v1| v2|
+----+---+---+---+
|1201| 1|1,0| x| |1202|
1|3,0| x| |1201| 2|2,0|
você | |1202| 2|4,0|
você |
+----+---+---+---+

Funcionalidade alterada
Listar todas as mudanças de funcionalidade no Spark 3.0 transformaria este livro em um tijolo com
vários centímetros de espessura. Portanto, por uma questão de brevidade, mencionaremos alguns
notáveis aqui e deixaremos você consultar as notas de lançamento do Spark 3.0 para obter detalhes
completos e todas as nuances assim que estiverem disponíveis.

Idiomas com suporte e obsoletos O Spark


3.0 oferece suporte a Python 3 e JDK 11, e a versão 2.12 do Scala é necessária. Todas
as versões do Python anteriores à 3.6 e Java 8 estão obsoletas. Se você usar essas
versões obsoletas, você receberá mensagens de aviso.

Mudanças nas APIs DataFrame e Dataset Nas


versões anteriores do Spark, os APs Dataset e DataFrame descontinuaram o método
unionAll() . No Spark 3.0 isso foi revertido e unionAll() agora é um alias para o método
union() .

Além disso, versões anteriores do Dataset.groupByKey() do Spark resultaram em um conjunto


de dados agrupado com a chave falsamente nomeada como valor quando a chave era um tipo
não estrutural (int, string, array, etc.). Como tal, os resultados da agregação de
ds.groupByKey().count() na consulta, quando exibidos, pareciam, de forma contra-intuitiva, como
(valor, contagem). Isso foi corrigido para resultar em (chave, contagem), que é mais intuitivo. Por exemplo:

// Em Scala val
ds = spark.createDataset(Seq(20, 3, 3, 2, 4, 8, 1, 1, 3)) ds.show(5)

+-----+
|valor|
+-----+
| 20|
| 3|
| 3|
| 2|
| 4|
+-----+

Funcionalidade alterada | 357


Machine Translated by Google

ds.groupByKey(k=> k).count.show(5)

+---+--------+
|chave|contagem(1)|
+---+--------+
| 1| | 2|
3| | 3|
20| | 1|
4| | 8| 1|
1|
+---+--------+
mostrando apenas as 5 primeiras linhas

No entanto, você pode preservar o formato antigo, se preferir, definindo spark.sql.leg


acy.dataset.nameNonStructGroupingKeyAsValue como true.

Comandos DataFrame e SQL Explique


Para melhor legibilidade e formatação, o Spark 3.0 introduz o recurso Data
Frame.explain(FORMAT_MODE) para exibir diferentes visualizações dos
planos que o otimizador Catalyst gera. As opções FORMAT_MODE incluem
"simples" (o padrão), "estendido", "custo", "codegen" e "formatado". Aqui está
uma ilustração simples:

// Em Scala
val strings =
spark .read.text("/databricks-datasets/learning-spark-v2/SPARK_README.md")
val filtrado = strings.filter($"valor".contains("Spark")) filtrado.count()

# Em Python
strings = faísca
.read.text("/databricks-datasets/learning-spark-v2/SPARK_README.md")
filtrado = strings.filter(strings.value.contains("Spark")) filtrado.count()

// Em Scala
filtered.explain("simple")

# Em Python
filtered.explain(mode="simple")

== Plano Físico ==
*(1) Projeto [valor#72]
+- *(1) Filtro (isnotnull(valor#72) AND Contém(valor#72, Spark))
+- Texto do FileScan [valor#72] Em lote: falso, DataFilters: [isnotnull(valor#72),
Contém(valor#72, Spark)], Formato: Texto, Localização:
InMemoryFileIndex[dbfs:/databricks-datasets/learning-spark-v2/SPARK_README.md], PartitionFilters:
[], PushedFilters: [IsNotNull(value), StringContains (valor,Spark)],
ReadSchema: struct<valor:string>

// Em Scala
filtered.explain("formatado")

358 | Capítulo 12: Epílogo: Apache Spark 3.0


Machine Translated by Google

# Em Python
filtered.explain(mode="formatted")

== Plano Físico ==
* Projeto (3)
+- * Filtro (2)
+- Digitalizar texto (1)

(1) Saída de texto


de digitalização [1]: [valor#72]
Em lote: falso
Localização: InMemoryFileIndex [dbfs:/databricks-datasets/learning-spark-v2/...
PushedFilters: [IsNotNull(valor), StringContains(valor,Spark)]
ReadSchema: estrutura<valor:string>

(2) Filtro [id do codegen: 1]


Entrada [1]: [valor#72]
Condição: (isnotnull(valor#72) AND Contém(valor#72, Spark))

(3) Projeto [id do codegen: 1]


Saída [1]: [valor#72]
Entrada [1]: [valor#72]

-- Em SQL
EXPLICAR FORMATADO
SELECIONE *

DE tmp_spark_readme
Valor WHERE como "%Spark%"

== Plano Físico ==
* Projeto (3)
+- * Filtro (2)
+- Digitalizar texto (1)

(1) Saída de texto


de digitalização [1]: [valor#2016]
Em lote: falso
Local: InMemoryFileIndex [dbfs:/databricks-datasets/learning-spark-v2/
SPARK_README.md]
PushedFilters: [IsNotNull(valor), StringContains(valor,Spark)]
ReadSchema: estrutura<valor:string>

(2) Filtro [id do codegen: 1]


Entrada [1]: [valor#2016]
Condição: (isnotnull(valor#2016) AND Contém(valor#2016, Spark))

(3) Projeto [id do codegen: 1]


Saída [1]: [valor#2016]
Entrada [1]: [valor#2016]

Para ver o restante dos modos de formato em ação, você pode experimentar o notebook no repositório
GitHub do livro. Confira também os guias de migração do Spark 2.x para o Spark 3.0.

Funcionalidade alterada | 359


Machine Translated by Google

Resumo

Este capítulo forneceu um destaque superficial dos novos recursos do Spark 3.0. Tomamos a
liberdade de mencionar alguns recursos avançados que merecem destaque. Eles operam nos
bastidores e não no nível da API. Em particular, demos uma olhada na remoção dinâmica de
partições (DPP) e na execução adaptativa de consultas (AQE), duas otimizações que melhoram o
desempenho do Spark em tempo de execução. Também exploramos como a API experimental do
Catalog estende o ecossistema Spark para armazenamentos de dados personalizados para fontes
e coletores de dados em lote e streaming, e analisamos o novo agendador no Spark 3.0 que permite
aproveitar as vantagens das GPUs nos executores.

Complementando nossa discussão sobre a UI do Spark no Capítulo 7, também mostramos a nova


guia Structured Streaming, que fornece estatísticas acumuladas sobre jobs de streaming,
visualizações adicionais e métricas detalhadas sobre cada consulta.

As versões do Python abaixo de 3.6 foram preteridas no Spark 3.0, e as UDFs do Pandas foram
reprojetadas para oferecer suporte a dicas de tipo Python e iteradores como argumentos. Existem
Pandas UDFs que permitem transformar um DataFrame inteiro, bem como combinar dois
DataFrames coagrupados em um novo DataFrame.

Para melhor legibilidade dos planos de consulta, DataFrame.explain(FORMAT_MODE) e EXPLAIN


FORMAT_MODE em SQL exibem diferentes níveis e detalhes de planos lógicos e físicos. Além
disso, os comandos SQL agora podem obter dicas de junção para toda a família de junções
suportadas pelo Spark.

Embora não tenhamos conseguido enumerar todas as alterações na versão mais recente do Spark
neste breve capítulo, recomendamos que você explore as notas de lançamento quando o Spark 3.0
for lançado para saber mais. Além disso, para obter um rápido resumo das alterações feitas ao
usuário e detalhes sobre como migrar para o Spark 3.0, recomendamos que você consulte os guias
de migração.

Como lembrete, todo o código deste livro foi testado no Spark 3.0.0-preview2 e deverá funcionar
com o Spark 3.0 quando for lançado oficialmente. Esperamos que você tenha gostado de ler este
livro e aprendido nesta jornada conosco. Agradecemos pela sua atenção!

360 | Capítulo 12: Epílogo: Apache Spark 3.0


Machine Translated by Google

Índice

A otimização e ajuste, 173-205 usando


Agendador com reconhecimento de acelerador, 351 Spark SQL em, 84-89 método

ACM (Associação de Máquinas de Computação), approxQuantile(), 68


6 approx_count_distinct(), 239
ações, 28-30, 61 AQE (Adaptive Query Execution), 345-348 cálculos

adição de colunas, 63, 152 arbitrários com estado, 253-261 funções de tipo

agg(), 187 array, 139-141 função arrays_overlap(),

agregação(), 162 139 função array_distinct(), 139 função

agregações, 66, 239-246 array_except(), 139 função

propriedade allowUnquotedFieldNames, 101 array_intersect(), 139 array_join( )


Amazon S3, 89 função, 139 função array_max(), 139
AMPLab, 3 função array_min(), 139 função

Fase de análise (Spark SQL), 81 array_position(), 139 função

funções analíticas, 149 array_remove(), 139 função

Formato Apache Arrow, 115 array_repeat(), 139 função array_sort(),

Apache Cassandra, 89, 137, 231 139 função array_union(), 139 função

Colmeia Apache, 89, 113-155 array_zip() , 139 artefatos, 326

Apache Hudi, 272


Iceberg Apache, 272
Apache Kafka
sobre, 8, 15
leitura de, 228 ASF (Fundação de Software Apache), 2

Streaming estruturado e, 228-230 escrevendo AST (árvore de sintaxe abstrata), 81

para, 229 atomicidade, de data lakes, 270

Apache Mesos, 12, 177 auditoria de alterações de dados com histórico de operação,
282
Grupos Meetup do Apache Spark, 16
Modo de acréscimo (streaming estruturado), 212, 215, Método avg(), 67
245 Propriedade do esquema avro, 106
Avro, como fonte de dados para DataFrames e tabelas
aplicativos, Spark about,
26 conceitos SQL, 104

de, 25-28 depuração, AWSSageMaker, 334


Azure Cosmos DB, 134
204 drivers e
executores, 10, 12 programas de AzureML, 334

driver, 10

361
Machine Translated by Google

seleção aleatória de recursos por, 313

Ensacamento B, renomeação, 63, 153


modo de execução de barreira 313, arquivos de valores separados por vírgula (arquivos CSV),

implantação em lote 351, 332 102 adoção/expansão da comunidade, do Spark, 16

Beeline, consultando com, 119 Modo completo (streaming estruturado), 212, 215, 245

BHJ (junção de hash de transmissão), 188.349


big data, 1 tipos de dados complexos, 49, 139-141

Tabela grande, propriedade de compactação, 101, 103, 106

1 diretório bin, 21, 21 função de computação, 44


função concat(), 139 arquivo
arquivos binários, como fonte de dados para DataFrames
e tabelas SQL, 110 conf.spark-defaults.conf, 173 configurações,

amostras de bootstrapping, 313 173-176 usadas


variáveis de transmissão, 188, 344 neste livro,
visualização xviii , 173-176
método bucketBy(), 96 fontes
de dados integradas, 83-112, 94 funções configurando Spark,
integradas, 139-141, 239 bytecode, 7, 23, com Delta Lake, 274 consistência, de data lakes,

25 270 aplicações contínuas, 15 modo


contínuo (streaming estruturado),
217 modelo de streaming contínuo, 8 modo de disparo
C
contínuo, 219 método correlação(), 68
cache(), cache
183-187, 93, 183-187
função cardinalidade(), classe de custos
caso 139, 71, 158
mitigando, 170 de
Declaração CASE, 152
bancos de dados, 267
Cassandra, 89, 137, 231
de latência, 209
API de catálogo, 93, 349-351
count(), 29, 66, 183, 215
Otimizador de catalisador, xvi, 16, 77-82, 170
countDistinct(), 239
CBO (otimizador baseado em custos), 81
Exemplo de contagem de M&Ms, método
CDC (captura de dados de alteração), 271
35-39 covariance(), 68
pontos de verificação, 217, 262
CrossValidador, 317
classificação, 286-287, 292, 304 condições
Arquivos CSV, como fonte de dados para DataFrames e
de cláusula, 281 modo
Tabelas SQL, 102
cliente, 12 método
função cube(), 235 função
close(), 233 gerenciadores
cubed(), 116
de cluster, 10, 12, 176, 178 recursos de
customStateUpdateFunction(), 254
cluster provisionamento, clustering 262,
exemplos de código 286, 288,
D
302, usando, xviii
Fase de geração de código (Spark SQL), 81 DAG (gráfico acíclico direcionado), 4, 27 dados

codegen, habilitação em Spark SQL, 189


cogroup(), 356 acomodando mudanças, 279 mudanças

método collect(), 67 de auditoria com histórico de operação,


282
collect_list(), 138
collect_set(), 239 desduplicação, 281

Objeto de coluna, adição diversidade de formatos para soluções de armazenamento,


de 54 265

colunas, 63, governança de, como uma característica das casas do lago,
271
eliminação de 152, 63,
152 em DataFrames, 54 crescimento em tamanho de, 267

362 | Índice
Machine Translated by Google

carregamento em tabelas Delta Lake, 275 tipos de dados estruturados, 49


transformação, 214, 279-282 atualização, DataFrame.cache(), 183

280 corrupção de DataFrame.persist(), 184


dados, aplicação de esquema na gravação para prevenir, 278-279 DataFrameReader sobre,

diretório de dados, 21 5 como

tarefas de engenharia de fonte de dados para DataFrames e tabelas SQL, 94 usando,

dados, 15 engenheiros de dados, xv, 58-60

15 evolução de dados, 323 DataFrames,


dados ingestão, 290 data 144-155 alterações para, 357

lakes, 265-284 sobre, 268 comparado com


bancos de dados, 266-268 conjuntos de dados, 74 convertendo
lakehouses, para Conjuntos de dados, 166 criação,
271 limitações de, 270 50-54 fontes de
soluções de dados para, 94-112 funções de
armazenamento ideais, ordem superior em, 138-144 avaliação lenta e, 29
265 leitura de, 269 gravação para, 269 gerenciamento de memória para,
tarefas de ciência de 167 leitura de arquivos Avro, 104 leitura de
dados, 14 cientistas arquivos binários, 110 leitura de
de dados, xv fontes de dados arquivos CSV em , 102 leitura de dados,
sobre, 89 integrados, 94 93 leitura de arquivos de imagem, 108
para DataFrames, leitura de arquivos JSON, 100
94-112 para leitura de arquivos ORC, 107 leitura de
tabelas SQL, arquivos Parquet, 97 comandos de
94-112 streaming, 226-234 explicação SQL, 358 streaming, 214
transformações, 222 gravação em
arquivos Avro, 105 gravação em Arquivos
API de fontes de dados, 94 CSV, 103 gravação
tipos de em arquivos JSON, 101
dados sobre, gravação em arquivos ORC, 108
48 complexos, gravação em arquivos Parquet, 99
49 estruturados, 49 gravação em tabelas Spark SQL, 99
suporte para diversos, como um recurso de lakehouses,
271 bancos de
dados sobre,

266 limitações DataFrameWriter sobre,

de, 267 leitura de, 267 5 como

escrita de, 267 fonte de dados para DataFrames e tabelas SQL, 96 usando,
58-60

Databricks Community Edition, xviii, 34


API DataFrame sobre, API de conjunto de dados

16, 47 colunas, 54 cerca de, 16, 69


operações alterações em, 357
comuns, 58-68 criação de DataFrames, criação de conjuntos de dados, 71
50-54 tipos de dados, 48 exemplos de, Operações de conjunto de
68 expressões, 54 dados, 72 exemplos
linhas, 57 esquemas, de, 74 objetos digitados, 69
Conjuntos de dados
50-54
comparado com DataFrames, 74 convertendo
DataFrames para, 166

Índice | 363
Machine Translated by Google

custos de uso, 170 DStreams (fluxos discretizados), 9 alocação


criação, 71 dinâmica de recursos, 177
codificadores, 168
JavaBeans para, 158
gerenciamento de memória para, 167
E facilidade de uso, do Spark, 5
operações, 72 API
Os elementos da aprendizagem estatística (Hastie,
única, 157-160 Tibshirani e Friedman), 309
Spark SQL e, 157-172
função element_at(), 139, 139 codificadores
trabalhando com, 160-167 (conjuntos de dados), codificação
DataSourceV2, 349-351
168 , one-hot, 297 garantias
propriedade dateFormat, 101, 103
exatamente uma vez de ponta a ponta, abordagem de
função day(), 65
conjunto 221 , guia Ambiente
String DDL (linguagem de definição de dados), 51
313 (Spark UI), 203 erros, correção, 280
depuração Spark, 204
estimadores , 290, 295
árvores de decisão, 308-313
método estimator_name.fit(),
desduplicação de dados, 281 295 ETL (extração, transformação e
ações de exclusão, 271, 281
carregamento), 15 modelos de avaliação, ordem
exclusão de dados relacionados ao usuário, 280
de avaliação 302-306 , 115 tempos
Delta Lake
limites de tempo de evento,
259 janelas de tempo de evento,
aproximadamente, 273 construindo lakehouses com Apache Spark e,
agregações com, 239-246
274-283

configurando Apache Spark com, 274


garantias exatamente uma vez,
carregamento de dados em tabelas,
arquivo de 221
275 carregamento de fluxos de dados em exemplos, 21 execuções, ordem
tabelas, 277
de, 323 executores, 12
DenseVector, 298 função
Guia Executores (Spark UI), 200
densa_rank(), 151
funções exist(), 143
dependências, 44 modos de implantação,
experimentos, 325
12, 330-335 linguagens obsoletas,
funções explode(), 138, 235 padrões
357 método description(), 68
de exportação, para inferência em tempo real, 334 funções
desenvolvedores , Spark e, 14-17
expr(), 145 expressões,
diretórios, 21
em DataFrames, 54 extensibilidade, de
fluxos discretizados, 9 níveis
Spark, 5 fontes de dados
de armazenamento DISK_ONLY, 184 externas, 113-155
dados distribuídos, partições e, 12 execução
Apache Cassandra, 137
distribuída, 10-14 ajuste de
Azure Cosmos DB, 134
hiperparâmetro distribuído, 337-340 gerenciamento de DataFrames comuns, 144-155 funções
estado distribuído, notação de 236 pontos (.),
de ordem superior em DataFrames e
72 download Spark,
Faísca SQL, 138-144
19-22 DPP (remoção de partição Banco de dados JDBC, 129-131
dinâmica), driver 343-345 , 10 programas de driver,
MongoDB, 137
método 10
MS SQL Server, 136
drop(), 64, 152 colunas
MySQL, 133
eliminadas, 63, 152 métricas
PostgreSQL, 132
Dropwizard, 224 DSL (linguagem Floco de neve, 137
específica de domínio), 44 API
Operações Spark SQL, 144-155
DStream, 209 Banco de dados SQL, 129-131
Quadro, 122-129

364 | Índice
Machine Translated by Google

F O sistema de arquivos do Google (Ghemawat, Gobioff e


tolerância a falhas, 2, 9, 15, 185, 209, 222 Leung), 268

gerenciamento de estado tolerante a falhas, 236 GraphFrames, 9


formatos de arquivo interface gráfica do usuário, 31
cerca de, 76 Biblioteca GraphX, 6, 9
Arquivos CSV, GradeSearchCV, 339

102 data lakes e 269 Instrução GROUP BY, 138 método

suporte para diversidade de 269 groupBy(), 30, 66, 73, 157, 182, 187, 244, 337 groupByKey(),
arquivos 254, 256

cerca de, UDFs Pandas agregados

21 leitura de, 226 agrupados, 116 agregações agrupadas, 238 UDFs

Streaming estruturado e, 226 gravação Pandas de mapas agrupados, 116

para, 227 sistemas


de arquivos, 89, 269
método filter(), 28, 29, 61, 72, 73, 143, 157, 162, 170, 215, 235 H
filtragem, Hadoop, 2, 268
DataFrames e 61 método fit() , 296 FIO Hadoop, 12
ajuste, 295 método Hastie, Trevor, Os Elementos da Estatística
flatMap(), 157, Aprendizagem, 309
170, 215, 235 flatMapGroupsWithState(), 253, HBase, 5
256, 261 função flatten(), 139 fmin(), 339 método HDFS (Hadoop Distributed File System), 2.268 APIs estruturadas
foreach(), 216, 230, 233-234 de alto nível, 25 funções de ordem
foreachBatch superior, 138-144, 162-167
() método, 216, 230, 281 método format(), 94, Colmeia, 89, 113-155
96, 100 método frequentItems(), 68 Tabelas Hive ORC SerDe (serialização e desserialização), 107

Objeto HiveContext, 11
Friedman, Jerome, Os Elementos da Estatística HiveServer2, 120
Aprendizagem, Hyperopt, 339
função 309 from_json(), configurações de hiperparâmetros, 317 ajuste
programação funcional 138, funções de ordem superior e de hiperparâmetros, 307
funcionalidade 162-167, distribuídos,
alterada, 357-359 validação cruzada de
337-340 k-fold, 316-319 pipelines de
otimização, 320-321 modelos baseados
em árvore, 307-316
Coleta de lixo G, 167, 178, 199
GDPR (Regulamento Geral de Proteção de Dados),
280

generalização, com flatMapGroupsWithState(), Coluna I id (StreamingQuery), propriedade


261
224 ignoreExtension, 106 imagens, como
linhas genéricas, 69 fonte de dados para DataFrames e
métodos getter, 70 Tabelas SQL, 108
função get_json_object(), 138 execuções incrementais, 234
GFS (sistema de arquivos do Google), 1 incrementais, 211 propriedades
Ghemawat, Sanjay, The Google File System, 268 agregações inferSchema, 103 junções internas,
globais, 238 visualizações 248-252 definições de
temporárias globais, 92 fontes de entrada e saída, 213
Gobioff, Howard, O sistema de arquivos do Google, 268 formatos de
Google, 1 arquivo

Índice | 365
Machine Translated by Google

cerca de, 76 Konwinski, Andy, xv


Arquivos CSV, Biblioteca de serialização Kryo, 168, 170, 184
102 data lakes e 269 Kubernetes, 12, 21
suporte para diversas cargas de trabalho, Kuo, Kevin, Mastering Spark com R, xvi, 285
269 colunas inputRowsPerSecond (Streaming).
Consulta), eu

224 instalando R, casas do lago


21 grupos interativos, gerenciando usando tempos
Apache Hudi, 272
limite,
Apache Iceberg, edifício
257-261 shell interativo,
272 com Apache Spark e Delta Lake, 274-283
274 isolamento, de data lakes,
270 suporte a iteradores, em Pandas UDFs, 355 Delta Lake, 273
recursos de , 271
lambdas, 170
J Java, 55, 157-160 idiomas, 357
Java Serialization, 184 latência, 209, 330
java.op.Serializable, 158 avaliação preguiçosa,
classe JavaBean, 71 28-30 nó folha, de árvores de decisão,
JavaBeans, para conjuntos de 308 aprendizado, 295
dados, 158 banco de dados Dados de empréstimo do clube de empréstimos, 274

JDBC, 89, 129-131 JDK (Java Leung, Shun-Tak, The Google File System, 268
Development versionamento de biblioteca,
Kit), 41 Joblib, 323 linhagem,
338 trabalhos, 26, 27 guia 29 regressão linear, 294
Jobs (Spark UI), Modelo de regressão linear, 295, 306
198 Linux Foundation, método
operações de junção, 187 transmissão 273 load(), 94
hash join (BHJ), 188 shuffle sort merge carregamento e salvamento
(SMJ), 189-197 de fluxos de dados em tabelas Delta Lake, 277
join(), formatos de arquivo
182, 187 cerca de,
joins about, 148 BHJ (broadcast 76 arquivos
hash join ), 349 SHJ (junção de CSV, 102 data lakes
hash aleatória), 349 SMJ (junção de e 269 suporte para diversidade
mesclagem de classificação aleatória), 349 de, 269 sistemas de
SNLJ arquivos, 89, 269 em tabelas
(junção de loop Delta Lake, 275 dados estruturados,
aninhada aleatória e replicada), 349 Spark usando Spark, 91
SQL, 348 JSON modelos de
(notação de objeto JavaScript) arquivos sobre, carregamento, 306 máquina local,
53, 100 como uma fonte de dados para DataFrames e 23
tabelas SQL, 100 distribuídos,
log-normalmente
305
K Log4j.properties.template, 37 registro, 325, 327
validação cruzada k-fold, 316-319 Fase de otimização lógica
(Spark SQL), 81 regressão logística,
Guia de integração Kafka, 229
287 LogisticRegressionModel,
Kaggle, 335
Karau, Holden, xv 306 propriedade
lowerBound, 130 método lr.fit(), 296 Luraschi, Javier, Mastering Spark with R, xvi ,
Kay, Alan, 3
285
Coalas, 340

366 | Índice
Machine Translated by Google

M arquitetura de microlote, Spark Streaming, método 208 min(),


67 mitigação de custos,
aprendizado de máquina (ML)
sobre, 286 170 MLeap, 334 MLflow,

construção de modelos usando estimadores, 295 323, 324-330


MLflow Model Registry, 332
criação de pipelines, 296-302
criação de conjuntos de dados de teste, modelos MLflow, 332 projetos MLflow,
330 MLflow Tracking,
291-293 criação de conjuntos de dados de
treinamento, 291-293 325-330 biblioteca MLlib

ingestão de dados, 290 projeto de sobre, xv, 6, 7 (veja também

pipelines, 289-307 avaliação de aprendizado de


máquina (ML))
modelos, 302-306
exploração, 290 ajuste de hiperparâmetros, aprendizado de máquina (ML) com, 285-321

307-321 regressão linear, opções de implantação de modelo com, 330

294 modelos de model.transform(), 332 modelos sobre, 326


construção usando estimadores,
carregamento, 306 razões para usar
295
Spark, 289 modelos de
avaliação,
economia, 306
supervisionados, 286 Carregamento 302-306 , gerenciamento
não supervisionados, 288 306 , salvamento

com MLlib, 285-321 engenheiros de 323-330 , 306

aprendizado de máquina, xv transformações com baseado em árvore,

estado gerenciadas , 237 componente


de modelos 307-316
tabelas gerenciadas, 89
(MLflow), 324 modificações, modularidade
funções de mapa, 139 método map(), 73, 138, 157, 162, 163, 170, 215,
235 151-155 , do Spark, 5

junção somente no lado do MongoDB, função 137 mês(),

mapa, 188 mapGroupsWithState(), 253, 256, 261 65 MR ( MapReduce),


1 MS SQL Server, 136
método mapInPandas(), 336, 356
mapPartitions(), 119 função propriedades multilinha,
101, 103 ambiente
map_concat( ), 139 função
map_form_arrays(), 139 função multilocatário, 177 banco de dados
MySQL, 86, 133
map_from_entries(), 139
Dominando Spark com R (Luraschi, Kuo e
Ruiz), xvi, 285
Objeto matriz, 292 N
Maven, 133, 134 Algoritmo Naive Bayes, 287
método max(), 67 dependências estreitas, 30
método mean(), 239 Netflix, 272
gerenciamento de memória, para conjuntos de dados e dados. modelos não MLlib, aproveitando o Spark para,
Quadros, 167 336-340
Nível de armazenamento MEMORY_AND_DISK, 184 análise não baseada em SQL, para bancos de dados, 268
Nível de armazenamento MEMORY_AND_DISK_SER, 184 agregações de streaming não baseadas em tempo, 238
Nível de armazenamento MEMORY_ONLY, 184 verificação de nulos,
Nível de armazenamento MEMORY_ONLY_SER, 184 115 coluna numInputRows (StreamingQuery), 224 propriedade
merge(), atualizando dados alterados para tabelas usando, numPartitions, 130
281
NumPy, 14
Metadados Mesos (ver Apache
Mesos), 93, 326
Ó
métricas, 224, 326
objetos, 69

Índice | 367
Machine Translated by Google

memória heap fora do Java, 168 criação, 181


Nível de armazenamento OFF_HEAP, 184 dados distribuídos e, 12
OHE (codificação one-hot), 297 importância de, 130
OLAP (processamento analítico online), 267 número de para embaralhamento,
OLTP (processamento de transações on-line), 267 262
Modo único (streaming estruturado), 217 embaralhamento, 182
OneHotEncoder, 298 desempenho, 262, 265
ONNX (troca de rede neural aberta), 334 persist (), 183-187 persistência, armazenamento em cache de dados e, 183-187
Exceções OOM (falta de memória), método 67 Fase de planejamento físico (Spark SQL), 81
open(), abertura 233, de biblioteca de serialização pickle (Python), 115
soluções de armazenamento, histórico de API de pipeline, 289
operações 265, auditoria de alterações de dados com, criação de
282 pipelines, 296-302
otimizando aplicativos Spark, método 173-205 definidos, 290
option(), 94, 96, 100 ações opcionais, projetos, 289-307
281 otimização, 320-321
ORC, como fonte de dados para DataFrames e tabelas SQL, dinamização, 153
106 ordem PMML (linguagem de marcação de modelo preditivo),
de execução, 323 método 335

orderBy(), 30, 66 junções porta 4040, 197


externas, 252 Banco de dados PostgreSQL, 86, 132
modos de saída (Structured Streaming), 212, 215, método process(), 233 coluna
245 processRowsPerSecond (Streaming-
coletor de saída, 215 Consulta), 224
overwrite(), 306 detalhes de processamento, especificando, 216
mecanismo de processamento, data lakes e, 269

P tempos limite de tempo de processamento, 257


Gatilho ProcessingTime, 217
Algoritmo PageRank, 9
Pandas Projeto Hidrogênio, 15, 351

por aí, 340 Projeto Tungsten, 4, 16, 82, 119, 167 projeções,

APIs de função, 354, 356 DataFrames e, 61 componentes de

funções definidas pelo usuário (UDFs), 115-117, projetos (MLflow), 324 métricas de publicação

336, 354, 355 usando Dropwizard Metrics,


224
operações paralelas, 323
Repositório PyPI, 20
parâmetro de paralelismo, 320
PySpark, 115-117, 354
paralelismo, maximização, 180
Concha PySpark, 22-25
paralelizar(), 119
Colunas
parâmetros, 326
Python e, 55 tipos de
ParamGridBuilder, 317
dados, 49 dicas
Parquet
sobre, 60, 96 de tipo, 354

como fonte de dados para DataFrames e tabelas


SQL, 97-100 Q
lendo arquivos em DataFrames, 97 lendo queries
arquivos em tabelas Spark SQL, 97 gravando monitorando ativo, 223-225
DataFrames em, 99 propriedade snapshots de tabelas, 283
partiçãoColumn, 130 partições sobre, 44 iniciando, 218
Streaming Estruturado, 213-225 com
Spark SQL Shell, 119

368 | Índice
Machine Translated by Google

R função reversa(), 139


RFórmula, 300
Biblioteca R, 21
R2, 302-306 RISELab, 3

R2D3, 309 RMSE (erro quadrático médio), 302 rollup(), 235


florestas aleatórias, 313-316 raiz, de árvores
de decisão, 308
randomSplit(), 293
funções de classificação, 149 Objetos de linha, 57
linhas
RDBSs (sistemas de gerenciamento de banco de dados
relacional), 1 genérico, 69

RDD (conjunto de dados distribuídos resilientes), 5, 16, 43, em DataFrames, 57


75 seleção aleatória de recursos por, 313

rdd.getNumPartitions(), 13 read(), Ruiz, Edgar, Mastering Spark com R, xvi, 285 coluna runID

29 leitura de (StreamingQuery), 224 consultas Spark SQL em

arquivos execução, 120 execuções, 325 arquitetura


Avro em DataFrames, 104 arquivos Avro de tempo

em tabelas Spark SQL, 105 arquivos binários de execução (Spark), 11, 74, 96, 170,
203, 234, 345
em DataFrames, 110 arquivos CSV em
DataFrames, 102 arquivos CSV em
tabelas Spark SQL, 102 de data lakes , 269 de
bancos de dados, 267 de Dados de amostra S , 160,
arquivos, 226 de Kafka, 162-167 método sampleBy(),
228 arquivos de 68 método save(), 96
imagem em método saveAsTable(), 96
DataFrames, 108 arquivos JSON em modelos de salvamento,
DataFrames, 100 arquivos JSON em 306 sbt (ferramenta de construção Scala), 40
tabelas Spark SQL, 100 arquivos ORC em escala
DataFrames, 107 arquivos ORC em construção de aplicativos independentes, 40
tabelas Spark SQL, 107 arquivos Parquet em classes de caso, 71
DataFrames, 97 arquivos Parquet em colunas e 55 API
tabelas SQL, 97 tabelas em DataFrames, única para, 157-160 usando,
93 arquivo README.md, 21 22-25
inferência em tempo real, Shell Scala, 23, 274
padrões de exportação para, 334 receptores, 169 escalabilidade
propriedade de de bancos de dados,
nome de registro, 106 modelo de 268 de soluções de armazenamento, 265
processamento de registro por tempo, 207 Spark, 177-182
propriedade recordNamespace, 106 Pandas UDFs escalares, 116
redesenhar Pandas UDFs, 354 função SCD (dimensão de mudança lenta), 271
reduzir(), 144, 162 reduzirByKey(), agendadores, 351
187 componente de aplicação/governança de esquema, 271, 278-279 método
registro (MLflow), 324 árvores de decisão de schema(), 94
regressão, SchemaRDDs, 43
308-313 linear, 294 logística, esquemas, 50-54, 279
287 florestas scikit-learn, 289, 310, 312, 336, 339
aleatórias, mecanismo de tungstênio de segunda geração, 167
313-316 renomear () método, método select(), 28, 61, 73, 162, 215, 235 função
153 renomeação de colunas, selectExpr() , 138 garantias
63, 153 REST API, 324 semânticas, com marcas d'água, 245 propriedade set,
103

Índice | 369
Machine Translated by Google

função sequência(), 139 aproveitamento para modelos não MLlib,


SerDe (serialização e desserialização), 169 modularidade
Tubarão, 113 336-340 de,
conchas 5
PySpark, 22-25 escalonamento, 177
Escala, 23, 274 velocidade de, 4
Faísca, 25, 85, 119 estruturação, 44-47 usos
Faísca SQL, 119 para, 14-17
SHJ (shuffle hash join), 349 Spark + AI Summit, 16 APIs Spark, 16
partições shuffle, 182 Spark Cassandra
serviço shuffle, 179 Connector, 231 Spark
função shuffle(), 139 Core, 343
shuffles, 187, 262 Spark shell, 25, 85,
SIMD (instrução única, dados múltiplos), 167 APIs 119 Spark SQL sobre, 6, 7, 76,
singulares, 157-160 343 Apache Hive e, 113-155
coletores exemplos de consulta básica,
personalizado, 84-89 Otimizador Catalyst,
230-234 opções 77-82 Conjuntos de
para, 222 streaming, dados e, 157-172 ordem de avaliação,
226-234 115 funções de ordem superior
inclinação, em,
199 sklearn, 332 função slice(), 139 138-144 banco de
SMJ (shuffle sort merge join), 189-197, 349 dados JDBC, 89,
instantâneos, consulta, 283 129-131
SNLJ (junção de loop aninhada aleatória e replicada), junções, 348 verificação
349 de nulos, 115 operações, 144-155 tabelas,
Snowflake, 137 89-94 uso e interface, 83 funções
software, usuário neste livro, xviii definidas pelo
sortBy(), 187 usuário (UDFs), 114 uso em Aplicativos
opções de fonte, 222 Spark, 84-89 visualizações,
limites de taxa de fonte, configuração, 262 89-94 Servidor Spark Thrift JDBC/
Spark ODBC, 121 Spark Tuning
about, xvi, 4 Guide, 320
conceitos de aplicação, 25-28 Spark UI, 31-33, 197, 197-205 Spark web UI
construindo lakehouses com Delta Lake e, (consulte web UI) spark-shell, 31
274-283 spark -submeter script, 21,
adoção/expansão da comunidade de, 16 174, 179, 197, 352
características de design de, 4 spark.executor.memory,
desenvolvedores e, 14-17 178 opção
diretórios, 21 spark.local.dir, 182 pacote
execução distribuída, 10-14 spark.ml, 8, 312 pacote spark.mllib,
download, 19-22 8 spark.read(), 247 spark Função .read.csv(),
primeiros anos 60 spark.readStream(),
de, 3 facilidade 247, 333 interface programática
de uso de, 5 spark.sql, 85 objeto
evolução de, 1 SparkConf,
11 objeto
SparkContext,
extensibilidade de, 5 arquivos, 21 formatos internos em comparação com11, 263Object
Java Sparkling
Formato, 168 Water, 335 sparklyr, 21 projeto SparkR, 21 SparkSession, 10, 26, 85, 92 Vetor es

370 | Índice
Machine Translated by Google

velocidade, de Spark, 4 StreamingQuery, 223


Bancos de dados SQL, 90, 129-131 StreamingQueryListener, 225 streams,
Guia SQL (IU do Spark), 202 carregando em tabelas Delta Lake, 277
Tabelas SQL StringIndexer, 298, 320
sobre, 60 APIs estruturadas

cache, 93 sobre, 43

criação, 119 API DataFrame, 47-69


fontes de dados para, 94-112 API de conjunto de dados, 69-74

inserção de dados, 120 leitura Conjunto de dados distribuídos resilientes (RDD), 43


de arquivos Avro, 105 leitura de Spark SQL, estruturação
arquivos CSV, 102 leitura de 76-82 Spark, 44-47 dados
arquivos JSON, 100 leitura de estruturados, 49

arquivos ORC, 107 leitura de Streaming Estruturado, 207-264


arquivos Parquet em, 97 escrevendo aproximadamente, 6, 8, 352

DataFrames para, 99 método sql(), APIs, 8


84 cálculos com estado arbitrários, 253-261 pontos de
Objeto SQLContext, 11 verificação, 217
SQLLine CLI, 120 engenheiros de dados e 15
estágios, 26, 28 fontes de dados, 226-234

Guia Estágios (Spark UI), 198 transformações de dados, 234-237


aplicativos independentes carregamento de fluxos de dados em tabelas Delta Lake,
construídos em Scala, 40 277

Exemplo de contagem de M&Ms, 35-39 processamento de fluxo de microlote, 208


Projeto Scala/Java usando coordenadas Maven, MLflow e 333 ajuste
274 de desempenho, 262 filosofia de,
Gerenciador de cluster independente, 210 modelo de
sintaxe de 12 programação de, 211-213 consultas,
estrelas, método start() 281 , 213-225 coletores,
218, 221 start-thriftserver.sh, 226-234 agregações
método 121 stat(), 68 de streaming com estado, 238-246 mecanismo de
agregações de streaming com estado, 238-246 processamento de fluxo, 207-211 junções de
transformações com estado, 215, 235 streaming, 246-252
transformações sem estado, 215, 235 estáticas STS (Spark Thrift Server), método 119
alocação de recursos, 177 stddev() , sum(), 67, 239 aprendizado
239 data lakes de de máquina supervisionado, 286
Suporte a máquinas de vetores, 287
armazenamento e, 269 idiomas suportados, 357
importância do ideal, 265 níveis de,
184 guia
T
Armazenamento (Spark UI), 200
Tableau, consultando com tabelas 122-129
junções estáticas de fluxo, 246
junções fluxo-fluxo, 248-252 streaming sobre, 89
(consulte Estruturado Streaming) streaming (veja também tabelas SQL)
DataFrame, 214 junções de
criando bancos de dados SQL e, 90
streaming, 246, 248-252 consultas de Delta Lake, 275, 281 leitura
streaming, múltiplas, 263 fontes de
em DataFrames, 93 não gerenciados
streaming, personalizado, 230-234 estado de
em comparação com gerenciados, 89 guias (Spark UI),
streaming, 234 objeto
197-205 método take(), 73
StreamingContext, 11

Índice | 371
Machine Translated by Google

tarefas, 26, 28 propriedade UpperBound, 130


visualizações temporárias, 92 upserting, 271, 281
TensorFlow, 332, 336 funções definidas pelo usuário (UDFs), 114-117, 239,
conjuntos de dados de teste, criação, 336

291-293 pacotes Spark de terceiros, 5 dados relacionados ao usuário, exclusão,


Servidor Thrift JDBC/ODBC, taxa de 280 funções utilitárias, 138
transferência 119 , 330
Tibshirani, Robert, Os Elementos da Estatística

Aprendizado, 309 Conjunto de dados de validação


tempos limite, gerenciamento de grupos interativos usando,
V , 316 variáveis, compartilhadas (ver variáveis compartilhadas)
257-261
Transformador VectorAssembler, 293
função to_date(), 64 função
VectorIndexer, 300 leitores
to_json(), 138 função ORC vetorizados, 106 leitores
to_timestamp(), 64 componente de vetorizados, 106 UDFs
rastreamento (MLflow), 325-330 dados de treinamento,
vetorizados (consulte Pandas, funções definidas pelo
291-293 suporte a transações,
usuário (UDFs))
265 , 271 função transform(), 142, 293
transformações sobre, 28-30 dados,
visualizações
279-282
sobre, 89
criação, 91 metadados, 93

DataFrames e, 61, 222 estreitos,


30 dados de
Atraso da marca d'água W ,
amostra, 162-167 com
243 marcas d'água
estado, 215, 235 sem
estado, 215, 235 manipulação de dados atrasados com,
243 junções internas com opcional, 248-252
Streaming estruturado e largura 234-237 , 30
junções externas com, 252

transformadores, 290, 293 garantias semânticas com, 245 UI da


web, 31, 197
modelos baseados em árvore, 307-316
Wendell, Patrick, xv método
detalhes de acionamento, 217
where(), 61, 235 geração de
ajuste Spark, 173-205 tuplas,
código de estágio completo, 119 dependências
50 APIs
amplas, 30 transformações
digitadas, 69 objetos
amplas (consulte partições aleatórias) função window(), 149,
digitados, 69
240 transformações em janela, 210
janelas, 149-151 withColumn( ) método, 64,
você

152 withWatermark(), 244


Uber Engenharia, 272
cargas de trabalho, 265, 269, 271 escrita
UDFs (consulte funções definidas pelo usuário (UDFs))
método union(), 147, 357 método
unionAll(), 357 uniões, 147
transformações
DataFrames em arquivos Avro, 105
com estado não gerenciadas, 237 tabelas não DataFrames em arquivos CSV, 103
gerenciadas, 89 APIs não DataFrames em arquivos JSON, 101
digitadas, 69 objetos DataFrames para arquivos ORC, 108
não digitados, 69
DataFrames para arquivos Parquet, 99
Modo de atualização (streaming estruturado), 212, 215,
DataFrames para tabelas Spark SQL, 99 de
245
bancos de dados, 267 para
atualizando dados, 280 data lakes, 269

372 | Índice
Machine Translated by Google

para arquivos, Y
227 para Kafka, 229 Yahoo!, 2
para sistemas de armazenamento, 230 YARN (consulte Hadoop
YARN) função ano(), 65
x
XGBoost, 323, 335 Z
Biblioteca XGBoost4J-Spark, 335 Zaharia, Matei, xv

Índice | 373
Machine Translated by Google

sobre os autores
Jules S. Damji é defensor sênior do desenvolvedor na Databricks e contribuidor do MLflow. Ele é um
desenvolvedor prático com mais de 20 anos de experiência e trabalhou como engenheiro de software
em empresas líderes como Sun Microsystems, Netscape, @Home, Loudcloud/Opsware, Verisign,
ProQuest e Hortonworks, construindo sistemas distribuídos em grande escala. Ele possui um B.Sc.
e um M.Sc. em ciência da computação e mestrado em defesa política e comunicação pela Oregon
State University, Cal State e Johns Hopkins University, respectivamente.

Brooke Wenig é líder de prática de aprendizado de máquina na Databricks. Ela lidera uma equipe de
cientistas de dados que desenvolve pipelines de aprendizado de máquina em grande escala para
clientes, além de ministrar cursos sobre práticas recomendadas de aprendizado de máquina
distribuído. Anteriormente, ela foi consultora principal de ciência de dados na Databricks. Ela possui
mestrado em ciência da computação pela UCLA com foco em aprendizado de máquina distribuído.

Tathagata Das é engenheiro de software da equipe da Databricks, committer do Apache Spark e


membro do Apache Spark Project Management Committee (PMC). Ele é um dos desenvolvedores
originais do Apache Spark, o desenvolvedor líder do Spark Streaming (DStreams) e atualmente é um
dos principais desenvolvedores do Structured Streaming e Delta Lake. Tathagata possui mestrado
em ciência da computação pela UC Berkeley.

Denny Lee é um defensor do desenvolvedor da Databricks que trabalha com Apache Spark desde
0.6. Ele é um engenheiro prático de sistemas distribuídos e ciências de dados com ampla experiência
no desenvolvimento de infraestrutura em escala de Internet, plataformas de dados e sistemas de
análise preditiva para ambientes locais e em nuvem. Ele também possui mestrado em informática
biomédica pela Oregon Health and Sciences University e arquitetou e implementou poderosas
soluções de dados para clientes empresariais de saúde.

Colofão
O animal na capa da Learning Spark, Segunda Edição, é o tubarão-gato-pintado-pequeno (Scyliorhinus
canicula), uma espécie abundante nas águas rasas do Mar Mediterrâneo e no Atlântico, ao largo da
costa da Europa e do norte. África. É um tubarão pequeno e esguio, com cabeça romba, olhos ovais
e focinho arredondado. A superfície dorsal é marrom-acinzentada e padronizada com muitas pequenas
manchas escuras e às vezes mais claras. Tal como outros tubarões, a textura da sua pele é formada
por “dentículos dérmicos”, minúsculos “dentes” que crescem todos numa direcção (como escamas
de peixe), formando uma superfície que é ao mesmo tempo hidrodinâmica e resistente a lesões e
parasitas.

Este tubarão que se alimenta à noite cresce até cerca de um metro de comprimento, pesa em média
3 quilos na maturidade e na natureza pode viver até 12 anos. Alimenta-se principalmente de moluscos,
crustáceos, cefalópodes e vermes poliquetas, embora também coma outros peixes. Esta espécie
Machine Translated by Google

exibe alguns comportamentos sociais, especialmente quando jovem, e um estudo de 2014 conduzido
pela Universidade de Exeter descobriu que os indivíduos exibiam personalidades sociais diferentes.
Apesar das mudanças no habitat, alguns tubarões preferiram permanecer em grupos visíveis,
enquanto outros permaneceram sozinhos, camuflados no fundo do habitat. Estes comportamentos
de socialização também reflectem uma variabilidade nas estratégias de segurança, quer através de
números, quer através de camuflagem.

Este tubarão-gato é ovíparo (põe ovos) e as fêmeas depositam de 18 a 20 pequenas caixas de ovos
a cada ano. Essas caixas de casca dura têm gavinhas que se prendem às algas marinhas no fundo
do oceano; cada caixa contém um tubarão jovem. Os jovens eclodem após cerca de nove meses.

Como o tubarão-gato-pintado-pequeno é indesejável para a pesca comercial, as populações estão


atualmente estáveis e a espécie está listada pela IUCN como sendo de menor preocupação. Muitos
dos animais nas capas da O'Reilly estão ameaçados de extinção; todos eles são importantes para o
mundo.

A ilustração da capa é de Karen Montgomery, baseada em uma gravura em preto e branco de


Animate Creation (1885), de JG Wood. As fontes da capa são Gilroy Semibold e Guardian Sans. A
fonte do texto é Adobe Minion Pro; a fonte do cabeçalho é Adobe Myriad Condensed; e a fonte do
código é Ubuntu Mono da Dalton Maag.

Você também pode gostar