Esse é o terceiro 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.
  2. Em um produto orientado a dados governamentais: parte 1 realizamos uma análise exploratória dos dados e definimos um recorte e um escopo para os dados do projeto.

No último post delimitados um escopo para o conjunto de dados que será utilizado em nosso projeto. Portanto, iremos iniciar carregando o subset construído na etapa anterior.

sampling_tags = pd.read_csv("./data/sampling_tags.csv", sep=';')
sampling_tags.set_index('datePublished', inplace=True)
sampling_tags.sort_index(inplace=True)
sampling_tags.head(2)
legislationIdentifier legislationType description keywords year
datePublished
1980-12-16 urn:lex:br:federal:decreto:1988-12-15;97251 Decreto Legislativo ABRE AO MINISTERIO DA AGRICULTURA, EM FAVOR DE... DOTAÇÃO ORÇAMENTARIA 1980
1980-12-16 urn:lex:br:federal:decreto:1988-12-15;97251 Decreto Legislativo ABRE AO MINISTERIO DA AGRICULTURA, EM FAVOR DE... REFORÇO 1980

Precisamos garantr que nosso conjunto de treinamento esteja restrito ao universo delimitado a normativos que possuam apenas as tags delimitadas para a POC, Por exemplo, o Decreto 97.251 de 1988 possui as seguintes classificações:

keywords = sampling_tags['keywords'].unique()
#restringir o dataset apenas para os normativos que serão utilizados para o treinamento do modelo
sample = population[population['legislationIdentifier'].isin(sampling_tags['legislationIdentifier'])].copy()
sample.loc[0, 'keywords']
['CREDITO SUPLEMENTAR',
 'MINISTERIO DA EDUCAÇÃO (MEC)',
 'REFORÇO',
 'DOTAÇÃO ORÇAMENTARIA']

Todavia, MINISTERIO DA EDUCAÇÃO (MEC) não está contemplado no rol de tags delimitadas para o projeto, desse modo devemos removê-las.

set(sample.loc[0, 'keywords']).difference(keywords)
{'MINISTERIO DA EDUCAÇÃO (MEC)'}
sample['keywords'] = sample['keywords'].apply(
    lambda x : list(set(x).intersection(keywords)) if x else np.nan
)

Como temos um componente temporal em nosso dataset, isto é, normativos publicados e classificados ao longo de diferentes anos é válido realizarmos um verificação se, eventualmente, houve algum período se alguma das tags não foi utilizada, por exemplo. Para isso, iremos subdividr o dataset em 4 décadas e verificaremos se para algum delas não há ocorrência de uso de alguma das tags.

decade1 = sampling_tags["1980-01-01" : "1989-12-31"].copy()
decade2 = sampling_tags["1990-01-01" : "1999-12-31"].copy()
decade3 = sampling_tags["2000-01-01" : "2009-12-31"].copy()
decade4 = sampling_tags["2010-01-01" : "2020-12-31"].copy()
for _, decades in zip([decade1, decade2, decade3, decade4], ['80-89', '90-99', '2000-2009', '2010-2020']):
    inspect = set(keywords).difference(set(_['keywords'].unique()))
    if inspect:
        print(f"{decades} : {inspect}")
80-89 : {'ORÇAMENTO DA SEGURIDADE SOCIAL'}

Para década 80-89 não há registro de uso das tag :ORÇAMENTO DA SEGURIDADE SOCIAL.

Tirando o fato que na década de 80 parecia não haver muita preocupação com destinação de recursos orçamentários para fins de seguridade social, no tocante ao nosso problema o fato temporal não se mostrou um fator relevante na hora de realizar a estratificação do dataset para fins de treinamento e avaliação.

Como bem apontado por Rachel Thomas, devemos subdividr o nosso conjunto de dados em 3 diferentes subsets: treinamento, validação e teste. O último, deve ser o mais representativo possível da realidade esperada em na "vida real". Portanto, vamos fazer uma amostragem aleatória que obedeça a distribuição de frequência das tags existentes em nosso dataset.

Preparação do conjunto de teste

Precisamos fazer uma seleção de dados para compor o dataset de teste que esteja bem próxima aos dados reais, principalmente, no que tocante a distribuição de frequência das tags. Portanto, vamos analisar essa distribuição.

def build_frequency_of_occurrences_for_top_tags(df: pd.DataFrame) -> pd.DataFrame:
    freq_dataframe = pd.DataFrame(df['keywords'].value_counts(normalize=True))\
        .assign(count=df['keywords'].value_counts().values)\
        .rename(columns={'keywords' : 'percent'})
    freq_dataframe = freq_dataframe.assign(
        frequency=freq_dataframe['percent']*freq_dataframe['count']
    )
    freq_dataframe['frequency'] = freq_dataframe['frequency'].astype('int')
    return freq_dataframe 
calculate_theoretical_test_sampling_frequency = build_frequency_of_occurrences_for_top_tags(sampling_tags)
calculate_theoretical_test_sampling_frequency.head(5)
percent count frequency
MUNICIPIO 0.083606 16256 1359
EXECUÇÃO 0.052228 10155 530
CONCESSÃO 0.051909 10093 523
SERVIÇO 0.051395 9993 513
APROVAÇÃO 0.050829 9883 502

Em seguida, podemos criar uma função para criar o nosso dataset de teste com uma distribuição mais próxima ao desejado.

def create_test_dataset(frequency_dataset: pd.DataFrame, 
                        sampling_dataset: pd.DataFrame) -> pd.DataFrame:
    """
    Construção do dataset de test.
    """
    df = sampling_dataset.copy()
    container = {tag : "" for tag in df['keywords'].unique()}
    consolida_ids = []
    for tag in container.keys():
        get_frequency = frequency_dataset.loc[tag, 'frequency']
        get_id_samples = df[df['keywords'] == tag].sample(get_frequency).index.tolist()
        container[tag] = get_id_samples
        df = df.drop(get_id_samples)
    [consolida_ids.extend(ids) for ids in container.values()]
    test_dataset = sampling_dataset.loc[consolida_ids]
    test_dataset = test_dataset.sort_index()
    return test_dataset
test_dataset = create_test_dataset(calculate_theoretical_test_sampling_frequency, sampling_tags)

Uma vez consolidado o dataset de teste, podemos avaliar a distribuição de frequência das tags comparado ao nosso conjunto amostral.

calculate_practical_test_sampling_frequency = build_frequency_of_occurrences_for_top_tags(test_dataset).rename(columns={
        'percent': 'pract_percent',
        'count' : 'pract_count',
        'frequency' : 'pract_frequency'
}
)
#cria um dataframe para comparar os datasets de treino com o da amostra.
compare_theory_with_practical = pd.concat(
    [calculate_theoretical_test_sampling_frequency, \
     calculate_practical_test_sampling_frequency],
    axis='columns'
).assign(
    diff_percent=((calculate_practical_test_sampling_frequency.pract_percent-calculate_theoretical_test_sampling_frequency.percent)*100))
compare_theory_with_practical.head(2)
percent count frequency pract_percent pract_count pract_frequency diff_percent
MUNICIPIO 0.083606 16256 1359 0.1004 5395 541 1.650972
EXECUÇÃO 0.052228 10155 530 0.0747 4014 299 2.267160
compare_theory_with_practical.sort_values(by='diff_percent', ascending=False)\
    ['diff_percent'].tail(1)
CREDITO SUPLEMENTAR   -1.533066
Name: diff_percent, dtype: float64
compare_theory_with_practical.sort_values(by='diff_percent', ascending=False)\
    ['diff_percent'].head(1)
RADIODIFUSÃO    2.446513
Name: diff_percent, dtype: float64

Para a nossa amostragem realizada para construção do dataset de teste temos uma variação positiva de +2.45% da frequência esperada para RADIODIFUSÃO e de variação negativa -1.53% para CREDITO SUPLEMENTAR. Consideramos esses desvios aceitáveis ao que se esperava para a distribuição das tags no dataset original.

Por fim, selecionamos os normativos que farão parte do dataset de teste.

sample_test = sample.loc[set(test_dataset.index)].copy()
sample_test.head(1)
legislationType description keywords datePublished
legislationIdentifier
urn:lex:br:federal:medida.provisoria:1995-06-27;1037 Medida_provis%C3%B3ria CRIA, A GRATIFICAçÃO DE DESEMPENHO E PRODUTIVI... [AMBITO, CRIAÇÃO, CRITERIOS, CORRELAÇÃO, DESTI... 1995-06-28

Preparação do conjunto de treino e validação

Uma vez concluída a construção dos dados de teste, precisamos construir aqueles designados para as etapas de treinamento e validação. Uma das formas mais comuns de realizar isso é fazer uma separação 70/30, isto é, 70% dos dados serão destinadas para parte de treinamento e os demais para validação. Um das formas mais comuns, é fazer uso da função train_test_split da biblioteca scikit-learn. Iremos avaliar se essa abordagem de divisão do dataset mediante o uso da função train_test_split é satisfatatória para nosso caso, isto é, esperamos que após o split tenhamos uma distribuição de frequências das tags similar ao nosso conjunto amostral.

Em seguida, excluímos os normativos que fazem parte do dataset de teste e podemos dar prosseguimento para a delimitação dos datasets de treino e validação.

sample_treino_validacao = sample.drop(test_dataset.index)
Y_sample_treino_validacao = sample_treino_validacao.drop(['legislationType', 'description', 'datePublished'], axis='columns')
X_sample_treino_validacao = sample_treino_validacao.drop(['keywords'], axis='columns')

Por fim, podemos fazer uma chamada a função train_test_split com intuito de separar nosso dataset na proporção de 70% dos dados para treino e 30% para validação.

xTrain, xValidate, yTrain, yValidate = train_test_split(
    X_sample_treino_validacao, 
    Y_sample_treino_validacao, 
    test_size = 0.3, 
    random_state = 42)

Vamos inspecionar se para cada um dos conjuntos de dados, foram contemplados com as 47 tags que compõe o escopo da nossa POC.

yTrain.explode('keywords')['keywords'].nunique(), yValidate.explode('keywords')['keywords'].nunique()
(47, 47)

O primeiro requisito que é a presença de todas as tags está cumprido e em seguida podemos realizar uma avaliação da distribuição de frequência destas em ambos os datasets. Primeiramente, vamos calcular a frequência relativa.

y_train_percentage = pd.DataFrame(yTrain.explode('keywords')['keywords']\
                                  .value_counts(normalize=True))\
                                  .rename(columns={'keywords': 'y_train_percentage'})
y_train_percentage.index.name = 'keywords'

y_validate_percentage = pd.DataFrame(yValidate.explode('keywords')['keywords']\
                                  .value_counts(normalize=True))\
                                  .rename(columns={'keywords': 'y_validate_percentage'})
y_validate_percentage.index.name = 'keywords'

Depois podemos realizar um cálculo para conhecer a diferença das frequências relativas das tags do dataset de treino e validação com aqueles da nossa amostragem.

analyze_keywords_distribution = y_train_percentage.join(y_validate_percentage, how='left')\
    .join(pd.DataFrame(calculate_theoretical_test_sampling_frequency['percent'])\
          .rename(columns={'percent' : 'sampling_percent'}), how='left')
analyze_keywords_distribution = analyze_keywords_distribution.assign(
    diff_train_to_sample=(analyze_keywords_distribution.y_train_percentage-analyze_keywords_distribution.sampling_percent)*100)\
    .assign(
    diff_validate_to_sample=(analyze_keywords_distribution.y_validate_percentage-analyze_keywords_distribution.sampling_percent)*100)\
    .reset_index()

O gráfico de barras abaixo, mostra a diferença encontrada na frequência relativa das tags do conjunto de treino quando comparado aos dados amostrais. Assim, é possível concluir que a variação absoluta não foi superior a 0.8%.

alt.Chart(analyze_keywords_distribution, title='Diferença de frequências das keywords do conjunto de treino para o conjunto amostral.').mark_bar().encode(
    alt.X("keywords"),
    alt.Y("diff_train_to_sample:Q", title='diferença de frequência / %'),
    color=alt.condition(
        alt.datum.diff_train_to_sample > 0,
        alt.value(default_color_1),  # The positive color
        alt.value(default_color_2),  # The negative color
    ),
    tooltip=['keywords', 'diff_train_to_sample']
).interactive().properties(width=800)

De forma similar, abaixo temos o mesmo gráfico com comparativo entre o dataset de validação e o conjunto de dados amostrais. Assim, de forma análoga a sua variação absoluta também não foi superior a 0.8%.

alt.Chart(analyze_keywords_distribution, title='Diferença de frequências das keywords do conjunto de validação para o conjunto amostral.').mark_bar().encode(
    alt.X("keywords"),
    alt.Y("diff_validate_to_sample:Q", title='diferença de frequência / %'),
    color=alt.condition(
        alt.datum.diff_train_to_sample > 0,
        alt.value(default_color_1),  # The positive color
        alt.value(default_color_2)  # The negative color
    ),
    tooltip=['keywords', 'diff_validate_to_sample']
).interactive().properties(width=800)

Consideramos ambos os desvios excelentes, portanto, concluímos que o uso do test_train_split satisfatório. Por fim, e não menos importante, vamos inspecionar se não houve nenhum erro no processamento e se os três conjuntos de dados construídos não compartilham de nenhum normativo em comum.

set(sample_test.index).intersection(xTrain.index).intersection(xValidate.index)
set()

Por conseguinte, com todas as verificações razóaveis tendo sido realizadas e validadas, podemos persistir os dados para as etapas futuras do trabalho.

sample_test.drop(['keywords'], axis='columns').to_csv("./data/xTest.csv", sep=';')
sample_test.drop(['legislationType', 'description', 'datePublished'], axis='columns').to_csv("./data/yTest.csv", sep=';')
xTrain.to_csv("./data/xTrain.csv", sep=';')
xValidate.to_csv("./data/xValidate.csv", sep=';')
yTrain.to_csv("./data/yTrain.csv", sep=';')
yValidate.to_csv("./data/yValidate.csv", sep=';')

Assim, concluímos a construção dos conjuntos de dados necessários para o treinamento do modelo de aprendizado de máquina. No próximo post, iremos começar a etapa de treinamento 👨‍💻! Até mais!