<jupyter_start><jupyter_text>Text als Netzwerk Die computerlinguistische Aufbereitung von Texten kann als Ausgangspunkt für weitere Analysen dienen, die typische Muster und Inhalte in Texten sichtbar machen. Eine Grundidee ist dabei, dass Elemente sich nicht allein durch ihre individuellen Eigenschaften auszeichnen, sondern vor allem durch ihre Relationen zu anderen Elementen. In der Soziologie hat dies etwa <NAME> (1997) herausgestellt. Dies gilt aber nicht nur für soziale Akteure, sondern auch für Sprache selbst. So hat etwa bereits Ludwig Wittgenstein in seinen *Philosophischen Untersuchungen* (2008 [1953]) die Verwandtschaft von Begriffen als ein „kompliziertes Netz von Ähnlichkeiten“ (ebd., 57) beschrieben.Diese abstrakten Ideen lassen sich mit den Methoden der Netzwerkanalyse operationalisieren. Ein Netzwerk ist dabei zunächst eine Möglichkeit, Beziehungen zwischen Elementen abzubilden. Die gängigen Attributdaten der Soziologie werden zumeist als Tabelle dargestellt, bei der jede Zeilen einen Fall und jede Spalte dessen Eigenschaften repräsentiert. Im Netzwerk kommt eine zweite Ebene hinzu: Die Beziehungen zwischen diesen Fällen, wobei die einzelnen Verbindungen wieder Eingenschaften haben können (etwa Art, Intensität, Dauer, etc.).In der Sozialforschung werden in der Regel Netzwerke von Akteuren analysiert. Auch solche Netzwerke lassen sich prinzipiell aus Texten gewinnen (etwa Diesner und Carley 2005). Für die Analyse von Texten lassen sich aber auch inhaltliche (semantische) Aspekte als Netzwerk darstellen und analysieren.<jupyter_code>import os
import networkx as nx
import pandas as pd
from textblob_de import TextBlobDE as TextBlob
from textblob_de import PatternParser
from itertools import combinations
from collections import namedtuple
from pprint import pprint<jupyter_output><empty_output><jupyter_text>Da wir hierfür die Verfahren der computerlinguistischen Sprachverarbeitung nutzen können, definieren wir zunächst wieder eine Funktion zur Lemmatisierung und zur Part-of-Speech-Filterung, die die Funktionen von `TextBlob` nutzt.Die Wörter werden dabei nach drei Kriterien gefiltert:* POS-Tags (wobei nur die ersten beiden Buchstaben der Tags berücksichtigt werden, die den Oberkategorien entsprechen, z.B. 'NN' für Substantive),* eine Stopwortliste,* und ein regulärer Ausdruck, der dazu dient, Zahlen enthaltende Wörter (z.B. '1990er', '18jährige') zu entfernen.<jupyter_code>Token = namedtuple('Token', ['word', 'part_of_speech', 'chunk', 'preposition', 'lemma'])

def tokenize(blob):
    parse = blob.parse()
    return [Token(*token.split('/', maxsplit=4)) for token in parse.split(' ')]
import os
import re
with open(os.path.join('..', 'Daten', 'stopwords.txt')) as stopwordfile:
    stopwords = stopwordfile.read().splitlines()

def lemmatize_and_filter(blob, tags):
    tokens = tokenize(blob)
    result = []
    for token in tokens:
        if (token.part_of_speech[0:2] in tags
            and not token.word in stopwords
            and re.match('[^\W0-9]+', token.lemma)):
            if token.part_of_speech.startswith('NN'):
                lemma = token.lemma.title()
            else:
                lemma = token.lemma.lower()
            result.append(lemma)
    return result<jupyter_output><empty_output><jupyter_text>Ein einfacher Ansatz, Relationen von Texteinheiten (Wörtern) zu bestimmen, ist die Kookkurrenz: Zwei Wörter, die in räumlicher Nähe zueinander stehen, haben eine erhöhte Wahrscheinlichkeit, auch inhaltlich aufeinander bezogen zu sein. Als räumliche Nähe kann z.B. ein Satz definiert werden. Es sind aber auch andere Ansätze möglich, etwa ein fester Wortabstand von etwa 5 Wörtern. Alle (gefilterten) Wörter im jeweiligen Fenster sollen also als vernetzt gelten. Dazu muss für jede Zweierkombination aller Wörter im Fenster eine Relation erzeugt werden.In Python steht dafür die Funktion `combinations()` zur Verfügung:<jupyter_code>[(a, b) for a, b in combinations('abc', 2)]<jupyter_output><empty_output><jupyter_text>Die Python-Bibliothek [NetworkX](http://networkx.github.io/) stellt einfache Funktionen bereit, ein Netzwerk zu erzeugen. Sie ist dabei allerdings nicht besonders schnell. Für größere Projekte bieten sich andere Bibliotheken wie [igraph](http://igraph.org/python/) an.Das Kernvorgehen ist dabei sehr einfach:1. Das Corpus ist in einer CSV-Datei gespeichert, wobei die Spalte 'text' den eigentlichen Text enhält.2. Für jeden Text wird ein `TextBlob` erzeugt, der linguistische Informationen ergänzt, so u.a. auch Satzgrenzen.3. Für jeden Satz werden die zu berücksichtigenden Lemmata bestimmt. In diesem Fall werden nur Substantive und Adjektive einbezogen.4. Es werden für jeden Satz alle möglichen Kombinationen dieser Lemmata ermittelt.5. Falls eine bestimmte Relation schon im Netzwerk enthalten ist, wird ihr „Gewicht“ um 1 erhöht, andernfalls wird eine neue Relation mit dem Gewicht 1 hinzugefügt.<jupyter_code>filepath = os.path.join('..', 'Daten', 'Rede_Jugend_forscht.txt')
with open(filepath) as infile:
    text = infile.read()

graph = nx.Graph()
blob = TextBlob(text, parser=PatternParser(lemmata=True))
for sentence in blob.sentences:
    lemmas = lemmatize_and_filter(sentence, ('NN', 'JJ'))
    for a, b in combinations(lemmas, 2):
        if a == b:
            continue
        try:
            graph[a][b]['weight'] += 1
        except KeyError:
            graph.add_edge(a, b, weight=1)
'Nodes: {}, Edges: {}'.format(graph.number_of_nodes(), graph.number_of_edges())<jupyter_output><empty_output><jupyter_text>Um diese Prozedur nicht für jede Analyse wiederholen zu müssen, kann das Ergebnis in einer Datei gespeichert werden.<jupyter_code>nx.write_graphml(graph, os.path.join('..', 'Daten', 'rede.graphml'))<jupyter_output><empty_output><jupyter_text>Dieses Netzwerk kann nun mit Mitteln der Netzwerkanalyse analysiert werden. Eine einfache Netzwerkstatistik ist der “degree”, die Anzahl der Verbindungen jedes Knotens. `graph.degree()` aus NetworkX gibt dabei in einem `dict` für jeden Knoten seinen degree aus. Die Python-Klasse `Counter` stellt für solche dictionaries einige Zusatzfunktionen bereit, etwa die Ausgabe der `n` Knoten mit dem höchsten degree.<jupyter_code>from collections import Counter
nodes_by_degree = Counter(graph.degree())
tops = nodes_by_degree.most_common(20)
tops<jupyter_output><empty_output><jupyter_text>Das Gesamtnetzwerk mit seinen 46.000 Knoten ist dabei nicht leicht zu analysieren und inhaltlich zu interpretieren. Es kann aber ein Ausgangspunkt für Detailanalysen sein. Ausgehend von der Annahme, dass sich die Bedeutung eines Wortes durch seinen Verwendungszusammenhang ergibt, lassen sich etwa Begriffsanalysen durchführen. Hierfür kann für ein Wort ein Teilnetzwerk erstellt werden, das nur die mit ihm direkt verbundene Wörter („Nachbarn“) und deren Verbindungen untereinander enthält.Als Beispiel soll das Wort „Integration“ dienen.<jupyter_code>data = pd.read_csv(os.path.join('..', 'Daten', 'Reden.csv'), parse_dates=['date'], encoding='utf-8')
graph = nx.Graph()
for text in data['text']:
    blob = TextBlob(text, parser=PatternParser(lemmata=True))
    for sentence in blob.sentences:
        lemmas = lemmatize_and_filter(sentence, ('NN', 'JJ'))
        if not 'Integration' in lemmas:
            continue
        for a, b in combinations(lemmas, 2):
            if a == b:
                continue
            try:
                graph[a][b]['weight'] += 1
            except KeyError:
                graph.add_edge(a, b, weight=1)
nx.write_graphml(graph, os.path.join('..', 'Daten', 'integration.graphml'))
subgraph = graph.subgraph(graph.neighbors('Integration'))
'Nodes: {}, Edges: {}'.format(subgraph.number_of_nodes(), subgraph.number_of_edges())<jupyter_output><empty_output><jupyter_text>Da eher zufällige Verbindungen das Ergebnis verzerren und ja systematische Beziehungen analysiert werden sollen, können die Kanten im Netzwerk noch gefiltert werden. Hier werden alle Kanten, die ein Gewicht von 1 haben, gelöscht. Es können auch höhere Schwellenwerte festgelegt oder aber ausgefeiltere statistische Kriterien zugrundegelegt werden, die die Signifikanz von Verbindungen bestimmen.<jupyter_code>lowedges = [edge for edge in subgraph.edges(data=True) if edge[2]['weight'] < 2]
len(lowedges)<jupyter_output><empty_output><jupyter_text>Durch das Entfernen von Kanten können im Netzwerk „Inseln“ entstehen, die nicht mehr mit dem Gesamtnetzwerk verbunden sind:<jupyter_code>subgraph.remove_edges_from(lowedges)
nx.number_connected_components(subgraph)<jupyter_output><empty_output><jupyter_text>Diese werden für die weitere Analyse ausgeschlossen.*Hinweis:* Hier wird ein Kniff angewandt, der möglichst ressourcensparend arbeitet, aber nicht auf den ersten Blick eingängig ist. Daher hier eine kurze Erläuterung.Die Funktion `connected_component_subgraphs` gibt für jede verbundene Komponente des Netzwerks ein Teilnetzwerk zurück. Da dies aber rechenaufwändig ist und potenziell viel Speicher benötigt, ist das Ergebnis der Funktion keine Liste aller Teilnetzwerke, sondern ein „Generator“. Ein Generator unterscheidet sich von einer Liste dadurch, dass seine Elemente erst „generiert“ werden, wenn auf sie zugegriffen wird. Dabei erlaubt er nur die Form `for x in y`, nicht aber `y[i]`. Ein Generator kann aber in eine Liste umgewandelt werden: `list(y)`.Die Komponenten in `connected_component_subgraphs` sind nach ihrer Größe geordnet, wobei das größte Teilnetzwerk zuerst ausgegeben wird. Um also nur das größte Teilnetzwerk zu erhalten wäre grundsätzlich dieses Vorgehen möglich (und auch nachvollziehbarer):```pythonsubgraphs = list(nx.connected_component_subgraphs(subgraph))subgraph2 = subgraphs[0]```Dafür müssten aber zunächst alle Teilnetzwerke erzeugt und in der Liste `subgraphs` gespeichert werden, obwohl sie nicht benötigt werden. Daher kann hier ein anderer Weg gewählt werden: In der Schleife `for subgraph2 in nx_connected_component_subgraphs(subgraph):` wird im ersten Durchgang das erste Teilnetzwerk erzeugt und der Variable `subgraph2` zugewiesen. Da das alles ist, was wir benötigen, kann die Schleife direkt danach abgebrochen werden, ohne dass weitere Befehle ausgeführt werden.<jupyter_code>for subgraph2 in nx.connected_component_subgraphs(subgraph):
    break
'Nodes: {}, Edges: {}'.format(subgraph2.number_of_nodes(), subgraph2.number_of_edges())
nx.write_graphml(subgraph2, os.path.join('..', 'Daten', 'integration_core.graphml'))<jupyter_output><empty_output><jupyter_text>Für die Analyse des Begriffsnetzwerkes von „Integration“ sind diejenigen Bereiche von Interesse, die eine dichtere Vernetzung aufweisen. Diese Teilnetzwerke sind nicht „Komponenten“ im oben genannten Sinne, da sie durchaus Teil des Gesamtnetzwerkes sind. Sie zu identifizieren kann also nur näherungsweise geschehen, es gibt kein absolutes Ergebnis. Dementsprechend sind in den letzten Jahren zahlreiche Algorithmen zur sogenannten “community detection” entwickelt worden, die sich im Detail und in ihrer Effizienz unterscheiden. Ein etablierter Algorithmus für NetworkX ist in [diesem Modul](http://perso.crans.org/aynaud/communities/) verfügbar.*Hinweis:* Die Funktion `best_partition()` gibt für jeden Knoten im Netzwerk die Nummer seiner Community zurück:    {'node1': 0,     'node2': 1,     'node3': 0}Diese Form ist aber nur bedingt geeignet, um die Communities weiter zu analysieren. Besser geeignet wäre eine Liste aller Knoten für jede Community:    [['node1', 'node3'], ['node2']]Daher wird zunächst eine Liste erzeugt, die für jeden möglichen Community-Wert eine leere Liste enthält. Im nächsten Schritt wird jeder Knoten der seiner Community entsprechenden Liste hinzugefügt. Dadurch wird die gewünschte Struktur hergestellt.Im nächsten Schritt kann dann für jede Community ein Teilnetzwerk erzeugt werden. Der Degree der Knoten in den Teilnetzwerken gibt Aufschluss über die zentralen Begriffe dieser Teilnetzwerke.<jupyter_code>import community
partition = community.best_partition(subgraph2)

communities = [[] for i in set(partition.values())]
for node, comm in partition.items():
    communities[comm].append(node)
    
community_graphs = [subgraph2.subgraph(nodes) for nodes in communities]
for i, graph in enumerate(community_graphs):
    print('Community {}:'.format(i))
    pprint(Counter(graph.degree()).most_common(12))<jupyter_output>Community 0:
[('kulturell', 35),
 ('Bildung', 16),
 ('Herkunft', 15),
 ('Bereich', 14),
 ('unabhängig', 12),
 ('sozial', 12),
 ('Bedürfnis', 12),
 ('Aktivitäten', 12),
 ('Ländern', 12),
 ('finanziell', 12),
 ('Lage', 12),
 ('Zugang', 12)]
Community 1:
[('Querschnittsaufgabe', 9),
 ('Bund', 2),
 ('Kommune', 2),
 ('Bundesregierung', 1),
 ('international', 1),
 ('Zusammensetzung', 1),
 ('Aufteilt', 1),
 ('Tatsache', 1),
 ('Konferenzsaal', 1),
 ('Veranstaltung', 1)]
Community 2:
[('Land', 10),
 ('Verantwortung', 7),
 ('Gesellschaft', 3),
 ('Familie', 2),
 ('Wurzeln', 2),
 ('Ankommt', 2),
 ('Identifikation', 2),
 ('Widerspiegelt', 2),
 ('langfristig', 2),
 ('Episode', 2),
 ('wichtig', 1),
 ('Schlüsselaufgabe', 1)]
Community 3:
[('europäisch', 45),
 ('fest', 13),
 ('Ihren', 13),
 ('Notwendigkeit', 5),
 ('stabil', 3),
 ('Welt', 3),
 ('Einheit', 3),
 ('Glauben', 3),
 ('Einigung', 3),
 ('stärk', 3),
 ('Willen', 3),
 ('Friedens', 3)]
Community 4:
[('Deutschland', 30),
 ('Thema', 18),
 ('Frage', [...]
{'fraction_non_alphanumeric': 0.09406830437387657, 'fraction_numerical': 0.011009586578789695, 'mean_word_length': 5.425890279114533, 'pattern_counts': {'":': 0, '<': 46, '<?xml version=': 0, '>': 45, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 1, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '11', 'dir': 'jupyter-structured-clean-dedup', 'id': '360852', 'license': 'CC-BY-4.0', 'n_tokens_mistral': 4484, 'n_tokens_neox': 4498, 'n_words': 1434, 'path': '04_Netzwerk/Text als Netzwerk.ipynb', 'repo_name': 'frederik-elwert/UniLu_HS2015_Textanalyse', 'size': '13363'}