#!/usr/bin/env python # coding: utf-8 # In[1]: # The code was removed by Watson Studio for sharing. # # Dados Abertos e Ciência de Dados: Análise da Actividade Parlamentar da XIV Legislatura Portuguesa # # ***Open Data and Data Science: Analysis of the Parliamentary Activity of the 14th Legislature of Portugal*** # # --- # # **Frederico Muñoz** # # --- # # 👉 **IBM Data Platform URL**: https://dataplatform.cloud.ibm.com/analytics/notebooks/v2/0b23f01c-e55a-4fe4-a2ca-57f2e478bf3e/view?access_token=4cc13fba3f3967530650b11a2956ed27c5cec9042113237f97cb0e4c58d3905f # # 👉 **Jupyter Viewer**: https://nbviewer.jupyter.org/github/fsmunoz/pt-act-parlamentar/raw/master/Actividade%20Parlamentar%20da%20XIV%20Legislatura.ipynb # # # 👉 *[How to cite this notebook](#How-to-cite-this-notebook)* # --- # # **Abstract**: *The application of Data Science methods and analysis to voting records is a well-established practice; in this paper we describe the process of obtaining, cleaning, exploring and choosing adequate analytic methods for the purpose of determining the distance between parties in the Portuguese Parliament. Through the use of institutional Open Data resources we discuss the different approaches possible in terms of clustering and dimension reduction, using DBSCAN, Spectrum Clustering and Multidimensional Scaling to augment the information produced by distance and affinity matrices, and discuss the results of these methods in the available data in a time where the concepts of Left and Right are often hotly debated. A detailed explanation of how an euclidean distance matrix is obtained and how clustering and MDS work using test datasets is also included.* # # --- # ## Introdução # 👉 ***This notebook is currently only fully available in Portuguese; while a future translation is likely in the meantime one can make good use of the fact that the actual code blocks are in English to at least follow through those parts*** # # O posicionamento absoluto e relativo dos vários partidos políticos no Parlamento português tem sido motivo de interesse redobrado nos últimos anos. A eleição de deputados de partidos sem anterior presença parlamentar tem alimentado o debate cujas implicações ideológicas foram vísiveis de forma bastante prática na problemática em torno da escolha de lugares: partidos desagradados com o lugar atribuído *(“Iniciativa Liberal Descontente Com Lugar Atribuído a Deputado No Parlamento - TSF” 2020)*, dificuldades gerais em termos de arrumação dos deputados *(Renascença 2019)*, questões de ordem mais ou menos prática em torno de acessos (Almeida 2019), enfim, várias dimensões para uma questão que acaba por revelar a importância simbólica do posicionamento absoluto e relativo de cada partido no hemiciclo. # # Esta questão não é particularmente nova *(Lourenço 2020)*, colocando-se em maior ou em menor grau com a entrada de novos partidos e a consequente necessidade de tomada de posição por parte do recém-chegado partido e a harmonização (possível) com os restantes, sendo que a sua posterior actividade parlamentar (nas suas diversas vertentes) poderá ou não alinhar-se com a sua auto-identificação (reflectida ou não nos lugares no hemiciclo). # # O ponto de partida para esta análise foi precisamente tentar descobrir se exclusivamente com base na actividade parlamentar, e em concreto no registo de votações, é possível estabelecer relações de proximidade e distância que permitam um agrupamento que não dependa de classificações a priori, e se sim, de que forma estes agrupamentos confirmam ou divergem da percepção existente? # # A utilização de dados abertos disponibilizados pelo Parlamento torna esta análise substancialmente mais simples, embora não sem a necessidade de tratamento e validação dos dados; de um ponto de vista prático este bloco de notas demonstra como aceder e transformar os dados de uma forma que pode ser útil para outras análises. No cenário nacional referência para a iniciativa http://hemiciclo.pt que, em linha com iniciativas europeias semelhantes, fornecesse um interface para um maior escrutinio da actividade parlamentar e um conjunto alargado de indicadores directos e indirectos do maior interesse *(Sapage 2020)*. O presente trabalho tem alguns pontos de contacto com esta iniciativa, dentro dos limites que o seu objectivo pedagógico estabelece. # # A combinação de dados abertos com um bloco de notas Jupyter permite que o leitor tenha visibilidade dos vários passos e transformações *(Randles et al. 2017)*, o que pode por vezes apresentar uma excessiva complexidade para quem não tenha familiaridade com programação; tentámos obviar esta limitação através da descrição das várias acções de forma a que se possa seguir a lógica e fruir dos resultados. Esta transparência assume uma dimensão adicional tendo em conta a temática que nos proposmos analisar, embora seja importante de forma tranversal (sobre a importância da repetibilidade, rastreabilidade, acesso e o papel de blocos Jupyter no contexto de open science ver, entre outros, exemplos em ecologia *(Powers and Hampton 2019)* astronomia *(Wofford et al. 2019)*). # # A plataforma utilizada para o desenvolvimento deste trabalho é a IBM Watson Data Platform (https://dataplatform.cloud.ibm.com/), que permite a utilização de *notebooks* Jupyter no contexto de uma gestão integral do processo de _Data Science_. # # 👉 *NB: a visualização do conteúdo deste bloco de notas terá variações conforme a forma que estiver a ser manipulado; na sua versão "estática" os diagramas e tabelas serão também eles estáticos. É, contudo, feito para poder ser instanciado e executado de forma interactiva, sendo a forma mais rápida a criação de uma conta gratuita na IBM Watson Data Platform (com acesso ao Watson Studio) e importação deste trabalho, ou a utilização de Binder como descrito na secção [Execução do bloco de notas](#Execução-do-bloco-de-notas).* # ## Metodologia # Com base nos dados disponibilizados pela Assembleia da República em formato XML _(“Dados Abertos” 2020)_ são criadas _dataframes_ (tabelas de duas dimensões) com base no processamento e selecção de informação relativa aos padrões de votação de cada partido (e/ou deputados não-inscritos). # # São fundamentalmente feitas as seguintes análises: # # 1. Vista geral das votações de cada partido, visualizado através de um _heatmap_ # 2. Matriz de distância euclidiana entre todos os partidos e visualização de *clustering* hierárquico através de um dendograma e método de Ward. # 3. Identificação de grupos (*clustering*) por DBSCAN e *Spectral Clustering*, com criação de matriz de afinidade # 4. Redução das dimensões e visualização das distâncias e agrupamentos num espaço cartesiano a duas e três dimensões através de *Multidimensional Scaling* (MDS) # # A utilidade deste tipo de análise em ciência política é reconhecida *(Figueiredo Filho et al. 2014)* e tem sido aplicada a vários registos de votações; a análise presente tem como principal diferença o ser efectuado sobre as votações de partidos e não, como é mais comum na bibliografia consultada, a deputados individuais. # # O tratamento prévio dos dados em formato XML é feito de forma a seleccionar as votações de cada partido (ou deputado não inscrito), num processo com alguma complexidade que é por isso detalhado em secção própria do Apêndice # # A informação é obtida a partir das listas publicadas de votações relativas a # # - Actividades # - Iniciativas # # Os dados utilizados são um subconjuntos dos disponibilizados, sendo que qualquer erro ou omissão nos dados originais irá ter imediato reflexo nos resultados das análises. # # Este trabalho não tem como objectivo principal apenas mostrar os resultados finais, mas também (ou fundamentalmente) todo o processo comum em *Data Science* para que se chegue até eles; é por isso pleno de blocos de código que tentámos contextualizar com descrições que tornem a sua compreensão dispensável para a quem não interessem esss detalhes, ao mesmo tempo que desviámos para secções do Apêndice discussões mais extensas de vários passos. Requer, ainda assim, algum esforço em termos de seguir o caminho (por vezes pejado de desvios, atalhos e retrocessos) até aos diversos resultados apresentados. # # 👉 *NB: O processo de tratamento de dados não é indiferente para o resultado final: *são feitas escolhas a vários níveis (desde a selecção dos dados considerados importantes aos algoritmos escolhidos) que têm impacto nos resultados, nem que seja por omissão*. Mais do que evitá-lo (o que não seria possível), optámos por identificar de forma clara as escolhas feitas e explicar as razões que levaram à sua escolha: cada leitor poderá assim determinar a razoabilidade de cada uma e, sobretudo, ensaiar novas formas que considere mais adequadas.* # ## Instalação de pré-requisitos # # Como primeiro passo definimos alguns valores que deverão ser utilizados pelo bloco de notas Jupyter para exibir tabelas e diagramas e algumas bibliotecas essenciais que são importantes para poder executar o bloco de notas de forma independente (nomeadamente em ambientes Jupyter exteriores ao IBM Watson Data Platform como o Binder https://mybinder.org/ ). # In[1]: get_ipython().system('pip install -q itables matplotlib pandas bs4 html5lib lxml seaborn sklearn pixiedust') get_ipython().run_line_magic('matplotlib', 'notebook') from itables import show import itables.options as opt opt.maxColumns=100 opt.maxRows=2000 opt.lengthMenu = [10, 20, 50, 100, 200, 500] # ## Obtenção, limpeza e tratamento dos dados # # # Esta fase é fundamental para toda a restante análise: é onde obtemos os dados e os transformamos em informação num formato que pode ser facilmente manipulado. # ### Obtenção do ficheiro # # Na página do Parlamento podem ser obtidos, para cada legislatura, ficheiros em formato JSON ou XML com a informação relativa a cada "área temática". Para a XIV Legislatura estamos interessados em duas dessas áreas: # # - **Iniciativas:** _"...Da lista de tipo de Iniciativas entradas, constam Projetos e Propostas de Lei, Projetos e Propostas de Resolução, Apreciações Parlamentares, Inquéritos Parlamentares, Projetos de Revisão Constitucional, Projetos de Deliberação, Projetos de Regimento, Ratificações, sendo as mais usuais, projetos de lei que são apresentados por Deputados, Grupos Parlamentares ou um grupo de cidadãos, e as propostas de lei apresentadas pelo Governo e pelas Assembleias Legislativas das Regiões Autónomas."_ # - **Actividades:** _"...Consideram-se Atividades Parlamentares as seguintes atividades: Apreciação de relatórios entregues por entidades externas, Audiências, Audições, Cerimónias, Conta Geral do Estado, Debates, Declarações Políticas, Defesa Nacional, Deslocações no âmbito das Comissões, Deslocações do Presidente da República, Eleição e composição para órgãos externos, Eventos no âmbito de Comissões, Grandes Opções do Conceito Estratégico da Defesa Nacional, Interpelações ao Governo, Moções, Orçamento e Conta de Gerência da AR, Orientação da Política Orçamental, Perguntas ao Governo, Programa de Estabilidade e Crescimento, Programa do Governo, Relatórios de Segurança Interna, Segurança Interna, Votos."_ # # Estas áreas concentram a maioria (se não a totalidade) das votações dos partidos, algo que tendo em conta o objectivo é fundamental: para a análise proposta só interessam eventos onde seja possível extraír a informação da votação, e dentro destes (como veremos) os que sejam directamente imputáveis a partidos. # # Començando pelas **iniciativas parlamentares**, definimos o URL da versão em XML do ficheiro; para o processamento deste formato usaremos `Element.Tree`, uma das opções em Python para este fim que vem incluída de base, pelo que o seguinte bloco descarrega o ficheiro e converte-o numa "árvore" (onde os "ramos" e as "folhas" são subdivisões da informação dado o formato hierárquico) # # In[2]: from urllib.request import urlopen import xml.etree.ElementTree as ET ini_url = 'http://app.parlamento.pt/webutils/docs/doc.xml?path=6148523063446f764c324679626d56304c3239775a57356b595852684c3052685a47397a51574a6c636e52766379394a626d6c6a6157463061585a68637939595356596c4d6a424d5a57647063327868644856795953394a626d6c6a6157463061585a686331684a566935346257773d&fich=IniciativasXIV.xml&Inline=true' ini_tree = ET.parse(urlopen(ini_url)) # ### Formato XML via Element.Tree, autoria das iniciativas e transformações preliminares # # Esta árvore pode ser visualizada abrindo o ficheiro original num editor de código que o formate de forma adequada (nomeadamente através de indentação dos blocos); trata-se de um ficheiro relativamente grande (~30 MB) e tem um conteúdo estruturado da forma habitual para o formato como pode ser observado na seguinte extracção das primeiras linhas: # In[3]: ## Import BeautifulSoup for the pretty-priting from bs4 import BeautifulSoup ## Get a string with the XML from the root down xmlstr = ET.tostring(ini_tree.getroot(), encoding='utf8', method='xml') ## Print the first 300 charaters; note that extra tags will be added to make the output "valid" print(BeautifulSoup(xmlstr[0:300], "xml").prettify()) # O código final é o resultado final de um processo de estudo do formato (com tentativas, avanços e recuos) mas, em termos gerais, o seguinte bloco diz-nos que: # # - A variável `ini_tree` é do tipo `xml.etree.ElementTree.ElementTree` # - Na raíz da árvore está um conjunto (`array`) de elementos # - Esses elementos têm a etiqueta `pt_gov_ar_objectos_iniciativas_DetalhePesquisaIniciativasOut` # # Por sua vez estes últimos elementos são constituidos por mais "ramos" e "folhas" que contém informação sobre a iniciativa: quem a propôs, a fases, as votações e os seus resultados, etc. # In[4]: print(type(ini_tree)) print(ini_tree.findall(".")) counter = 0 for initiative in ini_tree.findall('.')[0]: ## Only provide an example of the first lines if counter < 5: print(initiative) counter += 1 # O total de iniciativas é-nos dados pelo valor acumulado no bloco anterior: # In[5]: print("Total de iniciativas contidas na árvore: ", counter) # Dada a importância para o objectivo proposto é útil descrever o elemento `pt_gov_ar_objectos_VotacaoOut` que contém a informação sobre votações; podemos começar por contar as entradas na árvore: # In[6]: counter = 0 for c in ini_tree.findall(".//pt_gov_ar_objectos_VotacaoOut"): print('.', end='') counter += 1 # Tal como anteriormente o acumulador indica-nos o total, neste caso de votações: # In[7]: print("Total de votações contidas na árvore: ", counter) # O seguinte bloco encontra _apenas a primeira ocorrência_, para simplificar a discussão dos dados apenas a um caso: # In[8]: ## Get the first occurence of a "VotacaoOut" entry for c in ini_tree.find(".//pt_gov_ar_objectos_VotacaoOut"): print("{0:15}: {1}".format(c.tag,c.text)) # Note-se para já que existe informação que não está presente (nomeadamente a autoria); a autoria da _iniciativa_ pode ser determinada facilmente mas não se relaciona de forma directa com a autoria da matéria em votação, e existem iniciativas sem votações associadas (cf. [Iniciativas e votações](#Iniciativas-e-votações)) # # ### Votações: de descrição textual a informação estruturada # # É importante notar que o o resultado da votação está em "texto livre", o que nos remete para a diferença entre dados estruturados e não-estruturados [DataStructureData2018]; de forma simples significa que antes de podermos utilizar os dados temos de os transformar pois o que temos no campo `detalhe` não pode ser usado sem ser convertido numa tabela com o par _partido/votação_ # In[9]: ## Find the first instance of VotacaoOut for c in ini_tree.find(".//pt_gov_ar_objectos_VotacaoOut"): print("\t{0:15}: {1}".format(c.tag, c.text)) # O texto contido no `detalhe` tem informação descritiva que contém formatação HTML. É necessário processar o texto de forma a poder criar uma tabela que associe a cada partido (ou deputado não-inscrito) o seu sentido de voto. Este processamento seria simples se existisse sempre uma única forma de descrever as votações, o que não acontece. # #### Partidos e deputados na determinação do sentido de voto # O tratamento e transformação de dados é um processo que por vezes parece estar sempre inacabado; seja 80% do tempo gasto _(“Cleaning Big Data: Most Time-Consuming, Least Enjoyable Data Science Task, Survey Says” 2020)_ ou menos _(Dodds 2020)_ tem sempre uma importância fundamental. Até agora cobrimos já vários aspectos que nos colocam muito próximos da utilização dos dados de forma estruturada, mas ainda restam detalhes fundamentais. # # Um ponto importante a relembrar: a metodologia adoptada e os objectivos da análise prendem-se com o posionamento relativo de partidos políticos, por via dos seus grupos parlamentares ou deputados individuais, e não com o perfil de votação de cada deputado. O sistema parlamentar português assenta na representatividade por via de eleição de deputados eleitos por partidos mas os deputados votam de forma individual. Com variações dependendo dos partidos. A chamada "disciplina partidária" é a regra, por razões que não são únicas do sistema político português _(Jackson 1968)_. # # Esta realidade está presente na forma como os votos são apresentados por partidos nos registos oficiais mas apresenta também excepções importantes: # # 1. **Votações nominais onde todos os votos são descritos de forma individual.** # 2. **Votações onde existem votos de deputados diferentes do seu grupo** sendo apresentados de forma nominal. # 3. **Votações onde existem votos de deputados diferentes do seu grupo***, apresentados como um número total de deputados do grupo com esse sentido divergente. # 4. **Votações onde os votos são indicados sem excepções**, sendo este o caso mais comum e também mais simples de cobrir. # # Nos três primeiros casos não é imediata a posição de cada partido, e uma opção seria excluir estas votações completamente. Outra opção, e a que escolhemos, foi o de **determinar o sentido de voto com base na maioria dos votos**. # # O reduzido número de votações por este método tornam esta decisão de impacto reduzido, mas não deixa de existir: olhando para as votações relativas à eutanásia é conhecida a forma diferenciada como foi votada. A sua eliminação iria, por exemplo, omitir dados que afastam a IL do CDS-PP e CHEGA, à direita, e o PCP do PEV e BE, à esquerda. _O reduzido número de casos não deve remover a necessidade de se assumir a possibilidade do impacto ser relevante quando mais à frente verificarmos que a distância relativa de partidos (em particular à direita) é muito semelhante_, o que torna qualquer observação adicional passível de impactar o agrupamento e particularmnte o dendograma, razão pela qual considerámos adequada a decisão de extrapolar a decisão da maioria dos deputados de um partido como indicador da posição do partido. # # Os detalhes do processamento para cada tipo de votação é discutido em detalhe no apêndice [Processamento da descrição de votos](#Processamento-da-descrição-de-votos), com os respectivos exemplos. O seguinte bloco de código é o resultado final do tratamento dos vários casos: # In[10]: from bs4 import BeautifulSoup import re ## Iteract through the existing dict def party_from_votes (votes): """ Determines the position of a party based on the majority position by summing all the individual votes. Argument is a dictionary returned by parse_voting() Returns a dictionary with the majority position of each party """ party_vote = {} for k, v in votes.items(): ## Erase the name of the MP and keep the party only ## only when it's not from the "Ninsc" group - ## these need to be differentiated by name if re.match(".*\(Ninsc\)" , k) is None: nk = re.sub(r".*\((.+?)\).*", r"\1", k) else: nk = k ## If it's the first entry for a key, create it if nk not in party_vote: party_vote[nk] = [0,0,0] ## Add to a specific index in a list if v == "A Favor": party_vote[nk][0] += 1 elif v == "Abstenção": party_vote[nk][1] += 1 elif v == "Contra": party_vote[nk][2] += 1 for k,v in party_vote.items(): party_vote[k]=["A Favor", "Abstenção", "Contra"][v.index(max(v))] return party_vote def parse_voting(v_str): """Parses the voting details in a string and returns a dict. Keyword arguments: v_str: a string with the description of the voting behaviour. """ ## Split by the HTML line break and put it in a dict d = dict(x.split(':') for x in v_str.split('
')) ## Remove the HTML tags for k, v in d.items(): ctext = BeautifulSoup(v, "lxml") d[k] = ctext.get_text().strip().split(",") ## Invert the dict to get a 1-to-1 mapping ## and trim it votes = {} if len(v_str) < 1000: # Naive approach but realistically speaking... works well enough. for k, v in d.items(): for p in v: if (p != ' ' and # Bypass empty entries re.match("[0-9]+", p.strip()) is None and # Bypass quantified divergent voting patterns (re.match(".*\w +\(.+\)", p.strip()) is None or # Bypass individual votes... re.match(".*\(Ninsc\)" , p.strip()) is not None)): # ... except when coming from "Ninsc" #print("|"+ p.strip() + "|" + ":\t" + k) votes[p.strip()] = k else: # This is a nominal vote since the size of the string is greater than 1000 for k, v in d.items(): for p in v: if p != ' ': votes[p.strip()] = k ## Call the auxiliary function to produce the party position based on the majority votes votes = party_from_votes(votes) return votes # O resultado da votação nominal relativa à eutanásia que foi dada como primeiro exemplo é simplificado e apresenta a votação maioritária de cada partido como aquela que será considerada (um tratamento semelhante, por simples omissão, é dado às restantes variações): # In[11]: for v in ini_tree.find(".//pt_gov_ar_objectos_VotacaoOut/[id='86004']"): if v.tag == "detalhe": print("\t{0:15}: {1}".format(v.tag,parse_voting(v.text))) else: print("\t{0:15}: {1}".format(v.tag,v.text)) # #### Deputados não-inscritos # # Existe um tratamento especial no código para os deputados não-inscritos de forma a evitar que nestes casos seja removido o nome; recentemente foram aprovadas alterações que permitem a diferenciação de forma mais clara entre as duas deputadas não-inscritas _(Lusa 2020a)_ mas que não se aplicam retroactivamente aos dados existentes. # # Esta situação leva a que seja necessário diferenciar pelo nome, caso contrário na seguinte votação seriam contabilizados votos para um grupo/partido _"Ninsc"_, o que não faria sentido. # In[12]: ## Example of vote where two MPs which are not registered with a party would ## be grouped under a generic "Unregistered" group for v in ini_tree.find(".//pt_gov_ar_objectos_VotacaoOut/[id='88184']"): if v.tag == "detalhe": print("\t{0:15}: {1}".format(v.tag,parse_voting(v.text))) else: print("\t{0:15}: {1}".format(v.tag,v.text)) # ## Criação do _dataframe_ # # Após estes vários desvios (fundamentais para que se consiga produzir o resultado necessário) voltamos à representação em árvore que resulta da interpretação do formato XML da votação e com o auxílio das várias funções á entretanto definidas criamos um modelo tabular de todas as votações. # In[13]: import collections root = ini_tree counter=0 ## We will build a dataframe from a list of dicts ## Inspired by the approach of Chris Moffitt here https://pbpython.com/pandas-list-dict.html init_list = [] for voting in ini_tree.findall(".//pt_gov_ar_objectos_VotacaoOut"): votep = voting.find('./detalhe') if votep is not None: init_dict = collections.OrderedDict() counter +=1 init_dict['id'] = voting.find('id').text ## Add the "I" for Type to mark this as coming from "Iniciativas" init_dict['Tipo'] = "I" for c in voting: if c.tag == "detalhe": for party, vote in parse_voting(c.text).items(): init_dict[party] = vote elif c.tag == "descricao": init_dict[c.tag] = c.text elif c.tag == "ausencias": init_dict[c.tag] = c.find("string").text else: init_dict[c.tag] = c.text init_list.append(init_dict) ## Provide progression feedback print('.', end='') print(counter) # O processamento criou uma lista de dicionários que convertemos numa _dataframe_ Pandas. Note-se como várias votações não têm o campo `descricao`: obter a informação do título da iniciativa seria uma opção possivel para permitir adicionar contexto a estes casos, mas como referido, não sendo tal necessário para os objectivos imediatos deste trabalho, optou-se por simplificar o código e omitir essa informação (ver [Informação adicional da iniciativa](#Informação-adicional-da-iniciativa) para um exemplo) # In[14]: import pandas as pd ini_df = pd.DataFrame(init_list) print(ini_df.shape) ini_df.head() # A tabela contém 1723 linhas (o número de votações que tinhamos já observado) e 20 colunas; cada "caso" equivale a uma votação com os dados respectivos indicados pelas colunas, como pode ser observado tomando o primeiro caso como exemplo: # In[15]: ini_df.loc[0] # Nem todos os campos estão preenchidos em todas as votações, o que é normal visto nem todos os campos serem obrigatórios. # ### Não-inscritos e a necessidade de processamento adicional # # Já anteriormente o tratamento dos "não inscritos" levou à necessidade de pré-processamento; agora que já temos a estrutura de dados num _dataframe_ é também necessário proceder a alguns passos de pós-processamento que surgem após uma leitura supercicial da tabela acima. # # Existem dois casos que precisam ser analisados de forma individual; na secção [Processamento adicional dos não-inscritos](#Processamento-adicional-dos-não-inscritos) do apêndice detalhamos a informação que levou à decisão tomada e que é a seguinte: # # - Agrupar os dados do Livre e da deputada Joacine Katar Moreira, por ser deputada única e assim existir uma continuidade em termos do registo de votação. # - Não includir a deputada Cristina Rodrigues na comparação global dado o limitado número de votações. # # O seguinte bloco cria uma nova coluna, `L/JKM`, composta da sobreposição dos votos de ambos. # In[16]: ## Copy Livre voting record to new aggregate columns... ini_df["L/JKM"] = ini_df["L"] ## ... and fill the NAs with JKM voting record. ini_df["L/JKM"] = ini_df["L/JKM"].fillna(ini_df["Joacine Katar Moreira (Ninsc)"]) ini_df[["descricao","L","Joacine Katar Moreira (Ninsc)","L/JKM"]] # Processo semelhante aplicamos à deputada Cristina Rodrigues para podermos fazer uma análise específica: # In[17]: ## Copy PAN voting record to new aggregate columns... ini_df["PAN/CR"] = ini_df["PAN"] ## ... and update/replace with CR voting where it exists ini_df["PAN/CR"].update(ini_df["Cristina Rodrigues (Ninsc)"]) ini_df[["descricao","PAN","Cristina Rodrigues (Ninsc)","PAN/CR"]] # ### Processamento de Actividades # Até agora trabalhámos sobre _iniciativas_; existe uma fonte adicional de informação (contida num ficheiro separado e com uma estrutura semelhante mas nao idêntica) relativa às _Actividades Parlamentares_, onde também se incluém votações. # # Toda a discussão e código de processamento feito pode ser aplicado pelo que não iremos repetir as considerações já feitas; o processo de obtenção do XML é idêntico: # In[18]: act_url = 'http://app.parlamento.pt/webutils/docs/doc.xml?path=6148523063446f764c324679626d56304c3239775a57356b595852684c3052685a47397a51574a6c636e52766379394264476c32615752685a47567a4c31684a566955794d45786c5a326c7a6247463064584a684c30463061585a705a47466b5a584e595356597565473173&fich=AtividadesXIV.xml&Inline=true' act_tree = ET.parse(urlopen(act_url)) # O processamento também o é, com a diferença de que adicionamos um campo `descricao` que é na verdade obtido a partir do assunto da actividade. # In[19]: import re import collections root = act_tree counter=0 ## We will build a dataframe from a list of dicts ## Inspired by the approach of Chris Moffitt here https://pbpython.com/pandas-list-dict.html act_list = [] def get_toplevel_desc (vid, tree): """ Gets the top-level title from a voting id """ for c in tree.find(".//pt_gov_ar_objectos_VotacaoOut/[id='"+ vid +"']/../.."): if c.tag == "assunto": return c.text for voting in act_tree.findall(".//pt_gov_ar_objectos_VotacaoOut"): act_dict = collections.OrderedDict() counter +=1 votep = voting.find('./detalhe') if votep is not None: act_dict['id'] = voting.find('id').text ## Add the "A" for Type to mark this as coming from "Iniciativas" act_dict['Tipo'] = "A" for c in voting: if c.tag == "id": act_dict['descricao'] = get_toplevel_desc(c.text, act_tree) if c.tag == "detalhe": for party, vote in parse_voting(c.text).items(): act_dict[party] = vote elif c.tag == "ausencias": act_dict[c.tag] = c.find("string").text else: act_dict[c.tag] = c.text act_list.append(act_dict) ## Provide progression feedback print('.', end='') print(counter) # Criamos também um _dataframe_ com base na informação recolhida: # In[20]: act_df = pd.DataFrame(act_list) print(act_df.shape) act_df.head() # É necessário aplicar o mesmo princípio relativo à agregação de votos do Livre e Joacine Katar Moreira, PAN e Cristina Rodrigues. # In[21]: ## Copy Livre voting record to new aggregate columns... act_df["L/JKM"] = act_df["L"] ## ... and fill the NAs with JKM voting record. act_df["L/JKM"] = act_df["L/JKM"].fillna(act_df["Joacine Katar Moreira (Ninsc)"]) ## Copy PAN voting record to new aggregate columns... act_df["PAN/CR"] = act_df["PAN"] ## ... and update/replace with CR voting where it exists act_df["PAN/CR"].update(act_df["Cristina Rodrigues (Ninsc)"]) act_df[["descricao","PAN","Cristina Rodrigues (Ninsc)","PAN/CR"]].head() # ### Agregação das votações # # Neste momento temos dois _dataframes_, um para as Iniciativas e outro para as Actividades; durante o processo de construção de ambos foi adicionada uma coluna `Tipo` que permite identificar, se necessário, a origem da votação. # # No presente trabalho consideramos todas as votações de forma idêntica pelo que nos resta construir um _dataframe_ único que seja a junção dos existentes, tendo em consideração que as colunas de ambos não são exactamente as mesmas. # In[22]: print(ini_df.sort_index(axis=1).columns) print(act_df.sort_index(axis=1).columns) # A concatenação dos dados é feita através da remoção das colunas diferentes. # In[23]: votes = pd.concat([ini_df.drop(["tipoReuniao"],axis=1),act_df.drop(["data","publicacao"],axis=1)], sort=True) # Este último passo de processamento dá origem à tabela final que irá ser usada para as várias análises posteriores; o seu formato formato tabular pode servir de base para análises com outras ferramentas (ver [Conversão do _dataframe_ em CSV](#Conversão-do-_dataframe_em-CSV)). # In[24]: print(votes.shape) votes.columns # ## Exploração e visualização dos dados # Tendo a nossa fonte de dados devidamente construída podemos fazer algumas análises exploratórias com base em visualizações. # # Para começar seria interessante podermos ter uma representação de alto-nível que apresentasse as votações de forma global. Para este fim um mapa térmico (_heatmap_) é uma opção. # # Começamos por criar um novo _dataframe_ que contenha apenas o sentido de voto dos partidos que vamos analisar, removendo os campos adicionais. Esta acção poderia ser feita também por remoção de colunas: optamos pela especificação dos campos também porque nos permite colocar os vários partidos em "ordem", consoante a sua posição no parlamento _(“Estão Distribuídas (para Já) as Cadeiras No Parlamento - DN” 2020)_, o que facilita a localização e interpretação dos dados (e inclusivamente a maior ou menor pertinência dessa dimensão) # In[25]: votes_hm = votes[['BE', 'PCP', 'PEV', 'L/JKM', 'PS', 'PAN','PSD','IL','CDS-PP', 'CH']] votes_hm.head() # Este mapa apresenta os votos seguindo o seguinte esquema de cores: # # - A Favor: verde # - Abstenção: amarelo # - Contra: vermelho # - Ausência/NA: preto # In[26]: import matplotlib.pyplot as plt import matplotlib as mpl import seaborn as sns votes_hmn = votes_hm.replace(["A Favor", "Contra", "Abstenção", "Ausência"], [1,-1,0,2]).fillna(0) voting_palette = ["#FB6962","#FCFC99","#79DE79", "black"] fig = plt.figure(figsize=(8,8)) sns.heatmap(votes_hmn, square=False, yticklabels = False, cbar=False, cmap=sns.color_palette(voting_palette), ) plt.show() # Existem alguns pontos interessantes que podem ser observados desde já. Uma análise da origem das iniciativas e actividades a votação poderá confirmar ou desmentir estas observações: # # 1. **Uma maior tendência para votos "A Favor" à esquerda** # # Pode relacionar-se com uma maior produção de propostas a votação. # # 2. **Uma notória diferenciação do PS na maior quantidade de votos Contra** # # Enquanto partido que suporta o Governo, esta tendência pode relacionar-se com a anterior e representar o voto contra de propostas de outros partidos. # # 3. **Algum acompanhamento do PSD nos votos contra do PS** # # Podem representar convergências em propostas apresentadas pelo próprio ou em matérias consideradas estratégicas. # # 4. **Uma maior tendência para abstenções à direita** # # Mais uma vez pode estar relacionada com a autoria das propostas ou representar uma maior abertura a outras propostas ao usar mais a Abstenção do que a votação contra. # # 5. **Ausências visíveis no Livre/Joacine Katar Moreira e Chega** # # São dois deputados únicos pelo que é compreensível quando comparado com grupos parlamentares, embora se note que a IL tem também um deputado único e não regista comportamente idêntico. # # 6. **O PAN parece estar "deslocado"** # # Observando o alinhamento de cores o PAN estaria mais próximo de qualquer dos partidos de ambos os lados dos seus vizinhos imediatos (PS e PSD), sendo que o comportamente em termos de abstenção o parece aproximar mais do lado esquerdo (L/JKM). # # 7. **Aparente maior heterogeneidade à direita e homogeneidade à esquerda** # # Pode ser artefacto visual dado pelo maior número de abstenções mas parece existir uma maior variação cromática à direita do que à esquerda. # # # Estes são apenas algumas das impressões que uma exploração visual inicial proporciona; para algumas delas podemos encontrar novas visualizações que forneçam informação adicional. O seguinte diagrama de barras mostra o sentido de voto de cada partido ordenado em ordem descrecente de votos contra. # In[27]: from matplotlib.colors import ListedColormap votes_against = votes_hm.apply(pd.Series.value_counts).sort_values(by='Contra', ascending=False, axis=1).T votes_against.plot(kind='bar', stacked=True, colormap=ListedColormap(sns.color_palette("RdBu_r", 4)),linewidth=0) plt.legend(loc='center left', bbox_to_anchor=(0.7, 0.15)) plt.show() # ### Quem vota com quem: identificação de votações idênticas # # Com estes dados podemos tentar obter uma resposta mais clara do que o "mapa térmico" anterior nos apresenta como sendo semelhanças e diferenças no registo de votação. # # Uma das questões que se coloca (e normalmente coloca-se com maior ênfase sempre que há uma votação que é apontada como sendo "atípica", com base na percepção geral do que é o comportamente de voto habitual de cada partido) é saber "quem vota com quem". Estes dados podem ser obtidos através da identificação, para cada partido, da quantidade de votações onde cada outro votou da mesma forma e criação de uma tabela com os resultados: # In[28]: import numpy as np pv_list = [] print("Total voting instances: ", votes_hm.shape[0]) ## Not necessarily the most straightforard way (check .crosstab or .pivot_table, possibly with pandas.melt and/or groupby) ## but follows the same approach as before in using a list of dicts for party in votes_hm.columns: pv_dict = collections.OrderedDict() for column in votes_hmn: pv_dict[column]=votes_hmn[votes_hmn[party] == votes_hmn[column]].shape[0] pv_list.append(pv_dict) pv = pd.DataFrame(pv_list,index=votes_hm.columns) pv # Com estes dados podemos usar um "mapa térmico" (_heatmap_), uma visualização especialmente adequada para os casos onde temos valores que em magnitude em duas dimensões; esta visualização vai fundamentalmente facilitar a interpretação das proximidades e distâncias ao combinar a informação numérica já obtida com uma categorizaçã cromática que depende do valor: neste caso, quantas mais votações com votações iguais, mais escuro. # In[30]: fig = plt.figure(figsize=(8,8)) ax = fig.add_subplot() sns.heatmap( pv, cmap=sns.color_palette("mako_r"), linewidth=1, annot = True, square =True, fmt="d", cbar_kws={"shrink": 0.8}) plt.title('Portuguese Parliament 14th Legislature, identical voting count') plt.show() # Esta visualização já nos fornece pistas mais concretas em termos de quem votam mais vezes com quem, mas tem uma limitação que não deve ser ignorada: a valorização do sentido de voto não está a ser tido em conta, para a tabela acima é exactamente igual que um partido vote a Favor e que o outro vote Contra ou se Abstenha. Esta forma binária de considerar "proximidade" descarta informação relevante ao não considerar que, mesmo nos casos onde existem diferentes sentidos de voto, existem diferenças importantes. # # O ideal seria, portanto, conseguir determinar não só a quantidade de vezes que os partidos votam da mesma forma, mas também quantificar de forma diferente quando não o fazem. Isto para todos os partidos, e para todas as votações. E é precisamente isso que iremos fazer através da **criação de uma matriz de distâncias assente na distância numérica entre os sentidos de voto**. # ## Matriz de distância entre os partidos # Com base nos histórico de votações de cada partido produzimos uma matriz de distâncias entre eles; uma matriz de distâncias é uma matriz quadradra $n\times n$ (onde _n_ é o número de partidos) e onde a distância entre _p_ e _q_ é o valor de $ d_{pq} $. # # $ # \begin{equation} # D= \begin{bmatrix} d_{11} & d_{12} & \cdots & d_{1 n} \\ d_{21} & d_{22} & \cdots & d_{2 n} \\ \vdots & \vdots & \ddots & \vdots \\ d_{31} & d_{32} & \cdots & d_{n n} \end{bmatrix}_{\ n\times n} # \end{equation} # $ # # # A distância é obtida através da comparação de todas as observações de cada par usando uma determinada métrica de distância, sendo a distância euclideana bastante comum em termos gerais e também dentro de estudos sobre o mesmo domínio temático _(Krilavičius and Žilinskas 2008)_: cada elemento da matriz representa $ d\left( p,q\right) = \sqrt {\sum _{i=1}^{n} \left( q_{i}-p_{i}\right)^2 }$, equivalente, para dois pontos $P,Q $ , à mais genérica distância de Minkowski $ D\left(P,Q\right)=\left(\sum _{i=1}^{n}|x_{i}-y_{i}|^{p}\right)^{\frac {1}{p}} $ para $ p = 1$, mas note-se que a diagonal da matrix irá representar a distância entre um partido e ele próprio, logo $ d_{11} = d_{22} = \dots = d_{nn} = 0 $. # # Na secção [Distâncias e matrizes](Distâncias_e_matrizes) colocámos uma discussão mais detalhada (mas passo-a-passo e destinada a quem não tenha necessariamente presente a matemática utilizada) sobre distâncias, _clustering_ e como são calculdadas, para quem tenha interesse numa compreensão mais quantitativa da matéria. # # A conversão de votos em representações númericas pode ser feita de várias formas _(Hix, Noury, and Roland 2006)_; adoptamos a abordagem de Krilavičius & Žilinskas (2008) no já citado trabalho relativo às votações no parlamento lituano por nos parecer apropriada à realidade portuguesa: # # * A favor: 1 # * Contra: -1 # * Abstenção: 0 # * Ausência: 0 # # Este ponto é (mais um) dos que de forma relativamente opaca - pois raramente os detalhes têm a mesma projecção que os resultado finais - podem influenciar os resultados; cremos que em particular a equiparação entre _abstenção_ e _ausência_ merece alguma reflexão: considerámos que uma ausência em determinada votação tem um peso equivalente à abstenção, embora uma de forma passiva e outra activa. # # Para obtermos a matriz de distância usamos a função `pdist` e construímos um _dataframe_ que é uma matriz simétrica das distâncias entre os partidos. # In[31]: from scipy.spatial.distance import squareform from scipy.spatial.distance import pdist import scipy.spatial as sp, scipy.cluster.hierarchy as hc from itables import show votes_hmn = votes_hm.replace(["A Favor", "Contra", "Abstenção", "Ausência"], [1,-1,0,0]).fillna(0) ## Transpose the dataframe used for the heatmap votes_t = votes_hmn.transpose() ## Determine the Eucledian pairwise distance ## ("euclidean" is actually the default option) pwdist = pdist(votes_t, metric='euclidean') ## Create a square dataframe with the pairwise distances: the distance matrix distmat = pd.DataFrame( squareform(pwdist), # pass a symmetric distance matrix columns = votes_t.index, index = votes_t.index ) #show(distmat, scrollY="200px", scrollCollapse=True, paging=False) ## Normalise by scaling between 0-1, using dataframe max value to keep the symmetry. ## This is essentially a cosmetic step to #distmat=((distmat-distmat.min().min())/(distmat.max().max()-distmat.min().min()))*1 distmat # O mapa térmico é construído directamente a partir da matriz de distância, fornencendo uma forma bastante mais intuitiva de visualizar as distâncias: # In[32]: ## Display the heatmap of the distance matrix fig = plt.figure(figsize=(8,8)) ax = fig.add_subplot() sns.heatmap( distmat, cmap=sns.color_palette("Reds_r"), linewidth=1, annot = True, square =True, cbar_kws={"shrink": 0.8}) plt.title('Portuguese Parliament 14th Legislature, Distance Matrix') plt.show() # A ordem dos índice é a que foi determinada anteriormente e baseada na localização dos partidos e deputados no hemiciclo; deixando considerações adicionais para depois note-se que duas das impressões iniciais obtidas pela observação do mapa térmico das votações) parecem confirmar-se: # # 1. O PAN parece de facto deslocado (restando saber para qual dos lados). # 2. O quadrante superior esquerdo exibe distâncias menores que o inferior direito, demonstrando uma maior proximidade entre si dos partidos à esquerda do que à direita. # # Com base nas distâncias procedemos ao agrupamento usando o método de Ward _(Carvalho et al. 2009)_ de forma a obtermos uma árvore de proximidade hierárquica: um dendograma, neste caso associado ao mapa térmica com as linhas e colunas devidamente reordenadas de forma a permitirem visualizar em simultâneo as distâncias e o agrupamentos (ver [Distâncias e matrizes](Distâncias_e_matrizes) para uma explicação mais aprofundanda do tema) # In[33]: ## Perform hierarchical linkage on the distance matrix using Ward's method. distmat_link = hc.linkage(pwdist, method="ward", optimal_ordering=True ) sns.clustermap( distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, #standard_scale=1, row_linkage=distmat_link, col_linkage=distmat_link, figsize=(8,8)).fig.suptitle('Portuguese Parliament 14th Legislature, Clustermap') plt.show() # Uma visualização apenas do dendograma é também possível; a altura das linhas e os pontos de derivação não são aleatórios, representam as distâncias determinadas pelo método de agrupamento: # In[34]: from scipy.cluster.hierarchy import dendrogram fig = plt.figure(figsize=(8,5)) dendrogram(distmat_link, labels=votes_hmn.columns) plt.title("Portuguese Parliament 14th Legislature, Dendogram") plt.show() # 👉 *Estamos a trabalhar sobre o total de votações, incluíndo Actividade e Iniciativas; uma questão que se pode colocar é se existem diferenças, algo que discutimos em [Diferenças entre Actividades e Iniciativas](#Diferenças-entre-Actividades-e-Iniciativas).* # ## Identificar grupos e reduzir dimensões # # Já conseguimos determinar as distâncias e os agrupamentos, mas as possibilidades de visualização não se esgotam por aí. # # ### _Clustering_ de observações: DBSCAN e _Spectrum Scaling_ # # Uma forma diferente de determinar agrupamentos é através de métodos de _clustering_, que procuram determinar agrupamentos de pontos com base em mecanismos específicos de cada um dos algoritmos. # # Vamos demonstrar dois deles, e como passo preliminar vamos transformar a nossa matriz de distâncias: ao contrário do dendograma anterior estes métodos utilizam uma _matriz de afinidade_, onde valores mais altos significam uma maior semelhança (e, consequentemente, para uma matriz simétrica a diagonal passa de 0 para 1). # # Como passo preliminar normalizamos as distâncias no intervalo $ [0,1] $: # In[35]: import numpy as np distmat_mm=((distmat-distmat.min().min())/(distmat.max().max()-distmat.min().min()))*1 pd.DataFrame(distmat_mm, distmat.index, distmat.columns) # ... e subtraimos à unidade todos os valores da matriz, uma forma simples de obter a matriz de afinidade (mas não a única, nem a que garantidamente resulta sempre melhor). O resultado é uma matriz cujos valores indicam a _semelhança_ entre os pares, ao invés da _distância_. # In[36]: affinmat_mm = pd.DataFrame(1-distmat_mm, distmat.index, distmat.columns) affinmat_mm # A matriz de afinididade pode agora ser visualizada de forma semelhante ao mapa térmico de distância: # In[37]: sns.set(style="white") ## Make the top triangle mask = np.triu(np.ones_like(affinmat_mm, dtype=np.bool)) fig = plt.figure(figsize=(8,8)) ax = fig.add_subplot() plt.title('Portuguese Parliament 14th Legislature, Affinity Matrix') ## Display the heatmap of the affinity matrix, masking the top triangle sns.heatmap( affinmat_mm, cmap=sns.color_palette("Greens"), linewidth=1, annot = False, square =True, cbar_kws={"shrink": .8}, mask=mask,linewidths=.5) plt.show() # Existem vários métodos de proceder à identificação de _clusters_; um deles é o DBSCAN (_Density-Based Spatial Clustering of Applications with Noise_), um algoritmo que, entre outras características, não necessita de ser inicializado com um número pré-determinado de grupos, procedendo à sua identificação através da densidade dos pontos _(“DBSCAN: Macroscopic Investigation Python” 2018)_. # # Note-se que estes métodos são normalmente utilizados com um maior número de observações; seria, por exemplo, um método que através do registo dos votos individuais de cada deputado identificaria os partidos. Aqui temos um número reduzido de observações (pois estamos a utilizar uma matriz de semelhança entre $10\times10$, o que também explica o reduzido número de _clusters_ identificados - dois, alinhados com a primeira divisão do dendograma: # In[38]: from sklearn.cluster import DBSCAN dbscan_labels = DBSCAN(eps=1.1).fit(affinmat_mm) dbscan_labels.labels_ dbscan_dict = dict(zip(distmat_mm,dbscan_labels.labels_)) dbscan_dict # Outra abordagem para efectuar a identificação de grupos passa pela utilização de _Spectral Clustering_, uma forma de _clustering_ que utiliza os valores-próprios e vectores-próprios de matrizes como forma de determinação dos grupos. Este método necessita que seja determinado _a priori_ o número de _clusters_; com base na análise anterior (dendograma) observámos que temos 4 grupos constituidos por 2 partidos, e nesse sentido usar 4 permite-nos aferir se a distribuição dos restantes está alinhada com a separação proposta pelo modelo anterior. # In[39]: from sklearn.cluster import SpectralClustering sc = SpectralClustering(4, affinity="precomputed",random_state=2020).fit_predict(affinmat_mm) sc_dict = dict(zip(distmat,sc)) print(sc_dict) # O agrupamento está em linha com o que observámos no dendograma - mas tal não era garantido ou necessário pois diferentes formas de agregação podem ter resultados diferentes que devem ser fonte de análise e podem fornecer dados adicionais interessantes. A utilização de _Spectrum Scaling_ permite-nos aqui determinar um número de grupos maior. # ### _Multidimensional scaling_ # # Até agora temos conseguido extrair informação interessante dos dados de votação: # # 1. O mapa térmico de votação permite-nos uma primeira visão do comportamente de todos os partidos. # 2. A matriz de distâncias fornece-nos uma forma de comparar as distâncias entre os diferentes partidos através de um mapa térmico. # 3. O dendograma identifica de forma hierárquica agrupamentos. # 4. Através de DBSCAN e _Spectrum Clustering_ identificamos "blocos" com base na matriz de afinidade. # # Não temos ainda uma forma de visualizar a distância relativa de cada partido em relação aos outros com base nas distâncias/semelhanças: temos algo próximo com base no dendograma mas existem outras formas de visualização interessantes. # # Uma das formas é o _multidimensional scaling_ que permite visualizar a distância ao projectar em 2 ou 3 dimensões (também conhecidas como _dimensões visualizavies_) conjuntos multidimensionais, mantendo a distância relativa _(“Graphical Representation of Proximity Measures for Multidimensional Data « The Mathematica Journal” 2020)_. # # Como é habitual temos em Python, através da biblioteca `scikit-learn` (que já usámos para DBSCAN e _Spectrum Clustering_), uma implementação que podemos usar sem grande dificuldade _(“2.2. Manifold Learning — Scikit-Learn 0.23.2 Documentation” 2020)_. # In[40]: from sklearn.manifold import MDS mds = MDS(n_components=2, dissimilarity='precomputed',random_state=2020, n_init=100, max_iter=1000) ## We use the normalised distance matrix but results would ## be similar with the original one, just with a different scale/axis results = mds.fit(distmat_mm.values) coords = results.embedding_ coords # O resultado é um um conjunto de pontos definidos por coordenadas, neste caso 2 ($x,y$) dado termos especificado que queriamos reduzir para `n_components = 2`. Com estas coordenadas podemos facilmente criar um gráfico de dispersão: # In[41]: ## Graphic options sns.set() sns.set_style("ticks") fig, ax = plt.subplots(figsize=(8,8)) plt.title('Portuguese Parliament Voting Records Analysis, 14th Legislature', fontsize=14) for label, x, y in zip(distmat_mm.columns, coords[:, 0], coords[:, 1]): ax.scatter(x, y, s=250) ax.axis('equal') ax.annotate(label,xy = (x-0.02, y+0.025)) plt.show() # Através de MDS podemos agora ter uma ideia mais clara do que, visualmente, representam as distâncias que calculámos, reduzidas para duas dimensões. Isto dá-nos a possibilidade de definirmos agrupamentos da forma que consideremos mais adequada. # # Em todo o caso, e visto que temos os resultados de DBSCAN e de _Spectrum Clustering_, podemos adicionar também esta informação, complementando o MDS com a informação anterior ao definirmos as cores dos marcadores de cada partido consoante o grupo previamente identificado. # # Para o caso de DBSCAN: # In[42]: from sklearn.manifold import MDS import random sns.set() sns.set_style("ticks") fig, ax = plt.subplots(figsize=(8,8)) fig.suptitle('Portuguese Parliament Voting Records Analysis, 14th Legislature', fontsize=14) ax.set_title('MDS with DBSCAN clusters (2D)') for label, x, y in zip(distmat_mm.columns, coords[:, 0], coords[:, 1]): ax.scatter(x, y, c = "C"+str(dbscan_dict[label]), s=250) ax.axis('equal') ax.annotate(label,xy = (x-0.02, y+0.025)) plt.show() # e para _Spectrum Clustering_, com um maior número de _clusters_: # In[43]: from sklearn.manifold import MDS import random sns.set() sns.set_style("ticks") fig, ax = plt.subplots(figsize=(8,8)) fig.suptitle('Portuguese Parliament Voting Records Analysis, 14th Legislature', fontsize=14) ax.set_title('MDS with Spectrum Scaling clusters (2D)') for label, x, y in zip(distmat_mm.columns, coords[:, 0], coords[:, 1]): ax.scatter(x, y, c = "C"+str(sc_dict[label]), s=250) ax.axis('equal') ax.annotate(label,xy = (x-0.02, y+0.025)) plt.show() # É interessante reparar que ambos os métodos apresentam resultados compatíveis (os resultados de _Spectrum Clustering_ são essencialmente uma divisão dentro dos grupos identificados por DBSCAN) e ambos fazem visualmente sentido quando consideradas as distâncias do diagrama de MDS. # ### MDS em 3 dimensões: hiperplanos ideológicos # # Usámos MDS a duas dimensões, mas podemos fazê-lo também a 3 dimensões. Esta visualização será, provavelmente, a que mais interesse despertará: não por ser qualitativamente melhor que a anterior (adiciona de facto mais uma dimensão mas é discutível que seja mais compreensível), mas porque quem queira usar os dados apresentados até agora para justificar as suas próprias posições está de certa forma constrangido pela forma directa como as tabelas e os gráficos são exibidos: é sempre possível (e desejável) tecer considerações várias sobre o que foi apresentado mas essas considerações estão limitadas pela bidimensionalidade dos diagramas. # # Tal não acontece com a visualização do MDS em 3D: na forma interactiva que apresentamos permite a manipulação (nos vários sentidos da palavra) do cubo de forma a construir seja qual for o "compasso", "quadrante" ou esquema similar que seja desejado: ajustando os vários eixos há sempre forma de se poder "provar" que determinado partido está "no extremo" ou que outro "está próximo", que um é "do centro" e os outros "marginais": tal como estrelas numa constelação pode-se construir artificialmente uma visão a duas dimensões que não reflecte a verdadeira distância. # # Nesta espécie de pacote "faça o seu próprio compasso político" cada um passa a poder escolher o hiperplano ideológico que mais lhe convém, com a vantagem de poder ser apresentado como "baseado em dados e análises quantitativas" (mesmo que, ao omitir a terceira dimensão, esteja de facto a representar a informação _pior_ do que a versão a duas dimensões). # # Dito isto, a versão interactiva (e até certas visualização fixas de certos ângulos que permitem uma maior noção da terceira dimensão) é útil pois permite uma maior interactividade e adiciona mais uma dimensão, logo potencialmente mais informação. # # Para podernos anotar o gráfico definimos algums funções auxiliares _(“Python - Matplotlib: Annotating a 3D Scatter Plot - Stack Overflow” 2020)_ # In[44]: ## From https://stackoverflow.com/questions/10374930/matplotlib-annotating-a-3d-scatter-plot from mpl_toolkits.mplot3d.proj3d import proj_transform from matplotlib.text import Annotation class Annotation3D(Annotation): '''Annotate the point xyz with text s''' def __init__(self, s, xyz, *args, **kwargs): Annotation.__init__(self,s, xy=(0,0), *args, **kwargs) self._verts3d = xyz def draw(self, renderer): xs3d, ys3d, zs3d = self._verts3d xs, ys, zs = proj_transform(xs3d, ys3d, zs3d, renderer.M) self.xy=(xs,ys) Annotation.draw(self, renderer) def annotate3D(ax, s, *args, **kwargs): '''add anotation text s to to Axes3d ax''' tag = Annotation3D(s, *args, **kwargs) ax.add_artist(tag) # O processo é semelhante aos anteriores, com a definição de 3 dimensões via o parâmetro `n_components` e alguns ajustes na contrução do gráfico: # In[45]: from sklearn.manifold import MDS import mpl_toolkits.mplot3d import random mds = MDS(n_components=3, dissimilarity='precomputed',random_state=1234, n_init=100, max_iter=1000) results = mds.fit(distmat.values) parties = distmat.columns coords = results.embedding_ sns.set() sns.set_style("ticks") fig = plt.figure(figsize=(8,8)) ax = fig.add_subplot(111, projection='3d') fig.suptitle('Portuguese Parliament Voting Records Analysis, 14th Legislature', fontsize=14) ax.set_title('MDS with Spectrum Scaling clusters (3D)') for label, x, y, z in zip(parties, coords[:, 0], coords[:, 1], coords[:, 2]): #print(label,pmds_colors[label]) ax.scatter(x, y, z, c="C"+str(sc_dict[label]),s=250) annotate3D(ax, s=str(label), xyz=[x,y,z], fontsize=10, xytext=(-3,3), textcoords='offset points', ha='right',va='bottom') plt.show() # ## Conclusão # # # Existe uma proliferação de recursos e questionários assentes em "quadrantes", "compassos" e outros termos que pretendem posicionar o utilizador em termos ideológicos, com metodologias diferentes (e com resultados diferentes) e de diversos graus de complexidade e sofisticação _(Wall, Krouwel, and Kleinnijenhuis 2012)_. Este trabalho não pretende ser uma "revelação" ou emprestar uma base "científica" aos posicionamentos de cada um através de uma "matematização" do conhecimento enquanto valorizador qualitativo, lembrando Ortega y Gasset e a forma crítica como referia a tendência de todas disciplinas se quererem ao exemplo da física _(Ortega y Gasset 1995)_. # # Nesta conclusão iremos consciente e explicitamente abandonar pretensões de completa neutralidade e distanciamento, sem que isso signifique a execução de um determinado programa político pessoal: trata-se tão só do resultado natural da análise dos dados sob um prisma mais qualitativo. # # Os resultados obtidos são, de certa forma, razoavelmente modestos em termos de novidades: # # * Existe uma primeira divisão entre os partidos que se enquadra nos conceitos de Esquerda e Direita. # * Nesta divisão o posicionamento do Partido Socialista será, talvez, um dos pontos mais sensíveis. # * O PAN aparece agrupado à esquerda (não obstante ser o primeiro a ser diferenciado dos restantes). # * À direita o agrupamento entre CDP-PP, IL e CHEGA é bastante próximo, sendo que o método de agrupamento utilizado diferencia a IL primeiro que os restantes (mas outros métodos têm, empiricamente falando, outros resultados). # * À esquerda a CDU (PCP/PEV) e o BE+Livre/JKM são diferenciados. # * As votações da deputada não-inscrita Cristina Rodrigues são aproximadas,mas não idênticas, às do PAN. # # Há que ter em conta o facto do Partido Socialista ser o partido de sustentação do Governo, algo que determina de várias formas a forma como as votações ocorrem; de que forma é que este facto influencia o seu posicionamento não é possível quantificar mas, intutitivamente, não será irrelevante. Outro factor (embora não abordado de forma directa neste trabalho) é a aparente maior produção em termos de propostas dos partidos à esquerda, o que leva a m maior número de rejeições do partido que sustenta o Governo e, logo, um maior distânciamento. # # **À direita**, e pondo de lado a já referida inclusão do PS que pode ter as razões apontadas como factor relevante, o PSD aparece com um posicionamento pouco surpreendente no sentido em que se enquadra no "centro-direita" que se esperaria, sem prejuízo do posicionamento mais amplo que em diferentes fases possa considerar adequadas _(Lourinho 2020)_. # # A distância entre CDS-PP, CHEGA e IL é bastante pequena (note-se que embora os partidos à esquerda sejam genericamente mais próximos exibem distâncias entre si _maiores_ do que estes três); este resultado será talvez mais interessante para a IL no sentido em ser, como referido anteriormente, quem mais frequentemente recusa ser classificado em termos de "esquerda e direita" _(“Iniciativa Liberal Descontente Com Lugar Atribuído a Deputado No Parlamento - TSF” 2020)_: em termos de votações esta pretensão não tem aparente materialização quantitativa, sempre dentro dos contragimentos explicitamente identificados. # # **À esquerda**, o resultado não nos parece ser grandemente surpreendente: apesar das diferenças em várias votações o PCP e o PEV (este mais próximo dos restantes partidos agrupados à esquerda) aparecem como um bloco diferenciável do BE e Livre/JKM. No caso do PCP e PEV a proximidade não é estranha considerando serem partidos pertencentes a uma mesma coligação eleitoral _(Lusa 2020b)_, mas também no caso do BE e Livre/JKM há que considerar a proximidade histórica existente _(ZAP 2019)_. # # A posição do PAN será, aqui, provavelmente a mais interessamente no sentido em que este se classifica como "nem de esquerda ou direita" _(“Expresso | A História Do Partido Que Diz Que Não É de Direita Nem de Esquerda: O PAN” 2020)_: aparece "à esquerda" (estamos sempre aqui a mapear os resultados com uma realidade pre-existente) do Partido Socialista, e embora sendo por pouco não deixa de ser relevante. # # Outras leituras podem ser feitas (e dadas as várias dimensões dos dados, leituras aparentemente opostas podem coexistir) tanto com os resultados apresentados como através da alterção da forma de tratamento dos dados. # # Em termos de _Data Science_, este trabalho poderia (e poderá) ter uma continuidade, nomeadamente dentro do que são as possibilidade do _Watson Studio_, componente da plataforma "IBM Cloud Pak for Data" _(“IBM Cloud Pak for Data” 2020)_: execução diária da extração de dados, validação dos mesmos, actualização dos resultados de forma automática e também a possibilidade de criação de modelos preditivos automaticamente actualizados e testados através de _Watson Machine Learning_ _(“Watson Machine Learning - Overview | IBM” 2020)_; estas possibilidades da plataforma IBM Watson são transversais a qualquer projecto de _Data Science_, sendo o presente trabalho apenas um exemplo que utiliza uma pequena parte das funcionalidades disponibilizadas. Em concreto, e em diferentes fases de execução, podemos referir as seguintes linhas de trabalho: # # * Actualização semana, automática, dos dados, com visualização da evolução das distâncias ao longo da XV legislatura (utilização da funcionalidade de criação de _jobs_ em IBM Watson Studio) # * Comparação dos resultados obtidos com os de outros estudos que utilizam critérios assentes na classificação de posições de forma qualitativa. # * Incorporação de mais dados: quem produz mais propostas, quem vota mais a favor ou contra as propostas de determinado partido. # * Classificação automática das propostas em termos de conceitos chaves, através de análise textual, para possível criação de diferentes áreas temáticas ("Política externa", "Trabalho", etc.) # * Cruzamento da informação dos deputados para análise de escolaridade, profissão e outros dados. # * Quantidade de propostas, incluíndo propostas de alteração, de cada partido. # # Para finalizar, e considerando sempre a necessidade de analise crítica dos dados apresentados e a sua correcção, recordamos quando um (excelente) professor (de uma matéria bastante relevante para a análise efectuada) utilizou, numa das suas aulas, uma citação que se mantém fundamental: _"Se não temo o erro, é porque estou sempre disposto a corrigi-lo"_ _(“Frase do matemático português Bento de Jesus Caraça”, SPM 2020)_. Também para este trabalho estamos necessariamente abertos a correcções e sugestões fundamentadas. # ## Bibliografia # # * “2.2. Manifold Learning — Scikit-Learn 0.23.2 Documentation.” 2020. Accessed September 2. https://scikit-learn.org/stable/modules/manifold.html#multidimensional-scaling. # * Almeida, Joana. 2019. “CDS explica contestação ao lugar do Chega no Parlamento: ‘André Ventura pode cansar-se e dizer “olhe, basta, chega.”’” O Jornal Económico. October 23. https://jornaleconomico.sapo.pt/noticias/cds-explica-contestacao-ao-lugar-do-chega-no-parlamento-andre-ventura-pode-cansar-se-e-dizer-olhe-basta-chega-504766. # * Carvalho, Alexandre Xavier Ywata, Pedro Henrique Melo Albuquerque, Gilberto Rezende de Almeida Junior, and Rafael Dantas Guimaraes. 2009. “Spatial Hierarchical Clustering.” Revista Brasileira de Biometria 27 (3): 411–42. # * “Cleaning Big Data: Most Time-Consuming, Least Enjoyable Data Science Task, Survey Says.” 2020. Accessed August 20. https://www.forbes.com/sites/gilpress/2016/03/23/data-preparation-most-time-consuming-least-enjoyable-data-science-task-survey-says/#8e17b266f637. # * “Dados Abertos.” 2020. Accessed August 17. https://www.parlamento.pt/Cidadania/Paginas/DadosAbertos.aspx. # * “DBSCAN: Macroscopic Investigation Python.” 2018. DataCamp Community. August 3. https://www.datacamp.com/community/tutorials/dbscan-macroscopic-investigation-python. # * Dodds, Leigh. 2020. “Do Data Scientists Spend 80% of Their Time Cleaning Data? Turns Out, No?” Lost Boy. January 31. https://blog.ldodds.com/2020/01/31/do-data-scientists-spend-80-of-their-time-cleaning-data-turns-out-no/. # * “Estão Distribuídas (para Já) as Cadeiras No Parlamento - DN.” 2020. Accessed August 25. https://www.dn.pt/poder/nova-legislatura-estao-distribuidas-para-ja-as-cadeiras-no-parlamento-11412255.html. # * “Expresso | A História Do Partido Que Diz Que Não É de Direita Nem de Esquerda: O PAN.” 2020. Accessed September 20. https://expresso.pt/europeias-2019/2019-05-27-A-historia-do-partido-que-diz-que-nao-e-de-direita-nem-de-esquerda-o-PAN. # * Figueiredo Filho, Dalson Britto, Enivaldo Carvalho da Rocha, José Alexandre da Silva Júnior, Ranulfo Paranhos, Mariana Batista da Silva, and Bárbara Sofia Félix Duarte. 2014. “Cluster Analysis for Political Scientists.” Applied Mathematics 2014. # * “Frase do matemático português Bento de Jesus Caraça.” 2020. Sociedade Portuguesa de Matemática. Accessed September 20. https://www.spm.pt/news/4730. # * “Graphical Representation of Proximity Measures for Multidimensional Data « The Mathematica Journal.” 2020. Accessed September 2. https://www.mathematica-journal.com/2015/09/30/graphical-representation-of-proximity-measures-for-multidimensional-data/. # * Hix, Simon, Abdul Noury, and Gérard Roland. 2006. “Dimensions of Politics in the European Parliament.” American Journal of Political Science 50 (2): 494–511. https://www.jstor.org/stable/3694286. # * “IBM Cloud Pak for Data.” 2020. Accessed September 20. https://dataplatform.cloud.ibm.com/home2?context=cpdaas. # * “Iniciativa Liberal Descontente Com Lugar Atribuído a Deputado No Parlamento - TSF.” 2020. Accessed August 17. https://www.tsf.pt/portugal/politica/iniciativa-liberal-descontente-com-lugar-atribuido-a-deputado-no-parlamento-11412664.html. # * Jackson, Robert J. 1968. Rebels and Whips: An Analysis of Dissension, Discipline and Cohesion in British Political Parties. Macmillan. # * “Joacine Katar Moreira demite-se do Livre - DN.” 2020. Accessed August 20. https://www.dn.pt/poder/joacine-katar-moreira-demite-se-do-livre-11783401.html. # * Krilavičius, Tomas, and Antanas Žilinskas. 2008. “On Structural Analysis of Parliamentarian Voting Data.” Informatica 19 (3): 377–90. # * Lourenço, Eunice. 2020. “Guerra de lugares.” PÚBLICO. Accessed August 17. https://www.publico.pt/1999/10/26/jornal/guerra-de-lugares-125525. # * Lourinho, José Carlos. 2020. “Rui Rio: ‘Tanto sou do centro-direita como do centro-esquerda.’” O Jornal Económico. January 14. https://jornaleconomico.sapo.pt/noticias/rui-rio-tanto-sou-do-centro-direita-como-do-centro-esquerda-535068. # * Lusa. 2020a. “Quadro de tempos e votações da AR já distingue as duas deputadas não-inscritas.” PÚBLICO. Accessed August 23. https://www.publico.pt/2020/06/26/politica/noticia/quadro-tempos-votacoes-ar-ja-distingue-duas-deputadas-naoinscritas-1922017. # * ———. 2020b. “PCP e PEV confirmam convergência e priorizam creches, salários, ambiente e ferrovia.” PÚBLICO. Accessed September 20. https://www.publico.pt/2019/10/24/politica/noticia/pcp-pev-confirmam-convergencia-priorizam-creches-salarios-ambiente-ferrovia-1891225. # * Morse, J. N. 1980. “Reducing the Size of the Nondominated Set: Pruning by Clustering.” Computers & Operations Research 7 (1): 55–66. doi:10.1016/0305-0548(80)90014-3. # * “‘N’-Dimensional Euclidean Distance.” 2020. Accessed September 5. https://hlab.stanford.edu/brian/euclidean_distance_in.html. # * Ortega y Gasset, José. 1995. ¿ Qué Es Filosofía?. Espasa Calpe. # * Powers, Stephen M., and Stephanie E. Hampton. 2019. “Open Science, Reproducibility, and Transparency in Ecology.” Ecological Applications 29 (1): e01822. # * “Python - Matplotlib: Annotating a 3D Scatter Plot - Stack Overflow.” 2020. Accessed September 4. https://stackoverflow.com/questions/10374930/matplotlib-annotating-a-3d-scatter-plot. # * Randles, Bernadette M., Irene V. Pasquetto, Milena S. Golshan, and Christine L. Borgman. 2017. “Using the Jupyter Notebook as a Tool for Open Science: An Empirical Study.” In 2017 ACM/IEEE Joint Conference on Digital Libraries (JCDL), 1–2. IEEE. # * Renascença. 2019. “Uma nova porta para o Chega? Perceba porque é tão difícil sentar os deputados - Renascença.” Rádio Renascença. October 22. https://rr.sapo.pt/2019/10/22/legislativas-2019/uma-nova-porta-para-o-chega-perceba-porque-e-tao-dificil-sentar-os-deputados/noticia/169040/. # * Sapage, Sónia. 2020. “‘Hemiciclo.pt’ é uma nova versão digital do Parlamento.” PÚBLICO. Accessed September 21. https://www.publico.pt/2017/09/04/politica/noticia/hemiciclo-uma-nova-versao-digital-do-parlamento-1784304. # * SAPO. 2020. “Mais uma saída do PAN. Cristina Rodrigues deixa partido e passa a deputada não inscrita.” SAPO 24. Accessed August 20. https://24.sapo.pt/atualidade/artigos/mais-uma-saida-do-pan-cristina-rodrigues-deixa-partido-e-passa-a-deputada-nao-inscrita. # * “Spectral Clustering. Foundation and Application | by William Fleshman | Towards Data Science.” 2020. Accessed September 1. https://towardsdatascience.com/spectral-clustering-aba2640c0d5b. # * “Votação dos cinco projetos da eutanásia será nominal - JN.” 2020. Accessed August 20. https://www.jn.pt/nacional/votacao-dos-cinco-projetos-da-eutanasia-sera-nominal--11838428.html. # * Wall, Matthew, André Krouwel, and Jan Kleinnijenhuis. 2012. “Political Matchmakers: How Do the Decision Rules Employed by Vote Advice Application Sites Influence Their Advice?” In Elecdem Conference, Florence, 28–30. # * “Watson Machine Learning - Overview | IBM.” 2020. Accessed September 20. https://www.ibm.com/cloud/machine-learning. # * Wofford, Morgan F., Bernadette M. Boscoe, Christine L. Borgman, Irene V. Pasquetto, and Milena S. Golshan. 2019. “Jupyter Notebooks as Discovery Mechanisms for Open Science: Citation Practices in the Astronomy Community.” Computing in Science & Engineering 22 (1): 5–15. # * ZAP. 2019. “O Livre procurou o Bloco. E é mais o que os une do que o que os separa.” ZAP. October 24. https://zap.aeiou.pt/livre-procurou-bloco-287820. # ---- # ## Apêndice # ### How to cite this notebook # # *MUÑOZ, Frederico Serrano - Dados abertos e Ciência de Dados: Análise da Actividade Parlamentar da XIV Legislatura do Parlamento Português [Online] WWW: : https://dataplatform.cloud.ibm.com/analytics/notebooks/v2/0b23f01c-e55a-4fe4-a2ca-57f2e478bf3e/view?access_token=4cc13fba3f3967530650b11a2956ed27c5cec9042113237f97cb0e4c58d3905f* # # --- # # **BibTeX** # ```latex # @report{munozDadosAbertosCiencia2020, # title = {Dados abertos e Ciência de Dados: Análise da Actividade Parlamentar da XIV Legislatura do Parlamento Português}, # author = {Muñoz, Frederico Serrano}, # date = {2020}, # url = {https://dataplatform.cloud.ibm.com/analytics/notebooks/v2/0b23f01c-e55a-4fe4-a2ca-57f2e478bf3e/view?access_token=4cc13fba3f3967530650b11a2956ed27c5cec9042113237f97cb0e4c58d3905f}, # abstract = {The application of Data Science methods and analysis to voting records is a well-established practice; in this paper we describe the process of obtaining. cleaning, exploring and choosing adequate analytic methods for the purpose of determining the distance between parties in the Portuguese Parliament. Through the use of institutional Open Data resources we discuss the different approaches possible in terms of clustering and dimension reduction, using DBSCAN, Spectrum Clustering and Multidimensional Scaling to augment the information produced by distance and affinity matrices. A detailed explanation of how an euclidean distance matrix is obtained and how clustering and MDS work using test datasets is also included}, # langid = {por, en}, # type = {Jupyter Notebook} # } # ``` # ### Execução do bloco de notas # Este bloco de notas pode ser executado na plataforma IBM Data Platform através da criação de um novo *Notebook* (https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/creating-notebooks.html?audience=wdp) e importação do URL (https://github.com/fsmunoz/pt-act-parlamentar/raw/master/Actividade%20Parlamentar%20da%20XIV%20Legislatura.ipynb). # # É também possível utilizar o ambiente de execução _Binder_; a forma mais directa é através da integração disponibilizada no visualizador do projecto Jupyter e que para este projecto tem a seguinte ligação: # # https://nbviewer.jupyter.org/github/fsmunoz/pt-act-parlamentar/raw/master/Actividade%20Parlamentar%20da%20XIV%20Legislatura.ipynb # # No canto superior direito o ícone do Binder (três círculos) permite iniciar um ambiente dedicado onde a interacção passa a ser possível. # ![binder.png](attachment:binder.png) # ### Iniciativas e votações # Há uma **diferença entre a primeira votação e a primeira iniciativa** (e, em termos gerais, entre iniciativas e votações): existem iniciativas que por razões várias não têm votações associadas. Assim, note-se os detalhes da primeira iniciativa e como o tema é diferente do apresentados para a primeira iniciativa com votação mostrada anteriormente: # In[46]: for c in ini_tree.find("pt_gov_ar_objectos_iniciativas_DetalhePesquisaIniciativasOut"): print("{0:15}: {1}".format(c.tag,c.text)) # Esta iniciativa não tem votações associadas pelo que não irá ser incluída na tabela que lista as votações, mas deveria ser incluída numa tabela com objectivos diferentes do presente trabalho, focada por exemplo nas iniciativas parlamentares; aproveitemo-la, porém, para demonstrar como extrair a informação da autoria da iniciativa: os campos onde aparece `None`são quase sempre campos que contém informação com vários níveis e que é preciso extrair de forma especial. Para a autoria existe a secção `iniAutorGruposParlamentares` que contém um objecto `pt_gov_ar_objectos_AutoresGruposParlamentaresOut` com o nome do grupo parlamentar como conteúdo de `GP`: # In[47]: for c in ini_tree.find("pt_gov_ar_objectos_iniciativas_DetalhePesquisaIniciativasOut"): if c.tag == "iniAutorGruposParlamentares": print("{0:30}: {1}".format(c.tag,c.find('pt_gov_ar_objectos_AutoresGruposParlamentaresOut/GP').text)) else: print("{0:30}: {1}".format(c.tag,c.text)) # A **autoria das iniciativas** onde se integram as votações pode ser registada, mas é importante ter claro que esta autoria **é independente da autoria da matéria em votação**: uma iniciativa de um partido pode integrar votações propostas por outros. Esta situação não impacta a possiblidade de análise a que nos propomos mas abre áreas de exploração adicional para o futuro. Tendo em conta o objectivo deste trabalho optou-se por utilizar apenas a informação das votações de forma a tornar o código mais simples e focado, deixando para um segundo trabalho (em curso) uma contextualização mais ampla. # ### Processamento da descrição de votos # **Começemos pela primeira** usando a votação `86004` que representa o primeira caso ao ser uma votação nominal, uma das cinco relativas aos projectos relativos à eutanásia : # In[48]: from bs4 import BeautifulSoup def parse_voting1(v_str): """Parses the voting details in a string and returns a dict. Keyword arguments: v_str: a string with the description of the voting behaviour. """ ## Split by the HTML line break and put it in a dict d = dict(x.split(':') for x in v_str.split('
')) ## Remove the HTML tags for k, v in d.items(): ctext = BeautifulSoup(v, "lxml") d[k] = ctext.get_text().strip().split(",") ## Invert the dict to get a 1-to-1 mapping ## and trim it votes = {} for k, v in d.items(): for p in v: ## Sometimes there are empty entries ("boo, , bar, baz"), bypass them if p != ' ': votes[p.strip()] = k return votes for v in ini_tree.find(".//pt_gov_ar_objectos_VotacaoOut/[id='86004']"): if v.tag == "detalhe": vote_86004 = parse_voting1(v.text) print("\t{0:15}: {1}".format(v.tag,parse_voting1(v.text))) else: print("\t{0:15}: {1}".format(v.tag,v.text)) # A divisão dos votos por partido para esta votação em específico é a seguinte para esta votação: # In[49]: import pandas as pd import re ## Initialise an empty dict vote_e = {} ## Iteract through the existing dict with the nominal voting results for k, v in vote_86004.items(): print(k,v) ## Erase the name of the MP and keep the party only nk = re.sub(r".*\((.+?)\).*", r"\1", k) ## If it's the first entry for a key, create it if nk not in vote_e: vote_e[nk] = [0,0,0] ## Add to a specific index in a list if v == "A Favor": vote_e[nk][0] += 1 elif v == "Abstenção": vote_e[nk][1] += 1 elif v == "Contra": vote_e[nk][2] += 1 ## Convert dict to pandas dataframe, add a total columns and display it vote_e_df = pd.DataFrame.from_dict(vote_e, orient='index', columns=['A Favor', 'Abstenção', 'Contra']) vote_e_df['Total'] = vote_e_df.sum(axis=1) vote_e_df # Um gráfico de barras torna claro que a divisão ocorreu apenas nos dois maiores grupos parlamentares. # In[50]: ## Plot a stacked bar plot using the dataframe import seaborn as sns legend_colors = ["#79DE79", "#FCFC99","#FB6962"] sns.set(style="whitegrid") ## Plot sorted by total number of votes vote_e_df.sort_values('Total', ascending=False).drop('Total',axis=1).plot(kind="bar", stacked=True, color=sns.color_palette(legend_colors)) # O segundo caso diz respeito a **votações onde a posição partidária é indicada e apenas as excepções incluídas**; nestes casos existem uma determinada posição maioritária à qual se registam os desvios. Não existe forma, na verdade, de determinar se estamos perante uma posição mais ou menos "oficial" que a determinada pelo caso anterior, até porque isso implicaria saber se foi relaxada ou não a disciplina de voto, algo que não é possível obter dos dados em si. # # Também estes casos são excepcionais e quando existem são quase sempre com um número reduzido de excepções; atentemos à votação `87724`, a primeira que apresenta uma situação deste tipo: # In[51]: for v in ini_tree.find(".//pt_gov_ar_objectos_VotacaoOut/[id='87724']"): if v.tag == "detalhe": print("\t{0:15}: {1}".format(v.tag,parse_voting(v.text))) else: print("\t{0:15}: {1}".format(v.tag,v.text)) # Para além das deputadas não-inscritas temos a indicação do sentido de votos dos partidos e também a de dois deputados do PS que votaram de forma diferente do seu grupo parlamentar. # # Como considerar esta situação? Em coerência com a opção anterior consideramos a posição indicada pelo partido como sendo aquela que determina o seu posicionamento, não considerando as excepções. A justificação é semelhante à anterior embora neste caso tenha menos impacto: acontece sobretudo nos grupos parlamentares de maior dimensão (PS e PSD) e, como referido, de forma muito esporádica e com um reduzido número de deputados com votações desviantes. # # A votação 87946 apresenta-nos o terceiro caso, uma variante adicional quando o número de votos em sentido divergente é maior: # In[52]: for v in ini_tree.find(".//pt_gov_ar_objectos_VotacaoOut/[id='87946']"): if v.tag == "detalhe": print("\t{0:15}: {1}".format(v.tag,parse_voting(v.text))) else: print("\t{0:15}: {1}".format(v.tag,v.text)) # Aqui temos um indicador numérico como prefixo e que indica quantidade de deputados de um determinado grupo parlamentar (neste caso do PS) que votaram de forma divergente. Também aqui usamos o mesmo princípio e retemos a votação indicada como sendo a do partido e por isso descartam-se estas indicações. # ### Informação adicional da iniciativa # Para obter informação adicional das iniciativas onde se incluem as votações podem-se usar várias abordagens, desde o processamento de todas as iniciativas de forma recursiva à procura a partir do identificador da votação; o seguinte bloco exemplifica a segunda aproximação para a primeira votação encontrada: # In[53]: import re import collections root = ini_tree init_list = [] init_dict = collections.OrderedDict() def get_initiative_desc (vid, tree): """ Gets the initiative title from a voting id """ for c in ini_tree.find(".//pt_gov_ar_objectos_VotacaoOut/[id='"+ vid +"']/../../../.."): if c.tag == "iniTitulo": return c.text for c in ini_tree.find(".//pt_gov_ar_objectos_VotacaoOut"): if c.tag == "id": init_dict["iniDesc"] = get_initiative_desc(c.text, ini_tree) init_dict[c.tag] = c.text init_list.append(init_dict) ini_df_extra = pd.DataFrame(init_list) print(ini_df_extra.shape) ini_df_extra # Esta abordagem não é usada para as Iniciativas mas será aplicada às Actividades, essencialmente porque neste último caso há uma relação mais directa entre a matéria a votação e a actividade parlamentar (de tal forma que a votação não inclui uma descrição própria). # ### Processamento adicional dos não-inscritos # # Existem dois casos que precisam ser analisados de forma individual. # # #### Joacine Katar Moreira e o Livre # # Começando por **Joacine Katar Moreira**, a deputada eleita pelo Livre desvinculou-se pouco tempo após a eleição *(“Joacine Katar Moreira demite-se do Livre - DN” 2020)* e sendo deputada única a situação criada em termos de registo de votação é de complementaridade: ou há registo de votação do Livre ou há registo de votação da deputada Joacine Katar Moreira. # In[54]: print("Livre: ", ini_df["L"].dropna().shape[0]) print("JKM: ", ini_df["Joacine Katar Moreira (Ninsc)"].dropna().shape[0]) ini_df[["L","Joacine Katar Moreira (Ninsc)"]].head() # #### Cristina Rodrigues e o PAN # # Um caso diferente é o da deputada Cristina Rodrigues, eleita por Setúbal pelo PAN e que se desvinculou já perto do final da legislatura *(SAPO 2020)*: não só porque o número de votações efectadas enquanto deputada não-inscrita é bastante menor, como o facto de não ser deputada única faz com que existam votações onde a deputada e o seu anterior grupo parlamentar ambos votam, e como se pode verificar na tabela seguinte , várias vezes de forma diferente: # In[55]: print("PAN: ", ini_df["PAN"].dropna().shape[0]) print("CR: ", ini_df["Cristina Rodrigues (Ninsc)"].dropna().shape[0]) ini_df[["PAN","Cristina Rodrigues (Ninsc)"]].dropna()[ini_df["PAN"] != ini_df["Cristina Rodrigues (Ninsc)"]] # ### Conversão do _dataframe_ em CSV # # Existem várias formas de se obter o resultado deste processamento; a que apresentamos utiliza `PixieDust` _(“Welcome to PixieDust — PixieDust Documentation” 2020)_, uma biblioteca que permite trabalhar directamente com os dados sem se ter neessidade de modificar o código, incluíndo visualizações, filtragem e e também descarregar em vários formatos. # In[56]: from pixiedust.display import * display(votes) # ### Distâncias e matrizes # # A matriz de distância é feita através da utilização da função `pdist`; como já mencionado a distância euclidiana entre dois pontos $p$ e $q$ é $ d\left( p,q\right) = \sqrt {\sum _{i=1}^{n} \left( q_{i}-p_{i}\right)^2 }$, para $n$ dimensões . De forma a facilitar a intuição de como esta distância é aplicada ao registo de votações vamos, passo a passo, analisar um caso simplificado (o que nos serve também de forma de validação indirecta do processo). # # Vamos criar um cenário com três partidos, com nomes que reflectem o seu perfil de votação: # # * O Partido Favorável vota a favor. # * O Partido Abstencionista abstem-se (pelo menos até certo ponto). # * O Partido do Contra vota contra. # # ### Uma votação, $ n = 1 $ # # Começemos por uma única votação, onde cada um dos partidos segue a sua linha programática: # In[57]: v1=[[1],[0],[-1]] v1_df = pd.DataFrame(v1, columns=["v1"], index=["F","A", "C"]) v1_df # Com uma única votação o número de dimensões é de $n = 1$ e é bastante intuitivo que a distância entre eles é a _norma_:$ d\left( x,y\right) = | x - y |$; entre 1 e 0 a distância é 1, entre 1 e -1 a distância é 2, etc. Para $ n = 1 $ a distância euclidiana é equivalente à norma, pois $ \sqrt{\left( q - p\right)^2} = | q - p | $, como pode ser observado quando medidas a distância entre eles: # In[58]: import math ## We could also sue the array directly, e.g. ## print("Distance from" , a[0][0] , "and" , a[1][0],"=",math.sqrt((a[0][0]-a[1][0]) ** 2)) print("Distance from", v1_df.loc["F"].name, "and", v1_df.loc["A"].name, "=", math.sqrt((v1_df.loc["F"]["v1"]-v1_df.loc["A"]["v1"]) ** 2)) print("Distance from", v1_df.loc["F"].name, "and", v1_df.loc["C"].name, "=", math.sqrt((v1_df.loc["F"]["v1"]-v1_df.loc["C"]["v1"]) ** 2)) print("Distance from", v1_df.loc["A"].name, "and", v1_df.loc["C"].name, "=", math.sqrt((v1_df.loc["A"]["v1"]-v1_df.loc["C"]["v1"]) ** 2)) # Temos a distância entre os três possíveis pares: a distância entre _F_ e _A_ é idêntica à distância entre _A_ e _F_. Isto é importante porque ajuda a explicar a diferença entre a forma "condensada" e forma "quadrada". A função `pdist` retorna a distância entre os vários pares na forma _condensada_: # In[59]: pdist(v1_df) # Estes são os mesmos valores que obtivemos de forma manual, e é isso que a função faz: calcula uma determinada distância (euclidiana, neste caso e por omissão) entre todos os pares possíveis, sem que seja necessário especificarmos todas as combinações possíveis. # # Este formato condensado não é o que permite uma leitura mais imediata, e para tal existe a função `squareform` que apresenta os mesmos resultados mas numa matriz simétrica: # In[60]: squareform(pdist(v1_df)) # Em formato tabular torna-se ainda mais claro... e isto é exactmante a matriz de distância usada para o mapa térmico # In[61]: v1_distmat=pd.DataFrame(squareform(pdist(v1)), columns=v1_df.index, index=v1_df.index) v1_distmat # Geometricamente temos pontos numa recta: uma análise das distâncias entre os três partidos com base no histórico de votação seria simples de visualizar com base na posição desses pontos: # In[62]: fig, ax = plt.subplots() ax.axhline(y=0,c="gold",zorder=-1) for x in v1_df["v1"]: ax.scatter(x,y=0) plt.show() # Com base na matriz de distância contruimos o mapa térmico de forma muito simples: # In[63]: plt.figure(figsize=(4,4)) sns.heatmap( v1_distmat, cmap=sns.color_palette("Reds_r"), linewidth=1, annot = True, square =True, ) plt.show() # ...e o agrupamento com base nessa matriz: # In[64]: v1_distmat_link = hc.linkage(pdist(v1_df)) sns.clustermap( v1_distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, row_linkage=v1_distmat_link, col_linkage=v1_distmat_link, figsize=(4,4) ) # ### Duas votações, $ n = 2 $ # # Usando a mesma abordagem (agora sem necessidade de explicações adicionais) adicionamos mais uma votação, sempre em linha com o perfil fixo de votos de cada um: cada partido passa a ter dois votos, logo temos duas dimensões: # In[65]: v2=[[1,1],[0,0],[-1,-1]] v2_df = pd.DataFrame(v2, columns=["v1","v2"], index=["F","A", "C"]) v2_df # A distância euclidiana é agora feita de forma mais genérica: a raíz quadrada da soma do quadrado das diferenças: para a primeira diferença isto significa, passo a passo e para $q=F$ e $p=A$: # # $ d\left(q,p\right) = \sqrt {\sum _{i=1}^{n} \left( q_{i}-p_{i}\right)^2 } = \sqrt{\left( q_{1}-p_{1}\right)^2 + \left( q_{2}-p_{2}\right)^2 } = \sqrt{\left( 1 - 0\right)^2 + \left( 1 - 0\right)^2} = \sqrt{ 1^2 + 1^2 } = \sqrt{1+1} = \sqrt{2} \approx 1.4142135623730951 $ # # E de facto: # In[66]: print(math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(v2_df.loc["F"],v2_df.loc["A"])))) print(math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(v2_df.loc["F"],v2_df.loc["C"])))) print(math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(v2_df.loc["A"],v2_df.loc["C"])))) # Tal como antes é idêntico ao resultado de `pdist`, tanto na sua forma condensada como quadrada: # In[67]: print("pdist:\n", pdist(v2),"\n") print("squareform:\n",squareform(pdist(v2))) v2_distmat=pd.DataFrame(squareform(pdist(v2)), columns=v2_df.index, index=v2_df.index) v2_distmat # Com duas votações temos $ n = 2 $ e conseguimos ver os pontos num espaço cartesiano em $ \mathbb{R}^2 $ (um plano). # In[68]: fig, ax = plt.subplots() for x,y in zip(v2_df["v1"], v2_df["v2"]): ax.scatter(x,y) fig.show() # A matriz de distância seria neste caso, como no anterior, desnecessária (em $ \mathbb{R}^2 $ as distâncias entre os partidos são óbvias por facilmente visualizáveis); a forma de a construir é idêntica: # In[69]: plt.figure(figsize=(4,4)) sns.heatmap( v2_distmat, cmap=sns.color_palette("Reds_r"), linewidth=1, annot = True, square =True, ) plt.show() # ... e o mapa térmico correspondente: # In[70]: v2_distmat_link = hc.linkage(pdist(v2_df)) sns.clustermap( v2_distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, row_linkage=v2_distmat_link, col_linkage=v2_distmat_link, figsize=(4,4) ) # ### Três votações, $ n = 3 $ # # Este é o último caso onde a visualização pode ser feita de forma directa. Consideremos: # In[71]: v3=[[1,1,1],[0,0,0],[-1,-1,-1]] v3_df = pd.DataFrame(v3, columns=["v1","v2", "v3"], index=["F","A", "C"]) v3_df # A distância é calculada da mesma forma: # In[72]: print(math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(v3_df.loc["F"],v3_df.loc["A"])))) print(math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(v3_df.loc["F"],v3_df.loc["C"])))) print(math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(v3_df.loc["A"],v3_df.loc["C"])))) # ... bem como a matriz de distância: # In[73]: print("pdist:\n", pdist(v3),"\n") print("squareform:\n",squareform(pdist(v3))) v3_distmat=pd.DataFrame(squareform(pdist(v3)), columns=v3_df.index, index=v3_df.index) v3_distmat # Estamos agora em $ \mathbb{R}^3 $, e para visualizar podemos usar uma projecção tridimensional: # In[74]: fig, ax = plt.subplots() ax = fig.add_subplot(111,projection='3d') for x,y,z in zip(v3_df["v1"], v3_df["v2"], v3_df["v3"]): ax.scatter(x,y,z) fig.show() # In[ ]: # In[75]: plt.figure(figsize=(4,4)) sns.heatmap( v3_distmat, cmap=sns.color_palette("Reds_r"), linewidth=1, annot = True, square =True, ) plt.show() # In[ ]: # In[76]: v3_distmat_link = hc.linkage(pdist(v3_df)) sns.clustermap( v3_distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, row_linkage=v3_distmat_link, col_linkage=v3_distmat_link, figsize=(4,4) ) # ### Mais de três votações, $ n > 3 $ # # A partir daqui coloca-se a questão fundamental: a impossibilidade de visualizar de forma directa a distância para além das três dimensões. A distância existe e segue exactamente os mesmo passos, simplesmente não é passível de visualização, razão pela qual é necessário (agora sim) depender de formas que "reduzam" as dimensões e as tornem visualizáveis. # # Até agora os agrupamentos têm sido sempre iguais pois a distância é sempre linear; vamos neste último caso assumir que o Partido Abstencionista teve uma mudança de posição e passou a votar por vezes a favor e contra, embora mais contra que a favor: # In[77]: v4=[[1,1,1,1,1,1,1,1,1,1],[0,0,0,1,0,-1,0,-1,-1,-1],[-1,-1,-1,-1,-1,-1,-1,-1,-1,-1]] v4_df = pd.DataFrame(v4, columns=["v1","v2", "v3","v4","v5","v6","v7","v8","v9","v10"], index=["F","A", "C"]) v4_df # A distância calculdada "manualmente": # In[78]: print(math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(v4_df.loc["F"],v4_df.loc["A"])))) print(math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(v4_df.loc["F"],v4_df.loc["C"])))) print(math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(v4_df.loc["A"],v4_df.loc["C"])))) # ... e o cálculo via `pdist` e a matriz de distância: # In[79]: print("pdist:\n", pdist(v4),"\n") print("squareform:\n",squareform(pdist(v4))) v4_distmat=pd.DataFrame(squareform(pdist(v4)), columns=v4_df.index, index=v4_df.index) v4_distmat # O mapa térmico: # In[80]: plt.figure(figsize=(4,4)) sns.heatmap( v4_distmat, cmap=sns.color_palette("Reds_r"), linewidth=1, annot = True, square =True, ) plt.show() # ... e o dendograma, já apresentando um agrupamento com base na maior aproximação com base no registo de votação: # In[81]: v4_distmat_link = hc.linkage(pdist(v4_df), method="ward") sns.clustermap( v4_distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, row_linkage=v4_distmat_link, col_linkage=v4_distmat_link, figsize=(4,4) ) # ### Métodos de _clustering_ # Da mesma forma que podemos especificar diferentes métricas de distância, podemos também especificar diferentes métodos de agregação, que com base na distância entre os vários pontos vão determinar de que forma podem ser agrupados: de que forma se determina a ligação entre eles. # # O valor por omissão usado pela função `linkage` o da ligação `simple`, determinada pela distância mínima entre os pontos mais próximos $ \min \,\{\,d(a,b):a\in A,\,b\in B\,\} $; uma alternativa bastante comum e que determina os _cluster_ com base na distância média é o `average` (também conhecido por UPGMA), e outra é o método de Ward [14WardMethod]. # # Podemos ver a diferença entre os três com o seguinte exemplo; adicionamos um partido ("B") normalmente se abstem mas vota duas vezes de forma diferente, ambas em sentido oposto ao partido "A". # In[82]: v5=[[1,1,1,1,1,1,1,1,1,1],[0,0,0,1,0,-1,0,-1,-1,-1],[0,0,0,-1,0,0,0,0,0,1],[-1,-1,-1,-1,-1,-1,-1,-1,-1,-1]] v5_df = pd.DataFrame(v5, columns=["v1","v2", "v3","v4","v5","v6","v7","v8","v9","v10"], index=["F","A","B" ,"C"]) v5_distmat=pd.DataFrame(squareform(pdist(v5)), columns=v5_df.index, index=v5_df.index) v5_distmat # Usando o método simples obtemos o seguinte dendograma: # In[83]: v5_distmat_link = hc.linkage(pdist(v5_df)) sns.clustermap( v5_distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, row_linkage=v5_distmat_link, col_linkage=v5_distmat_link, figsize=(4,4) ) # Usando UPGMA não há alterações (neste caso muito simples, porque o mais provável é existirem quando aplicadas a dados reais com maior número de observações e dimensões); os agrupamentos tendem a ser feitos de forma recursiva, por separação de elementos individuais até à identificação de um par final: # In[84]: v5_distmat_link = hc.linkage(pdist(v5_df), method="average") sns.clustermap( v5_distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, row_linkage=v5_distmat_link, col_linkage=v5_distmat_link, figsize=(4,4) ) # Com o método de Ward o resultado não é muito diferente mas tende a agrupar em _clusters_ de número e dimensões iguais e é por isso uma escolha muito popular *(Morse 1980)*; neste caso cria dois grupos de dois elementos, agrupando o novo partido pela consideração que faz das destâncias entre todos: # In[85]: v5_distmat_link = hc.linkage(pdist(v5_df), method="ward") sns.clustermap( v5_distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, row_linkage=v5_distmat_link, col_linkage=v5_distmat_link, figsize=(4,4) ) # ### Um exemplo simples de MDS # # Consideremos uma votação como a seguinte, onde os 4 partidos têm um perfil de votação simples que torna simples adivinhar os agrupamentos: entre o que vota sempre a favor e o que vota sempre contra temos partidos que metade das vezes se abstêm e a outra metade votam de forma diferente; a matriz de distância correspondente: # In[86]: v6=[[1,1,1,1,1,1,1,1,1,1],[1,0,1,0,1,0,1,0,1,0],[0,-1,0,-1,0,-1,0,-1,0,-1],[-1,-1,-1,-1,-1,-1,-1,-1,-1,-1]] v6_df = pd.DataFrame(v6, columns=["v1","v2", "v3","v4","v5","v6","v7","v8","v9","v10"], index=["F","A","B" ,"C"]) v6_distmat=pd.DataFrame(squareform(pdist(v6)), columns=v6_df.index, index=v6_df.index) v6_distmat # Com estas distâncias conseguimos, através de MDS, reduzir o número de dimensões de forma a podermos visualizar o resultado: # In[87]: from sklearn.manifold import MDS import random mds = MDS(n_components=2, dissimilarity='precomputed',random_state=2020, n_init=100, max_iter=1000) results = mds.fit(v6_distmat.values) coords = results.embedding_ fig, ax = plt.subplots(figsize=(8,8)) for label, x, y in zip(v6_distmat.columns, coords[:, 0], coords[:, 1]): ax.scatter(x, y, s=250) ax.axis('equal') ax.annotate( label, xy = (x+0.1, y), ) plt.show() # Observe-se que não temos agrupamentos: MDS não é um método de _clustering_ mas sim de redução das dimensões de forma a manter as distâncias relativas entre os vários pontos, o que nos permite identificar visualmente possíveis grupos (neste caso parece claro que $\{C,B\}$ e $\{A,F\}$ são grupos que se destacam pela proximidade edos seus elementos e distância entre si). # ### Spectrum Scaling # # Este método é, como referido, uma das formas de se poder proceder à identificação automática de _clusters_, tendo como base uma matriz de afinidade # In[88]: v6_distmat_mm=((v6_distmat-v6_distmat.min().min())/(v6_distmat.max().max()-v6_distmat.min().min()))*1 pd.DataFrame(1-v6_distmat_mm, v6_distmat.index, v6_distmat.columns) # Um parâmetros fundamental é o número de _clusters_ a identificar; neste caso indicamos dois: # In[89]: v6_sc = SpectralClustering(2, affinity="precomputed",random_state=2020).fit_predict(1 - v6_distmat_mm) v6_sc_dict = dict(zip(v6_distmat,v6_sc)) print(v6_sc_dict) # O MDS correspondente: # In[90]: fig, ax = plt.subplots(figsize=(6,6)) for label, x, y in zip(v6_distmat.columns, coords[:, 0], coords[:, 1]): ax.scatter(x, y, c = "C"+str(v6_sc_dict[label]), s=250) ax.axis('equal') ax.annotate( label,xy = (x+0.3, y),) # ## DBSCAN # # Este método dispensa a inicialização com o número de grupos : # In[91]: from sklearn.cluster import DBSCAN dbscan_labels = DBSCAN(min_samples=2,eps=0.7).fit((1 - v6_distmat_mm)) dbscan_labels.labels_ v6_dbscan_dict = dict(zip(v6_distmat_mm,dbscan_labels.labels_)) v6_dbscan_dict # Neste caso específico o resultado é idêntico: # In[92]: fig, ax = plt.subplots(figsize=(6,6)) for label, x, y in zip(v6_distmat.columns, coords[:, 0], coords[:, 1]): ax.scatter(x, y, c = "C"+str(v6_dbscan_dict[label]), s=250) ax.axis('equal') ax.annotate( label,xy = (x+0.3, y),) # ## Deputada Cristina Rodrigues e o PAN # # # Como referido optámos por não incluir a deputação não-inscrita Cristina Rodrigues na análise principal, essencialmente pela mais reduzida quantidade de votações. Fazemos aqui, contudo, duas análises de detalhes de forma a permitir uma leitura específica desta situação. # # ### Comparação com todas as votações, assumindo votos do PAN # # Neste caso assumimos que os votos da deputada são os mesmos do PAN antes da sua saída (condensamos todos os passos necessário para a construção da matriz de distância e dendograma num único bloco, seguindo-se as restantes análises sem explicações adicionais por redundantes): # In[93]: cr_votes_hm = votes[['BE', 'PCP', 'PEV', 'L/JKM', 'PS', 'PAN','PAN/CR','PSD','IL','CDS-PP', 'CH']] cr_votes_hmn = cr_votes_hm.replace(["A Favor", "Contra", "Abstenção", "Ausência"], [1,-1,0,0]).fillna(0) ## Transpose the dataframe used for the heatmap cr_votes_t = cr_votes_hmn.transpose() ## Determine the Eucledian pairwise distance ## ("euclidean" is actually the default option) cr_pwdist = pdist(cr_votes_t, metric='euclidean') ## Create a square dataframe with the pairwise distances: ## the distance matrix cr_distmat = pd.DataFrame( squareform(cr_pwdist), # pass a symmetric distance matrix columns = cr_votes_t.index, index = cr_votes_t.index ) ## Perform hierarchical linkage on the distance matrix using Ward's method. cr_distmat_link = hc.linkage(cr_pwdist, method="ward", optimal_ordering=True ) #plt.figure(figsize=(8,8)) sns.clustermap( cr_distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, #standard_scale=1, row_linkage=cr_distmat_link, col_linkage=cr_distmat_link, figsize=(8,8) ) plt.show() # #### Spectrum Clustering # # Aplicando _Spectrum Clustering_ aos dados obtemos a seguinte classificação de _clusters_: # In[94]: cr_distmat_mm=((cr_distmat-cr_distmat.min().min())/(cr_distmat.max().max()-cr_distmat.min().min()))*1 cr_affinmat_mm = pd.DataFrame(1-cr_distmat_mm, cr_distmat.index, cr_distmat.columns) cr_sc = SpectralClustering(5, affinity="precomputed",random_state=2020).fit_predict(cr_affinmat_mm) cr_sc_dict = dict(zip(cr_distmat,cr_sc)) cr_sc_dict # #### _Multi Dimension Scaling_ # # O gráfico de MDS com a inclusão da deputada Cristina Rodrigues é o seguinte: # # In[95]: cr_mds = MDS(n_components=2, dissimilarity='precomputed',random_state=2020, n_init=100, max_iter=1000) cr_results = mds.fit(cr_distmat_mm.values) cr_coords = cr_results.embedding_ sns.set() sns.set_style("ticks") fig, ax = plt.subplots(figsize=(6,6)) #fig.suptitle('Portuguese Parliament Voting Records Analysis, 14th Legislature\nMDS with Spectrum Clustering clusters, inc. Cristina Rodrigues (2D)', fontsize=14) #plt.title('MDS with Spectrum Clustering clusters, inc. Cristina Rodrigues (2D)') for label, x, y in zip(cr_distmat_mm.columns, cr_coords[:, 0], cr_coords[:, 1]): ax.scatter(x, y, c = "C"+str(cr_sc_dict[label]), s=100) ax.axis('equal') if label=="PAN/CR": ax.annotate(label,xy = (x-0.10, y+0.020)) else: ax.annotate(label,xy = (x-0.02, y+0.020)) plt.show() # Como podemos observar as diferenças são bastante pequenas, o que tendo em conta o (relativamente) pequeno número de votações feitas de forma individual não é de estranhar, em especial quando existem também muitos casos onde são idênticos, e onde são diferentes normalmente a diferença é a mínima (i.e. são raras situações de votos A Favor/Contra, que têm maior distância): # In[96]: cr_pan_comp = votes[[ 'PAN','PAN/CR','Cristina Rodrigues (Ninsc)']] cr_pan_comp = cr_pan_comp.dropna().replace(["A Favor", "Contra", "Abstenção", "Ausência"], [1,-1,0,0]) voting_palette = ["#FB6962","#FCFC99","#79DE79", "black"] cr_pan_diff = cr_pan_comp[["PAN","Cristina Rodrigues (Ninsc)"]].dropna()[cr_pan_comp["PAN"] != cr_pan_comp["Cristina Rodrigues (Ninsc)"]] cr_pan_same= cr_pan_comp[["PAN","Cristina Rodrigues (Ninsc)"]].dropna()[cr_pan_comp["PAN"] == cr_pan_comp["Cristina Rodrigues (Ninsc)"]] print("Voted the same:", cr_pan_same.shape[0]) print("Voted differently:",cr_pan_diff.shape[0]) fig, ax = plt.subplots(figsize=(6,6)) sns.set(color_codes=True) ## Build a heatmap of the vote when they diverged #fig.suptitle('Portuguese Parliament Voting Records Analysis, 14th Legislature', fontsize=14) #ax.set_title('PAN and Cristina Rodrigues voting record (only where they diverge)') sns.heatmap(cr_pan_diff.astype(float) , cbar=False,yticklabels = True, cmap=sns.color_palette(palette=["#FB6962","#FCFC99","#79DE79"]), linewidth=1) plt.show() # ### Diferenças entre Actividades e Iniciativas # # A combinação de votos em Actividades e Iniciativas resulta na matriz de distância que será usada para as restantes análises; recuperando o *clustermap* para referência: # In[97]: ## Display the heatmap of the distance matrix fig = plt.figure(figsize=(8,8)) ax = fig.add_subplot() ## Perform hierarchical linkage on the distance matrix using Ward's method. distmat_link = hc.linkage(pwdist, method="ward", optimal_ordering=True ) sns.clustermap( distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, #standard_scale=1, row_linkage=distmat_link, col_linkage=distmat_link, figsize=(8,8)).fig.suptitle('Portuguese Parliament 14th Legislature, Clustermap') plt.show() # e também o MDS correspondente: # In[98]: from sklearn.manifold import MDS mds = MDS(n_components=2, dissimilarity='precomputed',random_state=2020, n_init=100, max_iter=1000) ## We use the normalised distance matrix but results would ## be similar with the original one, just with a different scale/axis results = mds.fit(distmat_mm.values) coords = results.embedding_ coords ## Graphic options sns.set() sns.set_style("ticks") fig, ax = plt.subplots(figsize=(8,8)) plt.title('Portuguese Parliament Voting Records Analysis, 14th Legislature', fontsize=14) for label, x, y in zip(distmat_mm.columns, coords[:, 0], coords[:, 1]): ax.scatter(x, y, s=250) ax.axis('equal') ax.annotate(label,xy = (x-0.02, y+0.025)) plt.show() # Uma questão que pode ser colocada é se existem diferenças entre as votações em Iniciativas e Actividades: as Actividades são em menor número (pelo que o seu peso será sempre menor quando somado às iniciativas) mas inclui votos sobre temáticas que podem ter uma dimensão diferente: votos de pesar, votos de condenação e outro tipo de actividades que têm desde logo uma possível dimensão internacional mais vincada e são formas de demonstração de posições ideológicas sobre acontecimentos nacionais e internacionais. # # Refira-se que todas as votações são votações políticas: o votar contra ou a favor de um voto de pesar ou condenação demonstra uma avaliação política sobre a pessoa, o acontecimento e/ou o próprio texto em votação, texto esse que inclui ele próprio considerações políticas e ideológicas. Existem muitas votações por unanimidade nos casos de votos de pesar e congratulação, mas existem também muitos outros com votações divididas, não sendo incomum votações sobre o mesmo tema com votações opostas, dada a forma como um mesmo acontecimento é descrito e apresentado. # # O seguinte bloco apresentea o _clustermap_ para cada uma das fontes de votação: # In[99]: votes_ini_hm = ini_df[['BE', 'PCP', 'PEV', 'L/JKM', 'PS', 'PAN','PSD','IL','CDS-PP', 'CH']] votes_ini_hmn = votes_ini_hm.replace(["A Favor", "Contra", "Abstenção", "Ausência"], [1,-1,0,0]).fillna(0) votes_act_hm = act_df[['BE', 'PCP', 'PEV', 'L/JKM', 'PS', 'PAN','PSD','IL','CDS-PP', 'CH']] votes_act_hmn = votes_act_hm.replace(["A Favor", "Contra", "Abstenção", "Ausência"], [1,-1,0,0]).fillna(0) ## Transpose the dataframe used for the heatmap votes_ini_t = votes_ini_hmn.transpose() votes_act_t = votes_act_hmn.transpose() ## Determine the Eucledian pairwise distance ## ("euclidean" is actually the default option) pwdist_ini = pdist(votes_ini_t, metric='euclidean') pwdist_act = pdist(votes_act_t, metric='euclidean') ## Create the distance matrix distmat_ini = pd.DataFrame( squareform(pwdist_ini), # pass a symmetric distance matrix columns = votes_ini_t.index, index = votes_ini_t.index ) distmat_act = pd.DataFrame( squareform(pwdist_act), # pass a symmetric distance matrix columns = votes_act_t.index, index = votes_act_t.index ) ## Display the heatmap of the distance matrix fig = plt.figure(figsize=(8,8)) ax = fig.add_subplot() ## Perform hierarchical linkage on the distance matrix using Ward's method. distmat_ini_link = hc.linkage(pwdist_ini, method="ward", optimal_ordering=True ) distmat_act_link = hc.linkage(pwdist_act, method="ward", optimal_ordering=True ) sns.clustermap( distmat_ini, annot = True, cmap=sns.color_palette("Purples_r"), linewidth=1, #standard_scale=1, row_linkage=distmat_ini_link, col_linkage=distmat_ini_link, figsize=(8,8)).fig.suptitle('Portuguese Parliament 14th Legislature, Clustermap (Initiatives Only)') sns.clustermap( distmat_act, annot = True, cmap=sns.color_palette("Blues_r"), linewidth=1, #standard_scale=1, row_linkage=distmat_act_link, col_linkage=distmat_act_link, figsize=(8,8)).fig.suptitle('Portuguese Parliament 14th Legislature, Clustermap (Activities Only)') plt.show() # Existem diferenças, em particular na análise que apenas inclui as Atividades: em particular o PS aparence agora agrupado "à esquerda" (separando-se antes do PAN, sendo o restante semelhante), e isto implica a rearrumação "à direita", com a criação de dois grupos (CDS-PP e CH, PSD e IL). # # Estas diferenças podem também ser vistas no MDS de cada tipo de votação: # In[100]: from sklearn.manifold import MDS import numpy as np mds_act = MDS(n_components=2, dissimilarity='precomputed',random_state=2020, n_init=100, max_iter=1000) mds_ini = MDS(n_components=2, dissimilarity='precomputed',random_state=2020, n_init=100, max_iter=1000) distmat_act_mm=((distmat_act-distmat_act.min().min())/(distmat_act.max().max()-distmat_act.min().min()))*1 distmat_ini_mm=((distmat_ini-distmat_ini.min().min())/(distmat_ini.max().max()-distmat_ini.min().min()))*1 ## We use the normalised distance matrix but results would ## be similar with the original one, just with a different scale/axis results_act = mds_act.fit(distmat_act_mm.values) coords_act = results_act.embedding_ results_ini = mds_ini.fit(distmat_ini_mm.values) coords_ini= results_ini.embedding_ ## Graphic options sns.set() sns.set_style("ticks") fig, ax = plt.subplots(figsize=(8,8)) plt.title('Portuguese Parliament Voting Records Analysis, 14th Legislature (Initiatives)', fontsize=14) for label, x, y in zip(distmat_ini_mm.columns, coords_ini[:, 0], coords_ini[:, 1]): ax.scatter(x, y, s=250) ax.axis('equal') ax.annotate(label,xy = (x-0.02, y+0.025)) plt.show() fig, ax = plt.subplots(figsize=(8,8)) plt.title('Portuguese Parliament Voting Records Analysis, 14th Legislature (Activities)', fontsize=14) for label, x, y in zip(distmat_act_mm.columns, coords_act[:, 0], coords_act[:, 1]): ax.scatter(x, y, s=250) ax.axis('equal') ax.annotate(label,xy = (x-0.02, y+0.025)) plt.show() # Se o "mapa" das Iniciativas é muito semelhante ao global, já o das Actividades apresenta diferenças (como seria de esperar tendo em conta o mapa de distâncias e dendograma), e quando comparado com o global: # # - IL mais próxima do PSD do que dos restantes # - PCP+PEV mais distante de BE/L(JKM)/PAN # - PS mais distante de PSD e mais próximo dos partidos "à esquerda". # # ### Utilização apenas das votações da deputada Cristina Rodrigues enquanto deputada não-inscrita # # Se reduzirmos a análise apenas às votações onde a deputada Cristina Rodrigues participou como deputda não inscrita temos o seguinte resultado, que não altera de forma substancial o que já foi apresentado: # In[101]: cr_only_votes_hm = votes[['BE', 'PCP', 'PEV', 'L/JKM', 'PS', 'PAN',"Cristina Rodrigues (Ninsc)",'PSD','IL','CDS-PP', 'CH']] cr_only_votes_hmn = cr_only_votes_hm.replace(["A Favor", "Contra", "Abstenção", "Ausência"], [1,-1,0,0]).dropna() ## Transpose the dataframe used for the heatmap cr_only_votes_t = cr_only_votes_hmn.transpose() ## Determine the Eucledian pairwise distance ## ("euclidean" is actually the default option) cr_only_pwdist = pdist(cr_only_votes_t, metric='euclidean') ## Create a square dataframe with the pairwise distances: ## the distance matrix cr_only_distmat = pd.DataFrame( squareform(cr_only_pwdist), # pass a symmetric distance matrix columns = cr_only_votes_t.index, index = cr_only_votes_t.index ) cr_only_distmat_link = hc.linkage(cr_only_pwdist, method="ward", optimal_ordering=True ) ## Normalise cr_only_distmat_mm=((cr_only_distmat-cr_only_distmat.min().min())/(cr_only_distmat.max().max()-cr_only_distmat.min().min()))*1 ## SC cr_only_affinmat_mm = pd.DataFrame(1-cr_only_distmat_mm, cr_only_distmat.index, cr_only_distmat.columns) cr_only_sc = SpectralClustering(5, affinity="precomputed",random_state=2020).fit_predict(cr_only_affinmat_mm) cr_only_sc_dict = dict(zip(cr_only_distmat,cr_only_sc)) cr_only_sc_dict ## MDS cr_only_mds = MDS(n_components=2, dissimilarity='precomputed',random_state=2020, n_init=100, max_iter=1000) cr_only_results = mds.fit(cr_only_distmat_mm.values) cr_only_coords = cr_only_results.embedding_ ## Plot it sns.set() sns.set_style("ticks") fig, ax = plt.subplots(figsize=(6,6)) fig.suptitle('Portuguese Parliament Voting Records Analysis, 14th Legislature', fontsize=14) ax.set_title('MDS with Spectrum Clustering clusters, inc. Cristina Rodrigues (2D)') for label, x, y in zip(cr_only_distmat_mm.columns, cr_only_coords[:, 0], cr_only_coords[:, 1]): ax.scatter(x, y, c = "C"+str(cr_only_sc_dict[label]), s=100) #ax.scatter(x, y, s=100) ax.axis('equal') if label=="PAN/CR": ax.annotate(label,xy = (x-0.10, y+0.020)) else: ax.annotate(label,xy = (x-0.02, y+0.020)) plt.show() # ... e a respectiva matriz de distância: # In[102]: sns.clustermap( cr_only_distmat, annot = True, cmap=sns.color_palette("Reds_r"), linewidth=1, #standard_scale=1, row_linkage=cr_only_distmat_link, col_linkage=cr_only_distmat_link, figsize=(8,8) ) plt.show() # --- # ***2020, Frederico Muñoz*** # In[ ]: