Escolar Documentos
Profissional Documentos
Cultura Documentos
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).
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.
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.
Figura 7-18. A guia Ambiente mostra as propriedades de tempo de execução do seu cluster 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
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.
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.
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
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.
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).
• 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.
Essas desvantagens moldaram a filosofia de design do Streaming Estruturado, que discutiremos a seguir.
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:
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.
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.
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.
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.
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:
# Em Python
spark = SparkSession...
linhas =
// Em Scala
val spark = SparkSession... val
lines =
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).
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
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.
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.
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:
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
é, 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.
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.
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:
# 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.
Uma vez
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.
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.
# 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 =
.outputMode("complete") .trigger(processingTime="1
segundo") .option("checkpointLocation",
// No Scala
import org.apache.spark.sql.functions._
import org.apache.spark.sql.streaming._ val
spark = SparkSession... val
lines =
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 .
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
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.
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:
Cálculos determinísticos
Todas as transformações de dados produzem deterministicamente o mesmo resultado quando recebem os
mesmos dados de entrada.
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
// Em Scala //
val isCorruptedUdf = udf para detectar corrupção na string
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.
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.
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 {
"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
} ],
"sink" :
{ "description" : "MemorySink"
}
}
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
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 {
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
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.
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)
// Em Scala
spark.streams.addListener(myListener)
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
// No Scala
importar org.apache.spark.sql.types._
val inputDirectoryOfJsonFiles = ...
• 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.
A gravação em
# Em Python
outputDir = ...
checkpointDir = ...
resultDF = ...
streamingQuery =
// No Scala
val outputDir = ...
val checkpointDir = val ...
resultDF = ...
val streamingQuery =
Em vez de usar a opção "caminho" , você pode especificar o diretório de saída diretamente como start(outputDir).
• 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.
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 =
// Em Scala
val inputDF = faísca
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "host1:porta1,host2:porta2")
.option("inscrever-se", "eventos")
.carregar()
tema corda Tópico Kafka em que o registro estava. Isso é útil quando inscrito em vários tópicos.
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.
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
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.
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 =
// Em Scala
val counts = ... // DataFrame[word: string, count: long] val
streamingQuery =
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.
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.
# Em Python
hostAddr = "<endereço IP>"
keyspaceName = "<keyspace>"
tableName = "<tableName>"
spark.conf.set("spark.cassandra.connection.host", hostAddr)
streamingQuery =
// No Scala
importe org.apache.spark.sql.DataFrame
spark.conf.set("spark.cassandra.connection.host", hostAddr)
val streamingQuery =
_) .outputMode("atualização") .option("checkpointLocation",
checkpointDir) .start()
# 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()
}
3 Para obter a lista completa de operações não suportadas, consulte o Guia de programação de streaming estruturado.
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()
resultadoDF.writeStream.foreach(ForeachWriter()).start()
// Em Scala
import org.apache.spark.sql.ForeachWriter val
foreachWriter = new ForeachWriter[String] { // digitado com Strings
resultadoDSofStrings.writeStream.foreach(foreachWriter).start()
A semântica detalhada desses métodos executados é discutida no Estruturado
Guia de programação de streaming.
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.
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.
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.
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.
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.
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.
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.
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:
• Agregações de streaming •
Junções fluxo-stream •
Desduplicação de streaming
• MapGroupsWithState
• FlatMapGroupsWithState
Essas operações permitem definir operações com estado arbitrárias (sessionização, etc.).
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()
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")
# Em Python
de pyspark.sql.functions import *
multipleAggs =
// Em Scala
importar org.apache.spark.sql.functions.* val
multipleAggs =
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.
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.
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 *
// Em Scala
importar org.apache.spark.sql.functions.*
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.
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).
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
Nesta consulta, cada evento será atribuído a duas janelas sobrepostas, conforme ilustrado na Figura 8-8.
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.
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.
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
// Em Scala
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
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
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
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.
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. ...
// 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:
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")
• 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.
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.
Vamos considerar isso com mais detalhes, primeiro com junções internas e depois com junções externas.
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:
# Em Python
# Streaming DataFrame [adId: String, impressionTime: Timestamp, ...] impressões =
spark.readStream. ...
// Em Scala //
Streaming DataFrame [adId: String, impressionTime: Timestamp, ...] val impressões
= spark.readStream. ...
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.
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
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
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")
Em nosso caso de uso de publicidade, nosso código de junção interno ficará um pouco mais complicado:
# 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"))
//Em Scala
// Definir marcas d'água
val impressõesWithWatermark = impressões
.selectExpr("adId AS impressionAdId",
"impressionTime") .withWatermark("impressionTime", "2 horas ")
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.
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
• 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.
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
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).
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.
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
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).
você
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:
d. Saída (U) = UserStatus, pois queremos gerar o status mais recente do usuário
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._
} 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.
operações com maior flexibilidade, então você deve usar flatMapGroupsWith State(). Discutiremos isso
após o tempo limite.
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.
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:
• Além disso, toda vez que atualizarmos o estado com novos dados, definiremos o tempo limite
duração.
// Em Scala
def updateUserStatus( userId:
String, newActions:
Iterator[UserAction], estado:
GroupState[UserStatus]): 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
}
}
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
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.
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.
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.
// Em Scala
def updateUserStatus( userId:
String, newActions:
Iterator[UserAction], estado:
GroupState[UserStatus]):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() }
}
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:
• 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.
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()
• 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.
• 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],
state.update(userStatus)
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:
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 o limite muito baixo pode fazer com que a consulta subutilize os recursos alocados e fique atrás da
taxa de entrada.
• 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)
# Em Python
# Execute streaming query1 no agendador pool1
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "pool1")
df.writeStream.queryName("query1").format("parquet").start(path1)
Resumo
Este capítulo explorou a escrita de consultas de streaming estruturado usando a API DataFrame.
Especificamente, discutimos:
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
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.
CAPÍTULO 9
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.
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
Cargas de trabalho em lote, como trabalhos ETL tradicionais que processam dados não estruturados brutos •
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.
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
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:
É 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.
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.
As bases de dados revelaram-se bastante inadequadas para acomodar estas novas tendências, devido às seguintes
limitações:
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.
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.
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.
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.
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.
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.
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
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:
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.
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:
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):
• Viagem no tempo, que permite consultar um instantâneo de tabela específico por ID ou por
carimbo de data/hora
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:
• Viagem no tempo, que permite consultar um instantâneo de tabela específico por ID ou por
carimbo de data/hora
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.
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!
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
• 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.
Você pode configurar o Apache Spark para vincular à biblioteca Delta Lake de uma das seguintes maneiras:
--packages io.delta:delta-core_2.12:0.7.0
Por exemplo:
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.
<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.
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"
.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"
Agora podemos ler e explorar os dados tão facilmente quanto qualquer outra tabela:
// Em Scala/ Python
+--------+
|contagem(1)|
+--------+
| 14705|
+--------+
+-------+-----------+---------+----------+
|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|
+-------+-----------+---------+----------+
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._
.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 =
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.
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 *
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)
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.
// Em Scala
# 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.
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.
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.
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._
deltaTable.update( col("addr_state")
=== "OR", Map("addr_state" -> lit("WA")))
# Em Python
da importação delta.tables *
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")
Semelhante às atualizações, com Delta Lake e Apache Spark 3.0 você pode executar diretamente o comando
DELETE SQL na tabela.
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
# Em Python
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.
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:
Condições da cláusula
Ações opcionais
Todas as cláusulas MATCHED e NOT MATCHED são opcionais.
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
# Em Python
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.
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
# Em Python
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("versionAsOf",
"4") .load(deltaPath)
# Em Python
(spark.read.format("delta")
.option("versionAsOf",
"4") .load(deltaPath))
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:
Suporte para cargas de trabalho simultâneas em lote e streaming com garantias ACID • Suporte
No próximo capítulo, exploraremos como começar a construir modelos de ML usando o MLlib do Spark.
CAPÍTULO 10
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.
285
Machine Translated by Google
Aprendizado supervisionado
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) .
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.
Algoritmo Classificação de
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.
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
(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.
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.
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,
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.
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):
# 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.
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""")
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.
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.
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
+--------+--------+-----+
|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 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.
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.
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")
.setLabelCol("preço")
// 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:
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.
# 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.
# 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:
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().
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.
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
stringIndexer = StringIndexer(inputCols=categoricalCols,
outputCols=indexOutputCols,
handleInvalid="skip")
oheEncoder = OneHotEncoder(inputCols=indexOutputCols,
outputCols=oheOutputCols)
// No Scala
importar org.apache.spark.ml.feature.{OneHotEncoder, StringIndexer}
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.
// No Scala
importar org.apache.spark.ml.feature.RFormula
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])
// Em Scala
val lr = new
+--------------------+-----+------------------+
| 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|
|(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.
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.
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
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
# 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
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
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
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,
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
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.
Vamos dar uma olhada na distribuição resultante se, em vez disso, olharmos para o logaritmo do
preço (Figura 10-8).
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.
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:
# 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 á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.
Á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.
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.
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.
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")
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")
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 .
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)
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)
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)),
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
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
As florestas aleatórias são um conjunto de árvores de decisão com dois ajustes principais:
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.
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
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.
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.
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.
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
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.
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.
# No pipeline
Python = Pipeline(stages = [stringIndexer, vecAssembler, rf])
// No Scala val
pipeline = new
Pipeline() .setStages(Array(stringIndexer, vecAssembler, rf))
(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())
// 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()
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
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)
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.
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:
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)
// Em Scala
val cv = new CrossValidator()
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
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.
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.
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
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
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
# Modelo de log
pipelineModel = pipeline.fit(trainDF)
mlflow.spark.log_model(pipelineModel, "model")
r2 = regressãoEvaluator.setMetricName("r2").evaluate(predDF)
mlflow.log_metrics({"rmse": rmse, "r2": r2})
# 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.
328 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google
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
# Em Python
mlflow.run( "https://github.com/databricks/LearningSparkV2/#mlflow-project-example",
parâmetros={"max_profundidade": 5, "num_trees": 100})
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.
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")
predDF = pipelineModel.transform(inputDF)
Algumas coisas que você deve ter em mente nas implantações em lote são:
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.
332 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google
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:
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")
streamingData =
# 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.
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.
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) .
// 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.
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.
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
+-----------------+
| 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.
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.
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.
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
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)
338 | Capítulo 11: Gerenciando, implantando e dimensionando pipelines de aprendizado de máquina com Apache Spark
Machine Translated by Google
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)
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
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
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.
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
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.
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.
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:
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.
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.
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:
b. Substitua uma junção de mesclagem de classificação, com base no tamanho das tabelas lidas, por uma transmissão
juntar.
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:
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.
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.
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:
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:
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:
Finalmente, a junção de loop aninhado embaralhar e replicar segue uma forma e sintaxe semelhantes:
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
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.
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:
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")
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.
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)
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).
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.
• spark.sql.streaming.ui.enabled=true
• spark.sql.streaming.ui.retainedProgressUpdates=100
• spark.sql.streaming.ui.retainedQueries=100
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.
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 .
@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:
df = faísca.intervalo(3)
df.withColumn("plus_one", pandas_plus_one("id")).show()
+---+--------+
| id|mais_um|
+---+--------+
| 0| | 1|
1| | 2| 2|
3|
+---+--------+
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.
@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)
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:
df.mapInPandas(pandas_filter, esquema=df.schema).show()
+---+
| identificação|
+---+
| 1|
+---+
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.groupby("id").cogroup(df2.groupby("id")
).applyInPandas(asof_join, "time int, id int, v1 double, v2 string").show()
+----+---+---+---+
|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.
// 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|
+-----+
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
// 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")
# Em Python
filtered.explain(mode="formatted")
== Plano Físico ==
* Projeto (3)
+- * Filtro (2)
+- Digitalizar texto (1)
-- Em SQL
EXPLICAR FORMATADO
SELECIONE *
DE tmp_spark_readme
Valor WHERE como "%Spark%"
== Plano Físico ==
* Projeto (3)
+- * Filtro (2)
+- Digitalizar texto (1)
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.
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.
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.
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!
Índice
adição de colunas, 63, 152 arbitrários com estado, 253-261 funções de tipo
Apache Cassandra, 89, 137, 231 139 função array_union(), 139 função
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
driver, 10
361
Machine Translated by Google
Beeline, consultando com, 119 Modo completo (streaming estruturado), 212, 215, 245
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
escrita de, 267 fonte de dados para DataFrames e tabelas SQL, 96 usando,
58-60
Índice | 363
Machine Translated by Google
364 | Índice
Machine Translated by Google
suporte para diversidade de 269 groupBy(), 30, 66, 73, 157, 182, 187, 244, 337 groupByKey(),
arquivos 254, 256
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
Índice | 365
Machine Translated by Google
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
Índice | 367
Machine Translated by Google
por aí, 340 Projeto Tungsten, 4, 16, 82, 119, 167 projeções,
funções definidas pelo usuário (UDFs), 115-117, projetos (MLflow), 324 métricas de publicação
368 | Índice
Machine Translated by Google
rdd.getNumPartitions(), 13 read(), Ruiz, Edgar, Mastering Spark com R, xvi, 285 coluna runID
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
370 | Índice
Machine Translated by Google
cache, 93 sobre, 43
Índice | 371
Machine Translated by Google
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.
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.