ForelliX hat geschrieben: Samstag 12. März 2022, 08:27
Das kann gut sein, aber ich lerne gerne. Mich würde natürlich interessieren, wie du das gelöst hättest und wie dein Skript aussehen würde. Natürlich nur wenn es deine Zeit erlaubt, bei mir läuft es ja
Okay, fangen wir mir dem Parsen an... Es gibt (natürlich) mehrere Möglichkeiten, Du verwendest str.split(). Das funktioniert aber aus drei Gründen nicht gut: erstens wegen der Zeichensetzung -- Du entfernst ja nur einen kleinen Teil davon --, zweitens wegen der Stoppwörter [1], und dann ist da ja noch der verdammte Plural... Es nutzt Dir vermutlich wenig, wenn Deine Datenbank hunderte Einträge für Worte wie "is", "a", "are" oder ähnliche enthalten, und wenn die Satzzeichen etc. nicht herausgefiltert werden -- dann hättest Du nämlich womöglich verschiedene Einträge für "company", "company," und "company." -- und wenn dann noch die Pluralformen davon vorkommen, hast Du sechs Einträge, die im Kern trotzdem alle dasselbe sind.
Deswegen ist es sinnvoll, einen besseren Tokenizer zu benutzen. Mein Code weiter unten verwendet spaCy, aber in scikit-learn und im NLTK gibt es ähnliche Möglichkeiten und obendrein noch ein paar "freie" Projekte um das Thema. Solche Tokenizer benutzen meistens vorgefertigte oder eigene MachineLearning-Modelle, um verschiedene Eigenschaften eines Wortes im Satzkontext zu berechnen, etwa: ist das Wort ein Stoppwort, ist es ein Verb, ein Substantiv, und so weiter. Der spaCy-Tokenizer erzeugt zudem für jedes Wort das zugehörige Lemma, also die Grundform eines Wortes, für "companies" also "company".
Nur um das kurz zu verdeutlichen: wenn ich die Stopwords und die Zeichensetzung aus dem von Dir verlinkten RSS-Feed nicht entferne, sind die häufigsten "Worte" diese (hier aus Platzgründen nur die ersten fünf, aber es geht noch lange so weiter):
Nicht so wertvoll für Deine WordCloud, oder? Und es geht noch viele, viele Zeilen lang so weiter... Wenn ich Zeichensetzung, Stopwords etc ausfiltere, bekomme ich dagegen dieses Ergebnis:
Das sieht doch schon viel besser aus, finde ich. Hier ist mein Code:
Code: Alles auswählen
#!/usr/bin/env python
RSS_URL = 'https://techcrunch.com/startups/feed/'
from collections import Counter
import feedparser
from bs4 import BeautifulSoup
import spacy
def filter_tokens(text):
'''filter alphanumeric and stopwords and return list of tokens'''
return [token.lemma_ for token in text
if token.is_alpha and not token.is_stop]
def main():
nlp = spacy.load('en_core_web_md')
tokens = list()
for entry in feedparser.parse(RSS_URL).entries:
tokens += filter_tokens(nlp(entry.title))
for item in entry.content:
tokens += filter_tokens(nlp(BeautifulSoup(item.value, 'lxml').text))
for word, count in Counter(tokens).most_common(10):
print('{:<30s} {:>4d}'.format(word, count))
if __name__ == '__main__':
main()
Was relationale Datenbanken angeht, so nutzt man dort üblicherweise eine Technik namens Normalisierung [2]. Dies würde für Dich bedeuten, daß Du Deine Daten in zwei Tabellen abbilden müßtest: einer Tabelle, die jeweils eine feste ID für jedes einzelne Wort beinhaltet (im Beispiel unten: Tabelle "words") und einer zweiten Tabelle, in der diese IDs referenziert und auf Deine Zeitstempel gemappt werden (Tabelle "counter").
Zuletzt bin ich mir nicht ganz sicher, ob es sinnvoll ist, diese Payload in eine relationale Datenbank zu schreiben -- das hängt natürlich auch von der Datenmenge ab, im Moment reden wir ja noch von nur einem nicht sehr großen Rss-Feed, das sind ja überschaubare Datenmengen. Aber wenn Du größere Datenmengen hast, könnte das mit einer relationalen Datenbank zunehmend imperformanter werden und als Nacharbeit eine Analyse der verwendeten Datenbank-Queries und die Anlage geeigneter Indizes erfordern. Außerdem wäre bei einer relationalen Datenbank natürlich ein Housekeeping sinnvoll, das regelmäßig die veralteten und daher uninteressant gewordenen Datensätze löscht. Womöglich könnte es aber daher einfacher und ressourcenschonender sein, die Daten in einen Pandas-Dataframe zu packen, um ihn einfach mit der Methode pandas.DataFrame.to_pickle() in eine Datei zu schreiben und später mit pandas.read_pickle() wieder einzulesen. Spaßeshalber habe ich das Skript unten einfach 20 Male laufenlassen, bis in der Tabelle "counter" etwas über 160k Einträge vorhanden waren -- die PostgreSQL-Datenbank ist dann ca. 22 MB groß, ein ge-pickle-ter Pandas-Dataframe hingegen nur 44 kB, und die ganzen Netzwerk- oder Socket-Roundtrips auf der relationalen Datenbank sind ja auch nicht ganz ohne -- allerdings funktioniert das natürlich nur, wenn die Datenmenge in den Arbeitsspeicher paßt. Ich persönlich würde aber auch noch andere Möglichkeiten in Betracht ziehen, csv-oder JSON-Dateien, Redis oder (wenn ohnehin schon vorhanden) OpenSearch zum Beispiel. Insbesondere Redis und OpenSearch (Amazons Fork von Elasticsearch) böten hier zum Beispiel auch die Möglichkeit, beim Eintragen neuer Daten gleich ihre Lebensdauer einzustellen, nach deren Ablauf sie automatisch gelöscht werden.
Aber, Du hattest ja nach meiner Lösung gefragt, und die sähe etwa so aus wie folgt, ich habe dabei nur ein bisschen Fehlerbehandlung weggelassen. Ich verwende allerdings kein MySQL, sondern PostgreSQL, allerdings sollte sich mein SQL-Code leicht konvertieren lassen -- hier in diesem Fall betrifft das ja IMHO nur die Primärschlüssel.
Code: Alles auswählen
#!/usr/bin/env python
RSS_URL = 'https://techcrunch.com/startups/feed/'
DB_CONNECTION_STRING = 'dbname=wordklaus'
from collections import Counter
import feedparser
from bs4 import BeautifulSoup
import spacy
import psycopg2
class DbWriter:
''' # create tables in PostgreSQL
create table words (id serial not null unique primary key, word text unique);
create table counter (id serial not null unique primary key, zeit timestamp not null default now(), word_id integer references words(id));
# and maybe:
create view wordcounts as select count(w.word), w.word from counter c left join words w on c.word_id = w.id GROUP BY w.word ORDER BY count(w.word) DESC;
'''
def __init__(self, connection):
self.connection = connection
self.cache = dict()
def add_token(self, word):
try:
curs = self.connection.cursor()
curs.execute('INSERT INTO words(word) VALUES (%s)', [word])
self.connection.commit()
except psycopg2.errors.UniqueViolation as e:
pass # okay, we just wanted to make sure the word exists
finally:
curs.close()
self.connection.commit()
if word in self.cache.keys():
word_id = self.cache[word]
else:
curs = self.connection.cursor()
curs.execute('SELECT id FROM words WHERE word=%s', [word])
result = curs.fetchall()
curs.close()
word_id = result[0][0]
self.cache[word] = word_id
curs = self.connection.cursor()
curs.execute('INSERT INTO counter (word_id) VALUES (%s)', [word_id])
curs.close()
self.connection.commit()
def filter_tokens(text):
'''filter alphanumeric and stopwords, and return list of tokens'''
#return [token.lemma_ for token in text if token.is_alpha and not token.is_stop]
return [token.lemma_.lower() for token in text if token.is_alpha and not token.is_stop]
def main():
nlp = spacy.load('en_core_web_md')
tokens = list()
for entry in feedparser.parse(RSS_URL).entries:
tokens += filter_tokens(nlp(entry.title))
for item in entry.content:
tokens += filter_tokens(nlp(BeautifulSoup(item.value, 'lxml').text))
connection = psycopg2.connect(DB_CONNECTION_STRING)
db = DbWriter(connection)
for token in tokens:
db.add_token(token)
connection.close()
if __name__ == '__main__':
main()
HF, YMMV.
PS: Es könnte sinnvoller sein, die Worte in Kleinschreibung zu konvertieren, das habe ich hier mal gelassen. Dazu muß nur "token.lemma_" in der Funktion filter_tokens() durch "token.lemma_.lower()" ersetzt werden.
[1]
https://de.wikipedia.org/wiki/Stoppwort
[2]
https://de.wikipedia.org/wiki/Normalisi ... Datenbank)