Esse é o segundo post de uma série de como construir um produto data-driven de ponta a ponta, caso você ainda não tenha acompanhado os demais, abaixo segue uma síntese com os respectivos links 😀.

  1. Em metadados de normas jurídicas federais coletamos dados do sistema LexML.

No último post coletamos um dataset com o objetivo de criar um data product a partir do treinamento de um modelo de classificação multi-label, treinando-o sobre as ementas de normativos federais.

Como primeiro passo do nosso processo de construção de um produto derivado de aprendizado de máquina, precisamos iniciar com um processo de análise exploratória dos dados (AED). Portanto, vamos carregar os dados em uma instância do TinyDB, em seguida vamos selecionar as features do nosso interesse e carregá-las em um dataframe.

db = TinyDB("./data/metadados_ficha_catalografica.json") 
#persiste todos os registros do banco num objeto
total_normativos = db.all()

#collapse
def alter_cols(df: pd.DataFrame) -> pd.DataFrame:
    """faz um tratamento nas colunas dataPublished e legislationType"""
    normas = df.copy()
    df['datePublished'] = pd.to_datetime(df['datePublished'])
    df['legislationType'] = df['legislationType'].apply(lambda x : x.split("/")[-1] if x else np.nan)
    return df

def load_into_dataframe(records: list) -> pd.DataFrame:
    """carrega os dados em um dataframe"""
    # https://stackoverflow.com/questions/5352546/extract-subset-of-key-value-pairs-from-python-dictionary-object/5352658
    content = [{k: data.get(k, None) for k in ('legislationIdentifier', 'legislationType', 'description',  'keywords',  'datePublished')} for data in records]
    df = pd.DataFrame(content)
    df = alter_cols(df)
    return df

df = load_into_dataframe(total_normativos)

Podemos inspecionar os dois primeiros registros dos nossos dados.

df.head(2)
legislationIdentifier legislationType description keywords datePublished year
0 urn:lex:br:federal:decreto:1985-05-30;91275 Decreto Legislativo ABRE AO MINISTERIO DA EDUCAÇÃO E CULTURA, O CR... [CREDITO SUPLEMENTAR, MINISTERIO DA EDUCAÇÃO (... 1985-05-31 1985.0
1 urn:lex:br:federal:decreto:1985-07-30;91500 Decreto Legislativo INSTITUI A SECRETARIA ESPECIAL DE AÇÃO COMUNIT... [CRIAÇÃO, SECRETARIA ESPECIAL, AÇÃO COMUNITARI... 1985-07-31 1985.0

O campo legislationType nos informa o tipo de normativos existente na base de dados, abaixo apresentamos uma visualização onde podemos identificar os tipos e os respectivos quantitativos.

count_normas_by_year = df.groupby(['legislationType', 'year'])['legislationIdentifier']\
    .count()\
    .reset_index()\
    .rename(columns={'legislationIdentifier' : 'quantitativo'})

Além disso, podemos inspecionar o campo keywords que contém múltiplas tags associadas a mesma norma, como apresentado abaixo:

df.loc[0, 'keywords']
['CREDITO SUPLEMENTAR',
 'MINISTERIO DA EDUCAÇÃO (MEC)',
 'REFORÇO',
 'DOTAÇÃO ORÇAMENTARIA']

Nesse momento precisamos alterar a estrutura do DataFrame para que possamos ter o nosso modelo de dados com uma granularidade de um registro de keyword por linha da tabela.

tags = df.explode('keywords').copy()
tags.head(2)
legislationIdentifier legislationType description keywords datePublished year
0 urn:lex:br:federal:decreto:1985-05-30;91275 Decreto Legislativo ABRE AO MINISTERIO DA EDUCAÇÃO E CULTURA, O CR... CREDITO SUPLEMENTAR 1985-05-31 1985.0
0 urn:lex:br:federal:decreto:1985-05-30;91275 Decreto Legislativo ABRE AO MINISTERIO DA EDUCAÇÃO E CULTURA, O CR... MINISTERIO DA EDUCAÇÃO (MEC) 1985-05-31 1985.0

Com a estrutura dos dados adequada a nossa análise, determinaremos as tags mais frequentes em nosso dataset.

#collapse
def plot_topN_tags(data: pd.DataFrame, field: str, N: int) -> None:
    """Plota um gráfico de barras horizontais com as Top N tags mais frequentes"""
    _ = pd.DataFrame(data)[field].value_counts()[:N]\
        .reset_index()\
        .rename(columns={'index' : 'keywords', 'keywords' : 'quantitativo'})
    chart = alt.Chart(_)\
        .mark_bar()\
        .encode(
            alt.X('quantitativo'),
            alt.Y("keywords", sort='-x'),
            tooltip='quantitativo'
        )\
        .properties(height=700)\
        .configure_mark(color=default_color_2)    
    display(chart)

plot_topN_tags(tags, 'keywords', 25)

Pudemos avaliar que as TOP 25 tags variam em ocorrências desde 16k como o caso de MUNICIPIO até algo próximo a 2.7k para CRIAÇÃO. Além disso, precisamos avaliar quantas tags distintas existem em nosso dataset.

#collapse
num_tags = tags['keywords'].nunique()

count_tags

Constatada o elevado número de tags distintas utilizadas em nosso dataset, é necessário explorar a distribuição do percentual acumulado das suas respectivas frequências. Essa visão poderá nos auxiliar se, eventualmente, estamos lidando com uma distribuição de cauda longa, isto é, um número grande de tags que foram utilizadas apenas algumas vezes ao longo do processo de anotação da base de dados.

#collapse
count_clf_tags = pd.DataFrame(tags.groupby('keywords')['legislationIdentifier']\
                              .count()\
                              .sort_values(ascending=False)\
                              .copy())\
                              .rename(columns={'legislationIdentifier' : 'quantitativo'})
count_clf_tags = count_clf_tags.assign(
    cumulative_sum=count_clf_tags.quantitativo.cumsum())
count_clf_tags = count_clf_tags.assign(
    cumulative_perc= 100*count_clf_tags.cumulative_sum/count_clf_tags.quantitativo.sum(),
    rank=range(1, count_clf_tags.shape[0]+1)
)
count_clf_tags['rank'] = count_clf_tags['rank'].astype('category')
count_clf_tags.reset_index(inplace=True)

A tabela abaixo apresenta que as 25 tags mais frequentes são responsáveis por aproximadamente 40% de todas as ocorrências de anotação em nosso dataset. Assim, temos um indício considerável sobre uma possível distribuição de cauda longa para essa feature.

count_clf_tags.head(25)
keywords quantitativo cumulative_sum cumulative_perc rank
0 MUNICIPIO 16256 16256 4.175292 1
1 EXECUÇÃO 10155 26411 6.783566 2
2 CONCESSÃO 10093 36504 9.375915 3
3 SERVIÇO 9993 46497 11.942579 4
4 APROVAÇÃO 9883 56380 14.480991 5
5 EMPRESA DE TELECOMUNICAÇÕES 9622 66002 16.952365 6
6 RADIODIFUSÃO 9617 75619 19.422456 7
7 ATO 8115 83734 21.506763 8
8 DESTINAÇÃO 7316 91050 23.385850 9
9 NORMAS 5548 96598 24.810833 10
10 ALTERAÇÃO 5263 101861 26.162614 11
11 DECLARAÇÃO 5253 107114 27.511828 12
12 UNIÃO FEDERAL 4828 111942 28.751881 13
13 DESAPROPRIAÇÃO 4682 116624 29.954435 14
14 CREDITO SUPLEMENTAR 4677 121301 31.155705 15
15 DOTAÇÃO ORÇAMENTARIA 4150 125451 32.221617 16
16 INTERESSE SOCIAL 4088 129539 33.271605 17
17 REFORÇO 4049 133588 34.311575 18
18 IMOVEL RURAL 3746 137334 35.273721 19
19 REFORMA AGRARIA 3744 141078 36.235353 20
20 AUTORIZAÇÃO 3672 144750 37.178493 21
21 INSTITUTO NACIONAL DE COLONIZAÇÃO E REFORMA AG... 3604 148354 38.104167 22
22 AREA PRIORITARIA 3569 151923 39.020851 23
23 RENOVAÇÃO 3429 155352 39.901577 24
24 CRIAÇÃO 2725 158077 40.601483 25

Dessa forma, podemos avaliar quantas tags representam 95% das ocorrências de anotação.

#collapse
count_select = count_clf_tags[count_clf_tags['cumulative_perc'] <= 95].copy()

count_tags_95p

Portanto, concluímos que 33% da população de tags do dataset são responsáveis por 95% de todas as anotações realizadas. Por fim, podemos plotar a distribuição de frequência da ocorrência dessa feature e claramente confirma o indício que trata-se de uma feature com distribuição assimétrica e com cauda longa.

#collapse
chart_all_rank = alt.Chart(count_select).mark_area(
    interpolate='step-after',
    line=True
).encode(
    alt.X('rank:O', axis=alt.Axis(values=[50, 500, 1000, 2000, 3000])),
    alt.Y('quantitativo:Q'),
    color=alt.value(default_color_1),
).properties(
    width=400,
    height=300,
    title='Distribuição da frequência de ocorrência das tags'
).interactive()

chart_top100_rank = alt.Chart(count_select[:100]).mark_area(
    interpolate='step-after',
    line=True
).encode(
    alt.X('rank:O', axis=alt.Axis(values=[10, 20, 30, 50, 75, 100])),
    alt.Y('quantitativo:Q'),
    tooltip='keywords',
    color=alt.value(default_color_2)
).properties(
    width=400,
    height=300,
    title='Distribuição da frequência de ocorrência das TOP 100 tags'
).interactive()
alt.vconcat(chart_all_rank, chart_top100_rank)
#chart_all_rank | chart_top100_rank

Para nossa prova de conceito iremos restringir, inicialmente, as tags que representam 50% das ocorrências. Essa decisão restringe o quantitativo a 47 tags, mas pela representatividade destas um percentual muito pequeno de normas serão excluídas da nossa amostragem.

#collapse
top50p_tags = count_clf_tags[count_clf_tags['cumulative_perc'] <= 50.00]\
    ['keywords'].values\
    .tolist() #lista das tags amostradas
#faz um recorte nos dados que contenham as tags selecionadas
sampling_tags = tags[tags['keywords'].isin(top50p_tags)].copy() 
#numero de tags selecionadas
num_tags_sampled = sampling_tags['keywords'].nunique()
#quantitativo de normas que serão excluídas da POC
num_diff_normas = (tags['legislationIdentifier'].nunique()-sampling_tags['legislationIdentifier'].nunique()) 
#percentual de normas que serão excluídas da POC
percent_diff_normas = num_diff_normas/tags['legislationIdentifier'].nunique()*100 

count_tags_95p

Quando apresentamos o nosso problema de aprendizado de máquina falamos que tratava-se de um tarefa de classificação multi-label, portanto, sabendo que cada norma pode receber simultaneamente mais de uma tag, uma pergunta relevante é: quantas tags por normativos costumam ser atribuídas a cada norma?

#collapse
def most_common_tags_frequency(s: pd.Series) -> pd.DataFrame:
    #faz uma contagem da ocorrência dos quantitativos de tags por normativos
    contador_tags = Counter()
    for _ in s:
        contador_tags[_] += 1
    #cria um dataframe para armazenar as informações estruturadas acima
    plot_count = []
    for _ in contador_tags.most_common(20): #TOP 20 ocorrências mais frequentes
        plot_count.append([_[0], _[1]])
    quantidade_tags_mais_frequentes = pd.DataFrame(plot_count, columns=['n° de tags por norma', 'n° de ocorrências'])
    quantidade_tags_mais_frequentes['n° de tags por norma'] = quantidade_tags_mais_frequentes['n° de tags por norma'].astype('category')
    return quantidade_tags_mais_frequentes

#analise para o conjunto original (populacao)
#identifica o número de tags por norma
count_len_pop = df.loc[df['legislationIdentifier']\
                   .isin(sampling_tags['legislationIdentifier']\
                   .unique()), 'keywords'].apply(lambda x : len(x))
quantidade_tags_mais_frequentes_populacao = most_common_tags_frequency(count_len_pop)

#analise para o conjunto amostral
count_len_sampling = sampling_tags.groupby('legislationIdentifier')['keywords'].count()
quantidade_tags_mais_frequentes_sample = most_common_tags_frequency(count_len_sampling)

#graficos
#hide
top20_pop_chart = alt.Chart(quantidade_tags_mais_frequentes_populacao, title='TOP 20 quantidades de tags por norma na população.')\
        .mark_bar(color=default_color_2)\
        .encode(
            alt.X('n° de ocorrências'),
            alt.Y("n° de tags por norma", sort='-x')
        )
#hide_input
top20_sample_chart = alt.Chart(quantidade_tags_mais_frequentes_sample, title="TOP 20 quantidades de tags por norma na amostragem.")\
        .mark_bar(color=default_color_1)\
        .encode(
            alt.X('n° de ocorrências'),
            alt.Y("n° de tags por norma", sort='-x'))
median_num_tags_norma_original = np.median(count_len_pop)
median_num_tags_norma_sample = np.median(count_len_sampling)

Abaixo, podemos ver que um comparativo entre o conjunto de dados originais e da amostragem que realizamos. Para o primeiro, a maior frequência de ocorrência é uma norma ter 10 registros de tags anotadas. A quantidade elevada de anotações por normativo é algo que ao meu ver confirma o potencial de uso de classificação automática por aprendizado de máquina de modo a garantir uma maior homegeniedade e acurácia no processo de classificação.

Nessa POC decidimos restringir o treinamento inicial a um subset das tags mais representativas e como temos uma distribuição de cauda longa é razoável que tenhamos muitos registros de tags pouco frequentes pulverizadas em várias classificações. Mas ao reduzir o escopo de tags em nossa amostragem espera-se uma redução no quantitativo de anotações por norma, e de fato isso aconteceu já que pudemos constatar uma redução da mediana das frequências das quantidades de tags por normativo reduziu de 10 para 5.

count_tags_95p

Assim, definimos um recorte inicial para podermos iniciar o treinamento do nosso modelo de classificação multi-label para a nossa POC, todavia, o treinamento ficará para o próximo post. Espero que tenham gostado de conhecer essa base, tanto quanto eu 👨, até a próxima!