Diego Fernández López
Federico Alfaro García
En el presente trabajo utilizaremos las redes con el objetivo de extraer información de un texto extenso, ver las palabras relevantes y conexiones entre las mismas.
Como objeto de estudio hemos elegido la Biblia. Entre las razones de esta elección sobresalen que es fácil de conseguir, su temática es variada, carece de derechos de autor, está traducida a una gran cantidad de idiomas (lo que permitiría hacer estudios comparativos en futuros trabajos) y su larga extensión. Además está escrita en un lenguaje fácilmente comprensible lo que facilita el estudio posterior.
El primer paso consiste en transformar el texto de la biblia en un grafo. La idea principal es considerar las palabras como nodos y los enlaces entre palabras se dan si las dos palabras aparecen seguidas.
Para ello se ha escrito código en el lenguaje Python, el cual explicamos a continuación:
Primero cargamos librerías:
import os
os.chdir('files')
import time
tiempo_inic=time.clock()
import nltk#librería para el tratamiento sintáctico
from itertools import imap,ifilter,islice#tanto imap como ifilter son iteradores, son usados
#por razones de eficiencia ver https://docs.python.org/2/library/itertools.html
import networkx as nx
from nltk.stem import SnowballStemmer
spanish_stemmer = SnowballStemmer("spanish")
#ajustamos el sistema para tratar el utf-8:
import sys
reload(sys)
sys.setdefaultencoding('utf8')
def pairwise(iterable):
"""otro iterador:(extraido de https://docs.python.org/2/library/itertools.html)
s -> (s0,s1), (s1,s2), (s2, s3), ...
"""
a, b = next(iterable),next(iterable),
while 1:
yield (a, b)
a,b=b,next(iterable)
endphase='.!?¿!-;.:'
import stop_words
stopwords=stop_words.get_stop_words(u'spanish')#cargamos las stopwords del idioma
La biblia se encuentra en un fichero txt codificado en utf-8 "BIBLIA.txt", al cual aplicamos el siguiente código:
text=open('BIBLIA.txt','r').read()
raw=str.decode( text,'utf8')
tokens = nltk.word_tokenize(raw,'spanish')
tokensLow=imap(unicode.lower,tokens)
from collections import defaultdict
root_original=defaultdict(set)
def getroot(original):
root=spanish_stemmer.stem(original)
root_original[root].add(original)
return root
relevantWords=imap(getroot,ifilter(lambda x: not(x in stopwords), tokensLow) )
digits='01234566789&#()[]'
def punt_gestion(pairs):
for (a,b) in pairs:
if a in endphase or b in endphase or (a[0] in digits) or (b[0] in digits):
pass
else:
yield (a,b)
parejas=(punt_gestion(pairwise(ifilter(lambda x: not(x in ','),relevantWords) )))
Este código lee la biblia y separa las palabras. Hay palabras que no se tienen en cuenta, las llamadas "stopwords", ya que no aportan información, como por ejemplo las preposiciones y los artículos; existen disponibles por internet estas listas de palabras, una por cada idioma. Otro tratamiento importante en este paso es que convertimos todas las palabras a su raíz, ya que no es importante diferenciar entre singular/plural, masculino/femenino o los diferentes tiempos verbales; de esta manera reducimos el número de nodos sin perfer información.
Con ayuda de la función pairwise, recorremos las palabras por parejas adyacentes, saltándonos aquellas que contengan signos que impliquen un cambio de frase.
Un ejemplo de esto sería:
Lo que dividimos en palabras y símbolos:
Transformamos las palabras en sus raíces (pues así buen/o/a/s se representa por el mismo símbolo) y obviamos las stopwords:
Juntamos las palabras contiguas con la función pairwise obteniendo:
Filtramos las parejas que contengan símbolos impliquen un cambio de frase.Resultando:
Es de resaltar que los iteradores evitan el tener que guardar las listas intermedias permitiendo una implementación más eficiente tanto en memoría como en tiempo.
Finalmente contabilizamos cuantas veces se repite cada pareja de palabras (obteniendo un diccionario (str,str)->Integer)
def f(x,y):
x[y]=1+x[y]
return x
countParejas=reduce(f,parejas,defaultdict(int))
En un principio probamos a hacer un digrafo donde los nodos sean las palabras y hay un vertice
aux=ifilter(lambda x:countParejas[x]>5,countParejas)
wordGraph=nx.DiGraph();
wordGraph.add_edges_from(aux)
El grafo conseguido lo guardaremos para su posterior tratamiento tanto en formato csv (tabla tipo excel) como en formato gml:
nx.write_gml(wordGraph,'grafo.gml')
import pandas as pd
tabla=pd.DataFrame(wordGraph.edges(),None,['Source','Target'])
tabla.to_csv('grafo.csv')
En esta sección ya tenemos construido el grafo. En una primera aproximación extraeremos las palabras más relevantes, usando el siguiente código:
from itertools import count,izip
import pandas as pd
import networkx as nx
data=pd.read_csv('grafo.csv')#cargamos el grafo en formato tabla
grafo=nx.DiGraph()
grafo.add_edges_from(zip(data['Source'],data['Target']) ) #reconstruimos el grafo desde la tabla
cuentas=pd.DataFrame()#tabla con distintas puntuaciones de nodos
cuentas['nodos']=list(grafo)
def add(func,name):
"""
func:grafo->dict(nodos=>|R)
name:str
añade una nueva columna (si es que no existe) con los resultados dados por func
"""
if name in cuentas.columns:
pass
else:
sol=func(grafo)
cuentas[name]=(map(lambda x:sol[x], cuentas['nodos']) )
def sort(columna,n=10):
'''
columna:str
Devuelve una tabla con los n nodos mejor puntuados según la columna 'columna'
'''
mejores=sorted(zip(cuentas['nodos'],cuentas[columna],count(0)),key=lambda x:-x[1] )
return cuentas.loc[map(lambda x:x[2],mejores[:10])]
add(nx.degree,'número nodos adyacentes')
add(nx.pagerank,'pagerank')
add(nx.betweenness_centrality,'centralidad')
Algunos estadísticos interesantes son:
estadisticos={}
#numero de vértices:
estadisticos['vertices']=len(grafo)
#número de aristas:
estadisticos['aristas']=len(grafo.edges())
#clústering:
import numpy as np
estadisticos['clustering']=np.mean(nx.clustering(nx.Graph(grafo)).values() )
estadisticos
{'aristas': 5500, 'clustering': 0.169398636244776, 'vertices': 1540}
Este coeficiente de clustering hay que compararlo con la probabilidad de que dos nodos sean vecinos, que es $p=\sharp aristas2/(\sharp vertices(\sharp vertices-1))=0.00464$. El coeficiente de cluster es mucho mayor a este
Mostramos los resultados:
%matplotlib inline
import matplotlib.pyplot as plt
plt.plot(sorted(cuentas['pagerank'],key=lambda x:-x),'o' )
sort('pagerank')
nodos | número nodos adyacentes | pagerank | centralidad | |
---|---|---|---|---|
975 | hij | 351 | 0.032262 | 0.106998 |
1055 | jehov | 326 | 0.023401 | 0.105755 |
1279 | rey | 170 | 0.016629 | 0.038384 |
817 | dios | 254 | 0.016264 | 0.056942 |
625 | tierr | 180 | 0.014246 | 0.048344 |
436 | cas | 132 | 0.010339 | 0.022818 |
384 | dij | 109 | 0.010187 | 0.018987 |
388 | dic | 63 | 0.009196 | 0.004174 |
139 | tod | 125 | 0.008168 | 0.028112 |
1210 | delant | 88 | 0.007917 | 0.016370 |
plt.plot(sorted(cuentas['número nodos adyacentes'],key=lambda x:-x),'o' )
sort('número nodos adyacentes')
nodos | número nodos adyacentes | pagerank | centralidad | |
---|---|---|---|---|
975 | hij | 351 | 0.032262 | 0.106998 |
1055 | jehov | 326 | 0.023401 | 0.105755 |
817 | dios | 254 | 0.016264 | 0.056942 |
625 | tierr | 180 | 0.014246 | 0.048344 |
1279 | rey | 170 | 0.016629 | 0.038384 |
436 | cas | 132 | 0.010339 | 0.022818 |
1063 | hombr | 128 | 0.006439 | 0.025643 |
139 | tod | 125 | 0.008168 | 0.028112 |
814 | israel | 123 | 0.006778 | 0.024241 |
145 | puebl | 112 | 0.007369 | 0.015338 |
plt.plot(sorted(cuentas['centralidad'],key=lambda x:-x),'o' )
sort('centralidad')
nodos | número nodos adyacentes | pagerank | centralidad | |
---|---|---|---|---|
975 | hij | 351 | 0.032262 | 0.106998 |
1055 | jehov | 326 | 0.023401 | 0.105755 |
817 | dios | 254 | 0.016264 | 0.056942 |
625 | tierr | 180 | 0.014246 | 0.048344 |
1279 | rey | 170 | 0.016629 | 0.038384 |
139 | tod | 125 | 0.008168 | 0.028112 |
1063 | hombr | 128 | 0.006439 | 0.025643 |
814 | israel | 123 | 0.006778 | 0.024241 |
436 | cas | 132 | 0.010339 | 0.022818 |
461 | man | 94 | 0.007442 | 0.020690 |
Por lo que se ve que en este caso que los tres estadísticos dan resultados similares a la hora de señalar las palabras más relevantes.
Mostramos el grafo cociente de las palabras más puntuadas:
nodos=set(list(sort('número nodos adyacentes')['nodos'])+list(sort('pagerank')['nodos'])+list(sort('centralidad')['nodos']) )
print( ','.join(nodos) )
nx.draw_networkx(nx.Graph(grafo.subgraph(nodos) ))
Por lo que se ve una alta relación entre los nodos.
En está sección extraeremos comunidades siguiendo distintos algoritmos, siguiendo sobre el programa anterior:
from collections import defaultdict
from itertools import izip
def groupBy(columna):
#devuelve un diccionario:valoresColumna->[Nodos]
# x->[Nodos(i)|Columna(i)==x]
def f(x,(nodo,group) ):
x[group].append(nodo)
return x
return reduce(f,izip(cuentas['nodos'],cuentas[columna]),defaultdict(list))
import community#http://mlg.ucd.ie/files/summer/tutorial.pdf
add(lambda x:community.best_partition(nx.Graph(x)),'comBPart')
comunidad=groupBy('comBPart')
cuentas.to_csv('grafoFinal.csv')
Donde comunidad es un diccionario que asigna a cada nodo un valor en la comunidad, partiendo de esto creamos el grafo cociente:
nodos=set(comunidad);
vertices={(i,j) for i in nodos for j in nodos if (
any(grafo.has_edge(a,b) for a in comunidad[i] for b in comunidad[j] ) ) }
Gcomunidad=nx.Graph();Gcomunidad.add_edges_from(vertices)
layout=nx.fruchterman_reingold_layout(Gcomunidad)
nx.draw_networkx(Gcomunidad,node_size=map(lambda x:len(comunidad[x]),Gcomunidad),pos=layout)
for n,subgraph in (imap(lambda (n,gr):(n,grafo.subgraph(gr)),comunidad.items() ) ):
plt.figure()
plt.title('comunidad '+str(n))
layout=nx.layout.spectral_layout(subgraph)
if n<=10:
nx.write_gml(subgraph,'comunidad '+str(n))
nx.draw_networkx(subgraph,pos=layout)
En el grafo el tamaño de los nodos es proporcional al número de palabras que contiene. El histograma de su tamaño es:
from matplotlib.pyplot import hist
_=hist(list(filter(lambda x:x<=300,map(
lambda x:(len(comunidad[x])),Gcomunidad ) ) ) ,bins=50
)
Esto nos indica una gran cantidad de parejas de palabras repetidas (comunidades de tamaño menor o igual que 3): salida
from itertools import *
f=open('out.txt','w')
f.write('\n'.join([str( (l,map(lambda x:list(root_original[x])[:3],l)) )
for l in filter(lambda x:len(x)<=3,map(lambda x:comunidad[x],Gcomunidad ) )
] ),)
f.close()
De esto se denota que en la biblia se tienden a repetir expresiones, pongamos algunos casos:
- huerto eden /cuidadano romano: puede considerarse un concepto unitario.
- trigo cebada: sin duda proviene de la enumeración de "trigo y cebada" por referirse a la fuente de alimentación
- entensar arco: una acción muy repetida.
- ...
Por desgracia Python no resulta la opción más comoda ni efectiva a la hora de dibujar grandes redes. Por lo que a partir de ahora usaremos el programa Gephi.
En el código anteriormente usado hemos guardado en los archivos grafoFinal.csv y en grafo.csv tanto el grafo como la información obtenida.
Desde el Gephi importamos las tablas recreando el grafo.
Sobre el grafo aplicamos el algoritmo de ForceAtlas obteniendo (doble click para verlo en toda su extensión):
Si nos fijamos en la parte inferior del grafo vemos que la comunidad azul relacciona muchos nombres propios con hijos.
En cuanto al resto de comunidades grandes las filtraremos para estudiarlas individualmente.
Con ayuda de Gephi extraemos el subgrafo de cada comunidad de gran tamaño , calculamos el pagerank del mismo y aplicamos algoritmos de redistribución de los nodos y de modularidad.
A continuación mostraremos cada subgrafo e interpretaremos los que consideremos más interesantes:
Esta es una de las comunidades más grandes en las que se divide el grafo. Los nodos más importantes son "dios" y "Jehová", que en realidad se refieren a lo mismo en la biblia, por lo que comparten muchas conexiones. Como cabía esperar, suelen ir acompañados de términos positivos, como "bendición", "esperanza", "justicia" o "todopoderoso". En la periferia se pueden ver alguno grupos especiales: abajo a la izquierda se puede ver el nodo "si" (forma condicional) acompañado de verbos; el verbo "hacer" es bastante importante también, con conexiones diversas.
<img src="files/C2.png",width=500,height=500>
<img src="files/C5.png",width=500,height=500>
En esta comunidad se ven claramente dos clases de palabras, relacionadas entre sí. Por un lado está el grupo de intervalos de tiempo con números, que forman expresiones referentes al paso del tiempo como "mil años" o "cuarenta días". Y por otro lado hay recursos naturales, como minerales ("oro", "plata", "bronce"), ganado ("oveja", "buey"), "madera" y "piedra".
<img src="files/C6.png",width=500,height=500>
<img src="files/C8.png",width=500,height=500>
Esta comunidad trata claramente sobre la geneología que se cuenta en la biblia. El nodo más importante de este grafo es "hij" (raíz de hijo, hija, hijos e hijas), que está conectado a muchos de los nombres que se mencionan en la biblia y al resto de nodos importantes: "padre", "hermano", "mayor" y "menor".
<img src="files/C9.png",width=500,height=500>
Esta comunidad está formada por tres pequeños grupos conectados entre sí. Arriba tenemos la palabra "fuego", acompañada por adjetivos como "consumidor" y "ardiente"; en el centro tenemos "cielo", conectado a las estrellas y la luna; y por último abajo destaca la palabra "ojos".
<img src="files/C10.png",width=500,height=500>
<img src="files/C13.png",width=500,height=500>
<img src="files/C15.png",width=500,height=500>
Si nos fijamos, hay algunos nodos con bucles, es decir, palabras idénticas o que comparten raíz que aparecen dos veces seguidas. Esto nos puede parecer un fenómeno extraño, por lo que hemos buscado algunos ejemplos en el texto. Algunas veces se trata de que se termina una frase y se empieza la siguiente con la misma palabra y otras muchas veces son expresiones como "piedra sobre piedra", "ojo por ojo","día a día" o "por los siglos de los siglos"; estas palabras aparecen con autoenlace porque hemos eliminado las stopwords.
Para mostrar los ejemplos en su contexto hemos extraido un par de párrafos con cada caso (script): párrafos
El estudio de un texto tan extenso a través de su grafo de palabras no es sencillo. Hemos tenido que reducir la complejidad del grafo mediante algunos trucos y aún así el grafo total es demasiado grande para entenderlo en su totalidad.
Parece, al menos en este caso, relativemente fácil descubrir a los entes con mayor protagonismo (Dios/Jehová) y las distintas comunidades nos permiten ver algunas temáticas, como bien puede ser las descendencias, menciones a los materiales y tiempo, conceptos como fuego y cielo...
En este sentido se puede ver la gran variedad de temáticas en la biblia. Con un conocimiento mayor de la biblia y del contexto en la que surgió se pueden sacar conclusiones más interesantes que escapan de nuestro alcance.
Lo bueno del procedimiento seguido en este trabajo es que podemos utilizar los códigos para estudiar otros textos, pudiendo ampliar el trabajo, por ejemplo, comparando la biblia en distintos idiomas, o ver las diferencias entre un texto antiguo y uno reciente. Pudiendose así profundizar mucho más en estos estudios.
Los códigos se han ejecutado sobre Jupiter notebook usando el lenguaje Python 2.7.11 sobre un ordenador de sobremesa con procesador Intel (R) Core (TM) i5-2300 CPU @ 2.80GHz con 4 GB ram, sistema operativo Lubuntu 64 bits (Linux).
En cuanto a las librerias usadas:
- pandas 0.14.1
- networkx 1.11
- nltk 3.2.1
- stop_words 2015.2.23.1
- matplotlib
- itertools
- collections
- numpy 1.11.0
Los gráficos de la sección Estudiando comunidades se han creado con la herramienta Gephi sobre el sistema descrito anteriormente.
Tiempo ejecución de los script (s):
time.clock()-tiempo_inic
63.356159000000005
El grafo fue construido a partir de una edición de la biblia en txt (por motivos técnicos cambiamos su codificación a UTF-8 con ayuda de un editor de texto plano):
http://www.unoenelsenor.com.ar/biblia.htm