Traduzido do artigo “Extracting and Analyzing 1000 Basketball Games using Pandas and Chartify“
Autor: Attila Tóth
Data: 2019-05-08
Introdução
Eu amo estatística descritiva. Visualizar dados e analisar tendências é um dos aspectos mais interessantes de qualquer projeto de data science. Mas e se não tivermos dados apropriados? Ou se os dados que temos não forem suficientes para chegarmos a quaisquer conclusões?
É aí que entra o ‘scraping’ (ético) de sites. Podemos obter todos tipos de dados a partir da internet – tabulares, imagens, vídeos, etc. Apenas precisamos saber algumas técnicas específicas de extração de dados.
Nesse artigo, iremos focar na extração de dados do site NBA.com. Sou um grande fã de da NBA e pensei por que não botar meu conhecimento de ‘scraping’ e criação de websites na análise de esportes?
Você vai achar esse artigo útil mesmo que não seja fã da NBA ou de esportes em geral. Você vai ganhar um entendimento geral sobre como obter, armazenar e analisar dados públicos e não-estruturados e sobre como planejar e implementar um projeto de data science na web. Esteja você interessado em aprender como fazer análise de dados ou em estatísticas esportivas, você irá os próximos minutos com certeza.
Iremos focar em estatística descritiva porque é sempre um elemento chave de qualquer projeto de data science.
Ferramentas que você deveria ter para este projeto
As ferramentas com que iremos trabalhar ao longo desse artigo são:
- Python como a linguagem de programação
- MySQL para armazenar dados
- Pandas para trabalhar os dados, e
- Chartify (obrigado ao pessoal do Spotify) para criar relatórios
A abordagem que iremos tomar no projeto
- Pesquisando a fonte de dados
- Assegurando que princípios éticos estão sendo seguidos
- Inspecionando o site para encontrar ‘data points’
- Entendendo os diferentes campos de dados
- Arquitetando a base de dados
- Obtendo e filtrando dados
- Analisando os dados e gerando relatórios
Pesquisando a fonte de dados
Já vimos aonde esse artigo vai! Vamos usar o site oficial de estatísticas da NBA como nossa fonte de dados. Uso esse site regularmente, ele contém uma enormidade de dados para os fãs da NBA (em especial aqueles com interesse em data science). Além disso, o site é muito bem formatado, o que o torna ideal para o ‘scraping’.
Mas nós não iremos usar o ‘scraping’. Acredite em mim, há uma maneira melhor e mais fácil de obter os dados que estamos buscando e que irei descrever mais tarde no artigo. Primeiro precisamos assegurar que não quebramos nenhum protocolo.
Assegurando que princípios éticos estão sendo seguidos
Precisamos assegurar que podemos usar eticamente o site escolhido como nossa fonte de dados. Por que? Porque queremos ser bons usuários da internet e não queremos fazer nada que prejudique os servidores do site. Como fazemos isso? Responder as questões abaixo deveria ajudar:
- Os dados são públicos? Todos os dados em stats.nba.com são públicos.
- Preocupações com robots.txt? Sitemap: https://stats.nba.com/sitemap.xml; User-agent: * – Não existem limitações. Ainda assim, fique atento à frequência de requisições.
- Como você planeja usar os dados? Pesquisa pessoal e para fins educacionais.
Podemos seguir em frente pois nossos objetivos estão alinhados com esses princípios éticos.
Nota: aconselho você a responder essas questões antes de fazer ‘scraping’ em qualquer site. Nossa abordagem nunca deveria prejudicar o trabalho de outras pessoas.
Inspecionando o site para encontrar ‘data points’
Essa é a parte onde conhecimento sobre HTTP e sobre como funciona um site irá poupar muitas horas. Não estou totalmente familiarizado com o site do qual estamos tentando extrair dados, por isso preciso primeiro inspecioná-lo.
Essa á página inicial que vemos quando abre o stats.nba.com.
Obtemos muitas estatísticas de jogadores, o que por ora não precisamos. Queremos obter dados sobre jogos – não sobre jogadores ou times específicos. Então, precisamos achar uma página onde jogos e resultados são mostrados.
Indo para a página de resultados:
Está ficando melhor – aqui temos resultados completos de jogos e de pontos quarto a quarto. Mas ainda não temos detalhes suficientes para construir nossa base de dados. O que realmente precisamos é essa página:
Esta é a página de jogo. Um jogo por página, com todos os detalhes. Aqui, encontramos um monte de diferentes campos de dados. Essa é uma boa base para construirmos nossa futura base de dados.
Inspecionando ‘requests’
Agora que identificamos onde precisamos ir, é hora de fazer algumas inspeções realmente técnicas. Iremos verificar o que acontece quando requisitamos essa página específica. Para fazê-lo (estou usando Firefox mas deveria ser o mesmo ou algo similar para outros navegadores):
- Clique F12
- Selecione o tab Network
- Recarregue a página (F5)
Agora, deveríamos ver todas as requisições que foram feitas:
A aplicação fez 83 requisições apenas para retornar 1 página! Agora, iremos filtrar as requisições que não têm relevância para nós e ver apenas as requisições de “dados”. Para isso, ligue o botão XHR dentro do tab Network no lado superior direito:
Vemos na maior parte requisições que têm resposta JSON após ligar o botão XHR. Isso é bom para nós porque JSON é um formato bem popular de transferir dados do backend para o frontend. A chance de encontrarmos nossos dados dentro de uma dessas respostas JSON é alta.
Mexendo em alguns dos pontos finais JSON, encontrei a que tem os tipos de dados que estamos procurando.
Essa URL retorna um JSON que contem todos os dados sobre um jogo. É por isso que disse antes que inspecionar corretamente um site antes de escrever o ‘scraper’ pode poupar muitas horas. Já há uma API que podemos usar então não precisamos fazer scraping para coletar os dados.
Agora, a URL de requisição necessita de um parâmetro – GameID. Note que cada jogo tem um GameID único. Portanto, precisamos achar um jeito de coletar os códigos GameId também.
Antes, estávamos olhando na página de resultados. Essa página tem cada jogo e um GameId único para o dia em questão. Um solução possível, a que vamos implementar, é iterar por cada dia (a partir da página de resultados), coletar todos os códigos de GamId e então inseri-los na nossa base de dados.
Nós iremos passar por esses códigos de GameId e parsear os JSONs contendo os detalhes dos jogos. Agora definimos quais campos de dados queremos coletar do JSON:
Entendendo os diferentes campos de dados
Um jogo de basquete tem muitos tipos de dados. Dados sobre times, pontos, jogadores, etc. – são tantos os dados que podemos obter que quase me deixa atônito! Para esse projeto, iremos limitar o escopo para alguns campos específicos:
- GameID: não é crucial para a análise mas em termos da base de dados será útil ter essa informação
- GameDate: para agrupar por data e obter informações sobre dias de jogo. Também para análise histórica
- AwayTeam: nome do time visitante
- HomeTeam: nome do time da casa
- AwayPts -> (Q1, Q2, Q3, Q4): pontos marcados pelo time visitante. Criaremos campos separados para os pontos por quarto
- HomePts -> (Q1, Q2, Q3, Q4): pontos marcados pelo time da casa. Criaremos campos separados para os pontos por quarto
- Referees -> (Referee1, Referee2, Referee3) -> Cada jogo tem três árbitros. Iremos armazenar seus nomes separadamente
- TimesTied -> Número de vezes em que ambos os times tiveram o mesmo número de pontos durante o jogo
- LeadChanges -> Número de vezes em que mudou o time com mais pontos no jogo
- LastMeetingWinner: ganhador do último jogo entre ambos times
- Winner: nome do time ganhador do jogo
Arquitetando a base de dados
Um registro armazena dados sobre um jogo.
Geralmente, quando montando a arquitetura de uma base de dados, as tabelas e sua normalização sempre dependem do tipo de conhecimento que queremos obter com o projeto. Por exemplo, você poderia calcular o ganhador através dos pontos marcados por ambos times. O time com mais pontos é o ganhador. Mas em nosso caso, vou criar uma coluna separada para o ganhador. Porque acho que não será um problema para nós ter um campo redundante como esse.
Tendo dito isso, não vou criar uma coluna separada para o total de pontos marcados por cada time. Apenas armazeno os pontos marcados por quarto por cada time. Se precisarmos obter o total de pontos, teremos que somar os pontos por quarto. Creio que esse não é um sacrifício muito grande considerando que assim podemos analisar os quartos de cada jogo especificamente.
Obtendo e filtrando os dados
Iremos seguir os passos abaixo para obter e filtrar nossos dados:
- Iterando pela página de resultados
- Coletando os GameIDs e os armazenando
- Iterando sobre retornos de dados de jogos e parseando JSON
- Salvando os campos de dados especificados na base de dados
- Limpando os dados
Vamos olhar cada passo em mais detalhes.
1. Iterando pela página de resultados
Basta inspecionar uma única página de resultados para perceber que essa página usa um arquivo JSON para obter dados também. Um exemplo de URL desse tipo de requisição:
https://stats.nba.com/stats/scoreboardV2?DayOffset=0&LeagueID=00&gameDate=03/03/2019
De novo, ao invés de fazer scraping dessa página, vamos usar esse ponto final para obter os GameIDs.
url = "https://stats.nba.com/stats/scoreboardV2?DayOffset=0&LeagueID=00&gameDate=03/03/2019"
requests.get(url, headers=self.headers)
2. Coletando os GameIDs e os armazenando
Iremos coletar os GameIDs do JSON:
games = data["resultSets"][0]["rowSet"]
for i in range(0, len(games)):
game_id = games[i][2]
game_ids.append(game_id)
Nesse código, os dados vêm do JSON parseado que requisitamos no passo anterior. Estamos coletando os GameIDs numa lista chamada game_ids.
Armazenando na base dados:
with self.conn.cursor() as cursor:
query = "INSERT INTO Games (GameId) VALUES (%s)"
params = [(id, ) for id in game_ids]
cursor.executemany(query, params)
self.conn.commit()
3. Iterando sobre respostas de dados de jogos e parseando JSON
Nesse passo agora, vamos usar os GameIDs que coletamos:
def fetch_game_ids(self):
with self.conn.cursor() as cursor:
query = "SELECT GameId FROM Games"
cursor.execute(query)
return [r[0] for r in cursor.fetchall()]
def make_game_request(self, game_id):
sleep(1) #segundos
url = "https://stats.nba.com/stats/boxscoresummaryv2?GameID={game_id}".format(game_id=str(game_id))
return requests.get(url, headers=self.headers)
def game_details(self):
game_ids = self.fetch_game_ids()
for id in game_ids:
data = self.make_game_request(id).json()
4. Salvando os campos de dados especificados na base dados
with self.conn.cursor() as cursor:
query = ("INSERT IGNORE INTO GameStats ("
"GameId, GameDate, AwayTeam, HomeTeam, LastMeetingWinner, Q1AwayPts, "
"Q2AwayPts, Q3AwayPts, Q4AwayPts, Q1HomePts, Q2HomePts, Q3HomePts, Q4HomePts, "
"Referee1, Referee2, Referee3, TimesTied, LeadChanges, Winner"
") VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)")
params = self.filter_details(data)
cursor.execute(query, params)
self.conn.commit()
5. Limpando os dados
Depois de armazenar dados sobre cada jogo da temporada, encontrei alguns pontos fora na base de dados. Removi o jogo de Estrelas da NBA (NBA All-star) da base porque era um ponto muito fora da curva em relação ao total de pontos. Não deveria estar junto dos jogos da temporada regular.
Também removi alguns jogos da pré-temporada. De modo que agora temos apenas os jogos da temporada regular.
Analisando os dados e gerando relatórios
Finalmente, a parte legal:
- Relatórios gerais sobre a base de dados
- Vantagem de jogar em casa
- Distribuição dos pontos marcados
- Pontos por jogo por data
- Comparação entre dois times
- Maiores viradas
- Maiores surras
- Maior número de pontos em dia de jogo
- Jogos mais vibrantes
- Árbitros mais prolíficos
Estes são relatórios ad-hoc interessantes de olharmos. Existem diversas outras formas de analisar essa base de dados – eu te encorajo a bolar outras análise mais avançadas.
Instalando as bibliotecas requeridas
Antes de começarmos a gerar os relatórios, precisamos instalar as bibliotecas que iremos utilizar.
Primeiro, instalamos pandas para lidar com as tabelas de dados:
sudo pip install pandas
Depois, ao invés de matplotlib, vamos instalar a relativamente nova mas fácil de usar biblioteca de gráficos chartify:
sudo pip3 install chartify
Relatórios gerais sobre o conjunto de dados
Para aquecer nossa jornada de visualização de dados, vamos começar com alguns simples relatórios descritivos sobre nossa nova base de dados:
def describe(self):
query = ("SELECT *, (Q1Pts+Q2Pts+Q3Pts+Q4Pts) AS GamePts FROM " "( SELECT (Q1HomePts+Q1AwayPts) AS Q1Pts, (Q2HomePts+Q2AwayPts) AS Q2Pts, (Q3HomePts+Q3AwayPts) AS Q3Pts, " "(Q4HomePts+Q4AwayPts) AS Q4Pts, TimesTied, LeadChanges FROM GameStats"
") s")
df = pd.read_sql(query, self.conn)
d = {'Mean': df.mean(),
'Min': df.min(),
'Max': df.max(),
'Median': df.median()}
return pd.DataFrame.from_dict(d, dtype='int32')[["Min", "Max", "Mean", "Median"]]
Vantagem de jogar em casa
Agora vamos partir para a parte interessante. Vamos gerar um gráfico de pizza para nos dizer se há vantagem de jogar em casa, ie, há mais chances de vitória para uma time jogando em casa, baseado nas estatísticas?
def pie_win_count(self):
query = ("SELECT SUM(CASE WHEN Winner=HomeTeam THEN 1 ELSE 0 end) AS HomeWin, "
"SUM(CASE WHEN Winner=AwayTeam THEN 1 ELSE 0 end) AS AwayWin, "
"SUM(CASE WHEN Winner='OT' THEN 1 ELSE 0 end) AS OT " "FROM GameStats")
df = pd.read_sql(query, self.conn).transpose()
df.columns = [""]
df.plot.pie(subplots=True, autopct='%.2f%%')
plt.show()
Interessante. Assim como no futebol, times da NBA também tem uma vantagem razoável ao jogar em casa. O time da casa ganhou 57% dos jogos. considerando apenas os jogos da temporada regular, vitória em casa: 511, vitória fora: 338, tempo extra: 47.
Distribuição de pontos marcados
Vamos falar de pontos. Vamos usar a biblioteca Chartify a partir daqui. Gerar um histograma dos pontos marcados por jogo:
def pts(self):
query = ("SELECT (Q1AwayPts+Q2AwayPts+Q3AwayPts+Q4AwayPts+Q1HomePts+Q2HomePts+Q3HomePts+Q4HomePts) AS Pts FROM GameStats")
df = pd.read_sql(query, self.conn)
ch = chartify.Chart(y_axis_type='density', blank_labels=True)
ch.set_title("Distribution of points")
ch.axes.set_xaxis_label("Overall Points")
ch.axes.set_xaxis_tick_values([p for p in range(170, 290, 10)])
ch.axes.set_xaxis_tick_orientation('diagonal')
ch.axes.set_yaxis_label("Games")
ch.plot.histogram(
data_frame=df,
values_column='Pts')
ch.show('html')
A maioria dos jogos estão na faixa dos 200 a 240 pontos. Isso dá cerca de 100 a 120 pontos por time por jogo. Há uma diferença enorme no número de jogos que ficam fora dessa faixa.
Pontos por jogo por data
Agora quero ver se há correlação entre a data de um jogo e o número de pontos marcados. Por exemplo, no futebol os times costumam marcar mais gols quando a temporada está na parte final.
def pts_history(self):
query = ("SELECT GameDate, " "Q1HomePts+Q1AwayPts+Q2HomePts+Q2AwayPts+Q3HomePts+Q3AwayPts+Q4HomePts+Q4AwayPts) AS GamePts "
"FROM GameStats ORDER BY GameDate")
df = pd.read_sql(query, self.conn)
ch = chartify.Chart(blank_labels=True, x_axis_type='datetime')
ch.plot.scatter(
data_frame=df,
x_column='GameDate',
y_column='GamePts')
ch.set_title("Game Overall Points")
ch.set_subtitle("By date")
ch.show("html")
Parece que a data de um jogo não importa muito em termos de pontos marcados. Pelo menos não olhando em alto nível.
Comparação entre dois times
Gosto de comparar times para ver como estão performando em relação a si mesmos. Para o nosso estudo, escolhi um time com boa performance e outro com performance ruim.
Esses dois times têm distribuições de pontos bem distintas. Para o Cleveland, é raro chegar a 120 pontos num jogo. Geralmente marca entre 90 e 110. Para o Milwaukee, marca perto de ou acima de 120 pontos.
Baseado nesse gráfico, não surpreende que o Milwaukee esteja em primeiro lugar na sua conferência enquanto o Cleveland é o penúltimo colocado. Seria interessante ver esse gráfico com Kyrie e Lebron de volta no time, mas isso fica para outra vez!
Maiores viradas
Queremos ver viradas. Quem não gosta de ver uma virada quando o jogo já se dava por encerrado? Vamos ver casos em que um time perdia por muito no primeiro tempo e conseguiu virar o jogo.
def comebacks(self):
query = ("SELECT *, ABS(Home1stHalf-Away1stHalf) AS Comeback FROM " "("
"SELECT GameDate, AwayTeam, HomeTeam, (Q1AwayPts+Q2AwayPts) AS Away1stHalf, "
"(Q1HomePts+Q2HomePts) AS Home1stHalf, (Q3AwayPts+Q4AwayPts) AS Away2ndHalf, "
"(Q3HomePts+Q4HomePts) AS Home2ndHalf FROM GameStats" ") s "
"WHERE (Home1stHalf > Away1stHalf AND Home1stHalf+Home2ndHalf < Away1stHalf+Away2ndHalf) OR " "(Home1stHalf < Away1stHalf AND Home1stHalf+Home2ndHalf > Away1stHalf+Away2ndHalf) "
"ORDER BY `Comeback` DESC")
df = pd.read_sql(query, self.conn)
df["1stHalf"] = df.apply(lambda row: str(row["Away1stHalf"]) + ":" + str(row["Home1stHalf"]), axis=1)
df["2ndHalf"] = df.apply(lambda row: str(row["Away2ndHalf"]) + ":" + str(row["Home2ndHalf"]), axis=1)
df = df.drop(["Away1stHalf", "Home1stHalf", "Away2ndHalf", "Home2ndHalf"], axis=1)
df.index += 1
return df
A maior diferença no primeiro tempo que um time conseguiu virar foi de 22 pontos. O vencedor marcou 70 pontos em um tempo em 4 de 5 desses jogos.
Quero chamar atenção para a performance defensiva do Denver Nuggets contra o Memphis Grizzlies. O Denver restringiu o Memphies a 32 pontos no segundo tempo. Durante o intervalo, o Denver realmente se acertou defensivamente.
É esse o tipo de análise que adoro fazer através de visualizações.
Maiores surras
Se olhamos as maiores viradas, precisamos olhar as maiores surras também. Surras são jogos em que um time ganhou por uma margem enorme de pontos:
def blowouts(self):
query = ("SELECT *, ABS(AwayPts-HomePts) AS Difference FROM " "("
"SELECT GameDate, AwayTeam, HomeTeam, (Q1AwayPts+Q2AwayPts+Q3AwayPts+Q4AwayPts) AS AwayPts, " "(Q1HomePts+Q2HomePts+Q3HomePts+Q4HomePts) AS HomePts FROM GameStats"
") s "
"ORDER BY Difference DESC")
df = pd.read_sql(query, self.conn)
df.index += 1
return df
A maior surra foi do Boston em cima do Chicago. Boston venceu por 133 a 77, uma margem rídicula de 56 pontos. O mais surpreendente é que o jogo foi em Chicago, Boston era na verdade o time visitante. Em outro jogo, o Utah anotou somente 68 pontos, o que é 17 pontos por quarto em média. Isso fica muito abaixo da média geral de pontos por quarto (28)
Maior número de pontos em dia de jogo
Agora vamos olhar as coisas de um ângulo diferente. Quais dias de jogos tiveram times marcando pontos muito acima da média geral?
def most_pts_daily(self):
query = ("SELECT GameDate, COUNT(GameDate) AS GameCount, " "SUM(Q1HomePts+Q2HomePts+Q3HomePts+Q4HomePts+Q1AwayPts+Q2AwayPts+Q3AwayPts+Q4AwayPts)/COUNT(GameDate) AS PtsPerGame " "FROM GameStats GROUP BY GameDate "
"ORDER BY PtsPerGame DESC")
df = pd.read_sql(query, self.conn).round(1)
df.index += 1
return df
Veja que a média de pontos por jogo na NBA é 220. Então, os cinco dias que vemos na tabela acima realmente excederam à média. 2019-02-23 também está nessa lista com média de 235,5 pontos por jogo, o que é impressionante considerando que foram 12 jogos nesse dia.
Jogos mais vibrantes (volume 1)
Isso talvez seja subjetivo de acordo com o que cada um de nós entende como “vibrante”. Para os fins deste post, vou definir “vibrante” como sendo o número de mudanças na liderança durante um jogo. Você pode também definir sua própria métrica e gerar o relatório a partir dela.
def most_lead_changes(self):
query = ("SELECT GameDate, AwayTeam, HomeTeam, LeadChanges, " "(Q1AwayPts+Q2AwayPts+Q3AwayPts+Q4AwayPts) AS AwayPts, " "(Q1HomePts+Q2HomePts+Q3HomePts+Q4HomePts) AS HomePts, " "Winner AS Result FROM GameStats "
"ORDER BY LeadChanges DESC")
df = pd.read_sql(query, self.conn)
df.index += 1
return df
Houveram 32 mudanças na liderança no jogo entre Golden State Warriors e Utah Jazz! A liderança mudou a cada 1,5 minutos em média – parece-me que foi um jogo tenso. No final, o Golden State venceu por 124 a 123. Temos dois jogos do San Antonio Spurs na lista, talvez esse time tenha jogos mais apertados que outros times?
Jogos mais vibrantes (volume 2)
Outra maneira de definir o quão vibrante um jogo é, pode ser pelo número de vezes em que estiveram empatados durante o jogo.
def most_ties(self):
query = ("SELECT GameDate, AwayTeam, HomeTeam, TimesTied, (Q1AwayPts+Q2AwayPts+Q3AwayPts+Q4AwayPts) AS AwayPts, "
"(Q1HomePts+Q2HomePts+Q3HomePts+Q4HomePts) AS HomePts, Winner AS Result FROM GameStats "
"ORDER BY TimesTied DESC")
df = pd.read_sql(query, self.conn)
df.index += 1
return df
Interessante, obtivemos jogos totalmente diferentes no top 5 em comparação à lista anterior. 3 dos 5 jogos acabaram em tempo extra. Houveram 26 empates durante o jogo Suns e wizards, o que implica que houve um empate no placar a cada 108 segundos em média.
Árbitros mais prolíficos
Sim, vamos olhar também para algumas estatísticas de árbitros. Ame-os ou não, eles são uma parte importante do jogo.
def referees(self):
query = ("SELECT Referee, COUNT(*) AS GameCount FROM "
"("
"(SELECT Referee1 AS Referee FROM GameStats) " "UNION ALL "
"(SELECT Referee2 FROM GameStats) "
"UNION ALL "
"(SELECT Referee3 FROM GameStats)"
") s "
"GROUP BY Referee ORDER BY GameCount DESC")
df = pd.read_sql(query, self.conn)
df.index += 1
return df
Número de árbitros na liga (que apitaram pelo menos um jogo): 68.
Árbitros mais prolíficos: Karl Lane, Tyle Ford, Pat Fraher, Scott Foster and Josh Tiven. Cada um deles apitou 48 jogos. Temos 124 dias de jogo na base de dados. Isso quer dizer que não seria possível assistir 3 dias de jogo seguidos sem que algum deles estivesse em quadra. Impressionante.
Notas finais
Esse post tem como objetivo inspirar você sobre como fazer uso de dados na web ou outros tipos de dados. Existem cada vez mais ferramentas disponíveis que você pode usar para obter conhecimento a partir de dados públicos. Espero que esse post tenha lhe dado algumas ideias sobre como fazer os dados trabalharem para você.
Você pode também usar essa análise para construir modelos de machine learning. Fizemos a parte de limpeza e exploração de dados – dê um passo além e use seus algoritmos preferidos para prever a probabilidade de vitória de um time. Existem múltiplas possibilidades.
Caso tenha alguma questão ou sugestão, deixe na seção de comentários abaixo. Obrigado pela leitura!
Sobre o autor
Attila Tóth
Attila é o fundador do site ScrapingAuthority.com, onde ele ensina ‘scraping’ e engenharia de dados. Ele tem experiência com design e implementação de soluções de extração e processamento de dados web. Você pode checar o canal de YouTube do Attilla aqui.
Veja também: