Dans ce billet, je plonge dans l’analyse de la censure littéraire à l’aide du langage Python et des outils Scrapy et spaCy.
je te partage une analyse du langage naturel effectuée sur des descriptions de livres. Ceux-ci ont tous en commun de figurer sur une liste de censure proposée par un sénateur républicain du Texas. J’ai explore le contexte politique dans cet article de blog.
Un contenu unique qui devrait plaire à plusieurs en cette fête de l’amour !
Extraction des titres et des URL de la liste
Pour faire l’extraction de la liste des titres de tous les livres et de leur URL, j’utilise la librairie Scrapy du langage Python.
import scrapy from scrapy.crawler import CrawlerProcess from scrapy.item import Item, Field
Je vais donc commencer par définir une classe (un modèle d’objet accompagné de ses propriétés) qui contient deux éléments extraits depuis les listes de livres:
- Le titre du livre
- L’URL de la page qui décrit le livre
class GoodReadsListItem(Item): title = Field() href = Field()
Je vais les extraire récursivement depuis chacune des 9 listes de livres.
Ils sont contenus dans une balise identifiée par le chemin CSS suivant: a.bookTitle
.
Voici le code HTML de cet élément, tel qu’obtenu depuis l’inspecteur de code du navigateur.
<a class="bookTitle" itemprop="url" href="/book/show/55184226-race-and-the-media-in-modern-america"> <span itemprop="name" role="heading" aria-level="4">Race and the Media in Modern America</span> </a>
Construction de la classe Spyder
On peut construire une classe de type Spyder pour extraire ces éléments depuis le code HTML.
Cette classe contient deux méthodes:
start_requests
: permet de définir les URLs à télécharger et initialiser les requêtes de téléchargement.- Ce sont ici les 9 pages qui contiennent les listes.
- Pour extraire le contenu, on définit la propriété callback avec une autre méthode :
parse
parse
: permets d’extraire les informations depuis la réponse et de les insérer dans l’objetGoodReadsListItem
que l’on a créé plus haut.- Le titre est extrait depuis le texte de la balise
span
, et on construit l’URL à partir de l’attributhref
, en concaténant l’adresse complète du site.
- Le titre est extrait depuis le texte de la balise
Enfin, on définit le nom de notre classe: GoodReadsListSpider
.
Voici le résultat:
class GoodReadsListSpider(scrapy.Spider): name = "goodreadslistspider" def start_requests(self): urls = [ "https://www.goodreads.com/list/show/" "168413.Banned_Books_According_to_Krause_Part_1_", "https://www.goodreads.com/list/show/" "168429.Banned_Books_According_to_Krause_Part_7", "https://www.goodreads.com/list/show/" "168430.Banned_Books_According_to_Krause_Part_8", "https://www.goodreads.com/list/show/" "168425.Banned_Books_According_to_Krause_Part_4", "https://www.goodreads.com/list/show/" "168420.Banned_Books_According_to_Krause_Part_2", "https://www.goodreads.com/list/show/" "168424.Banned_Books_According_to_Krause_Part_3", "https://www.goodreads.com/list/show/" "168428.Banned_Books_According_to_Krause_Part_6", "https://www.goodreads.com/list/show/" "168426.Banned_Books_According_to_Krause_Part_5", "https://www.goodreads.com/list/show/" "168432.Banned_Books_According_to_Krause_Part_9" ] for url in urls: yield scrapy.Request(url=url, callback=self.parse) def parse(self, response): page = response.url.split("/") anchors = response.css("a.bookTitle") for anchor in anchors: data = GoodReadsListItem({ 'title': anchor.css('span::text').get(), 'href': "https://goodreads.com" + anchor.attrib[ 'href'] }) yield data
Définition du processus d’extraction
Scrapy simule le comportement d’un navigateur web. Comme les sites web sont de plus en plus complexes et qu’ils utilisent des mécanismes de protection contre les robots, nous devons appliquer ici quelques notions d’éthique.
Nous allons utiliser un autorégulateur (AUTOTHROTTLE_ENABLED
) pour faire une pause (AUTOTHROTTLE_START_DELAY
) d’une seconde entre chacune des extractions. Ça parait court, mais c’est très long pour un ordinateur et ça donne le temps au serveur qui nous envoie les pages de respirer. Comme ça, il ne nous considère pas comme une menace.
Nous allons aussi désactiver les cookies (COOKIES_ENABLED
) et le cache (HTTPCACHE_ENABLED
), car nous voulons que chaque extraction se comporte comme si nous visitions le site web pour la première fois. C’est important, sinon le moteur de navigation pourrait réutiliser du contenu ancien qu’il a gardé en mémoire et fausser les données de notre extraction.
Enfin, nous définissons le format de données dans lequel nous voulons sauvegarder les résultats (FEEDS
). Avec le langage Python, le format JSON est approprié, car il ressemble à la structure de données dictionnaire.
Le processus complet, défini par la fonction CrawlerProcess
, ressemble à ceci:
process = CrawlerProcess({ 'AUTOTHROTTLE_ENABLED': 'True', 'AUTOTHROTTLE_START_DELAY': 1.0, 'HTTPCACHE_ENABLED': False, 'COOKIES_ENABLED': False, 'FEEDS': { 'scraped_data/goodreadslist.json':{ 'format': 'json', 'encoding': 'utf8', 'store_empty': False, 'fields': None, 'indent': 4, 'item_export_kwargs': { 'export_empty_fields': True, } } } })
Exécution de l’extraction
Pour faire l’extraction, on définir un navigateur virtuel avec la méthode crawl
et notre objet GoodReadsListSpider
.
process.crawl(GoodReadsListSpider)
On démarre ensuite le processus, puis on l’arrête lorsque l’extraction est terminée.
process.start() process.stop()
Nous avons maintenant un fichier JSON qui contient tous les titres des livres de la liste, ainsi que leur URL sur le site GoodReads.
Extraction des descriptions de chaque livre de notre analyse de la censure littéraire
Nous allons maintenant répéter le processus d’extraction pour chacun des livres, dont nous avons récolté les titres et les URLs.
Sur chacune des pages de livres, il y a une section qui contient des métadonnées sur le livre, qui porte l’identifiant metacol
. Nous allons donc nous intéresser à celle-ci.
Nous y trouvons notamment
- le titre,
- la série à laquelle il appartient (si applicable)
- l’URL vers la page de l’autrice ou auteur
- le nom de l’autrice ou auteur
- la description du livre
- la note moyenne
- le nombre de notes
- le nombre de revues
- le format du livre
- le nombre de page
- la date de publication
- l’ISBN (le nombre situé sous le code barre, qui identifie uniquement un livre)
Il y a aussi d’autres attributs présents sur la page, mais qu’une des réalités de la pratique du moissonnage du web, c’est que ça ne fonctionne pas de manière déterministe. Les sites web changent, le téléchargement peut tomber en erreur ou un script dynamique peut ajouter du bruit sur la ligne.
On définit d’abord une classe pour contenir toutes ces informations: GoodReadsBookItem
.
class GoodReadsBookItem(Item): bookTitle = Field() bookSeries = Field() authorURL = Field() authorName = Field() description = Field() ratingValue = Field() ratingCount = Field() reviewCount = Field() bookFormat = Field() numberOfPages = Field() datePublished = Field() isbn = Field()
Je vais créer deux méthodes additionnelles à l’intérieur de l’objet GoodReadsBookSpider
:
- clean_string: permet de nettoyer les caractères superflus tels que les espaces et les sauts de lignes à la fin
- split_published_date: Le champ de la date de publication est mal formaté et inclus aussi le nom de l’éditeur, et j’ai remarqué au cours de mes essais, que bien souvent, il est contaminé par du code d’outils marketing, et finit par être inutilisable. J’utilise donc une expression régulière pour extraire uniquement la date et la convertir dans un format normalisé ISO 8601.
Voici le code de ces deux méthodes:
def clean_string(self, strExtract): if strExtract is None: x = "" else: x = strExtract.strip('\n ') return x
def split_published_date(self, strPublisher): try: datepublished_str = re.search(r'^.*Published\s(\w+\s\d+\w*\s\d+)\sby.*', strPublisher).group(1) datepublished = parse(datepublished_str).isoformat() except: datepublished = datetime.datetime.now() return datepublished
J’utilise maintenant une panoplie de sélecteurs CSS pour extraire tous les attributs à l’intérieur du bloc de classe metacol
. Je ne vais pas les décrire un par un, mais leur fonctionnement est similaire à ce que j’ai utilisé pour extraire les titres et les URL des livres.
À la fin de la fonction parse
, je vérifie si le titre est vide. C’est mon indicateur pour valider si l’extraction a bien fonctionné ou non.
Voici le contenu de l’objet GoodReadsBookSpider
:
class GoodReadsBookSpider(scrapy.Spider): name = "goodreadsbookspider" def start_requests(self): urls = pd.read_json("scraped_data/goodreadslist.json").href for url in urls: yield scrapy.Request(url=url, callback=self.parse, dont_filter=True ) def clean_string(self, strExtract): if strExtract is None: x = "" else: x = strExtract.strip('\n ') return x def split_published_date(self, strPublisher): try: datepublished_str = re.search( r'^.*Published\s(\w+\s\d+\w*\s\d+)\sby.*', strPublisher).group(1) datepublished = parse(datepublished_str).isoformat() except: datepublished = datetime.datetime.now() return datepublished def parse(self, response): metacol = response.css('#metacol') converter = html2text.HTML2Text() converter.ignore_links = True data = GoodReadsBookItem( { 'bookTitle': self.clean_string(metacol.css( 'h1#bookTitle::text').extract_first()), 'bookSeries': self.clean_string(metacol.css( 'h2#bookSeries::text').extract_first()), 'authorURL': self.clean_string(metacol.css( '.authorName').attrib["href"]), 'authorName': self.clean_string(metacol.css( '.authorName > span:nth-child(1)::text') .extract_first()), 'description': converter.handle(metacol.css( '#descriptionContainer').extract_first()), 'ratingValue': self.clean_string(metacol.css( '#bookMeta > span:nth-child(2)::text') .extract_first()), 'ratingCount': self.clean_string(metacol.css( 'a.gr-hyperlink:nth-child(7) > ' 'meta:nth-child(1)') .attrib[ 'content']), 'reviewCount': self.clean_string(metacol.css( 'a.gr-hyperlink:nth-child(9) > ' 'meta:nth-child(1)') .attrib[ 'content']), 'bookFormat': self.clean_string(metacol.css( '#details > div:nth-child(1) >' ' span:nth-child(1)::text').extract_first()), 'numberOfPages': self.clean_string(metacol.css( '#details > div:nth-child(1) >' ' span:nth-child(2)::text').extract_first()) .replace( r' pages', ''), 'datePublished': self.split_published_date( self.clean_string(metacol.css( '#details > div:nth-child(2)') .extract_first())), 'isbn': self.clean_string(metacol.css( 'div.infoBoxRowItem:nth-child(2) >' ' span:nth-child(1) > span:nth-child(1)::text') .extract_first()) } ) if data['bookTitle'] != "": yield data else: pass
J’utilise ensuite un processus quasi identique pour extraire les données que celui que j’ai utilisé précédemment.
process = CrawlerProcess({ 'AUTOTHROTTLE_ENABLED': 'True', 'AUTOTHROTTLE_START_DELAY': 1.0, 'HTTPCACHE_ENABLED': False, 'COOKIES_ENABLED': False, 'DUPEFILTER_CLASS': 'scrapy.dupefilters.BaseDupeFilter', 'FEEDS': { 'goodreadsbooks_new.json': { 'format': 'json', 'encoding': 'utf8', 'store_empty': False, 'fields': None, 'indent': 4, 'item_export_kwargs': { 'export_empty_fields': True, } } } }) process.crawl(GoodReadsBookSpider) process.start() process.stop()
Une transformation profonde
Nous avons maintenant sous la main toutes les données que nous explorerons pour la suite de cet article. Certaines données sont manquantes, et c’est quelque chose qu’on doit accepter lorsqu’on obtient des données depuis le web.
Comme nous avons accès à la liste utilisée par Krause, nous pouvons aussi l’utiliser pour produire des données de référence.
La source est un fichier PDF. Je vais utiliser la librairie tabula pour extraire les tableaux sous la forme d’un fichier de données structurées CSV.
import tabula tabula.convert_into("raw_data/krausebooklist.pdf", "scraped_data/krausebooklist.csv", output_format="csv", pages='all')
Je vais maintenant les importer dans mon programme principal.
krausebooklist = pd.read_csv("scraped_data/krausebooklist.csv", dtype={'Published': int})
Voici un aperçu des données extraites:
J’en profite pour faire une première analyse de données avec cette source. Nous avons toutes les années de publication sous la main. Alors visualisons ces données pour avoir une idée de la distribution de la date de publication des ouvrages.
Je vais utiliser ici la librairie seaborn. C’est une interface à haut niveau (donc plus facile, mais moins polyvalente) à matplotlib.
plt.figure(figsize=(5,5)) sns.histplot(data=krausebooklist, x="Published", discrete=False, binrange=[1965,2025], binwidth=5) plt.tight_layout() plt.show()
Nettoyage des données
Nous allons maintenant nettoyer les données que nous avons téléchargées avec Scrapy précédemment. La première chose que l’on va observer, c’est que le champ de description contient presque tout le texte de la page. Ce qui nous intéresse se situe avant le texte Get A Copy
. Comme le texte est multiligne, nous allons aussi le concaténer à l’aide de la fonction join
.
J’assemble ces éléments dans une fonction qui extrait seulement la partie pertinente du texte contenu dans le champ description.
def extraire_debut_descr(str_desc): description = "".join(str_desc.split("\n")) try: desc = description.split("## Get A Copy")[0] if desc is None: desc_clean="None" else: desc_clean=desc except Exception as N: desc_clean = str(N) return desc_clean
Maintenant, pour effectuer le nettoyage, j’utilise la fonction apply qui s’applique à la Series goodreadsbooks['description']
, c’est-à-dire une colonne du DataFrame goodreadsbooks
.
J’exporte le résultat dans un fichier CSV et j’itère en ajoutant des consignes jusqu’à ce que le fichier de sortie me plaise. J’ouvre le CSV dans un éditeur qui se met à jour à chaque sauvegarde. Dans mon cas c’est Kate, l’éditeur de texte par défaut de l’envionnement KDE.
goodreadsbooks['description_clean']. \ to_csv("clean_data/goodreads_description_clean.csv")
Voici le résultat de la séquence de nettoyage:
goodreadsbooks['description_clean'] = \ goodreadsbooks['description'] \ .apply(lambda x: extraire_debut_descr(x)) \ .apply(lambda x: x.replace('\n',' ') \ .replace('_',' ') \ .replace('*',' ') \ .replace('...more',' ') \ .replace('—',', ') \ .replace('\\',' ') \ .replace('”','"') \ .replace('“','"') ) \ .apply(lambda x: re.sub("\-\s","-",x)) \ .apply(lambda x: re.sub("\s+"," ",x)) \ .apply(lambda x: re.sub("\.\s+$",". ",x)) \ .apply(lambda x: re.sub("\s*--\s*",", ",x)) \ .apply(lambda x: re.sub("\s+\.",'.',x)) \ .apply(lambda x: re.sub("[\"]{2,}",'"',x))
L’art de mettre des étiquettes sur la censure littéraire !
Nous allons maintenant entrer dans le vif du sujet, l’analyse de la censure littéraire. Je vais appliquer des techniques de linguistique sur le contenu des descriptions de livres. Pour ce faire, je vais utiliser la librairie d’analyse du langage naturel spaCy. Cet outil permet entre autres d’utiliser des modèles statistiques et d’apprentissage automatique pour étiqueter des textes au niveau lexical.
Comme mon ordinateur de travail a un processeur graphique NVIDIA, je vais l’utiliser pour accélérer les traitements
spacy.prefer_gpu()
Je vais charger le modèle pour l’anglais qui utilise des mécanismes d’attention (transformateurs): en_core_web_trf
. C’est le modèle le plus précis disponible dans cette librairie.
nlp = spacy.load("en_core_web_trf")
Analyse syntaxique de base
Ce qui est bien avec spaCy, c’est qu’en chargeant les descriptions, l’analyse de base est effectuée automatiquement. Les phrases sont décomposées en jetons, et chacun d’entre eux se voient assigner plusieurs attributs:
- analyse morphosyntaxique (le type des mots dans la phrase, tel que les noms, les verbes, les adjectifs, …)
- graphe des dépendances (le rôle des mots, tel que sujet, complément direct, …)
- détection des entités nommées (les référents culturels et géographique, les évènements, les personnalités …)
- lemmatisation (la racine du mot, par exemple un verbe à l’infinitif ou un adjectif au masculin singulier).
docs = nlp.pipe(goodreadsbooks['description_clean'])
Je définis les étiquettes morphosyntaxiques que je souhaite conserver pour mon analyse. Ce sont les mots qui ont une valeur sémantique dans une phrase.
- Nom propre (PROPN)
- Adjectif (ADJ)
- Nom commun (NOUN)
- Verbe (VERB)
Filtrage des mots (oui, encore de la censure littéraire !)
pos_tag = ['PROPN', 'ADJ', 'NOUN', 'VERB']
En traitement du langage naturel, on enlève généralement les mots qui ne sont pas porteurs de sens, appelés les « stopwords ». On va aussi éliminer la ponctuation qui séparent les groupes syntaxiques, tels que les virgules, les points, les points-virgules et les deux-points.
En anglais, la principale ponctuation à l’intérieur des mots se trouve dans les abréviations de certains verbe, la négation et le possessif, et sont situées à la fin du mot. Ça cause beaucoup moins de problème aux analyseurs syntaxiques.
Quand je fais ce genre de traitements en Python, j’accumule les résultats dans une liste de listes, puis je convertis le tout dans un DataFrame pour Pandas
i=0 docs_list = [] docs_keyword = [] for doc in docs: j=0 keyword = [] docs_list.append(doc) for token in doc: if token.is_stop or token.text in punctuation: continue if token.pos_ in pos_tag: keyword.append({ 'token': j, 'text': token.text, 'pos': token.pos_, 'lemma': token.lemma_, 'tag': token.tag_, 'dep': token.dep_ }) j=j+1 docs_keyword.append({'document':i, 'keywords':keyword}) i=i+1
On peut voir ici que le traitement est effectué sur le GPU, que j’ai capturé à l’aide de l’outil nvtop.
Je vais maintenant extraire les données sous la forme d’une table facile à manipuler.
- Je convertis la liste de jetons pour chacune des descriptions en une seule colonne, qui contiendra une ligne pour chacun des jetons sous la forme d’un dictionnaire (fichier JSON)
docs_keyword_exp = pd.DataFrame(docs_keyword).explode('keywords')
2. Je convertis les dictionnaires en colonnes avec la fonction json_normalize
de Pandas. Ensuite, j’effectue quelques manipulations additionnelles au niveau des indices du tableau, pour ramener le numéro du document source.
docs_keyword_exp2 = pd.json_normalize(docs_keyword_exp['keywords']) docs_keyword_exp2.reset_index(inplace=True) del docs_keyword_exp2["index"] docs_keyword_exp2['document'] = docs_keyword_exp['document'].values
3. Enfin, je convertis le lemme et le texte en minuscules.
docs_keyword_exp2['lemma'] = \ docs_keyword_exp2['lemma'].apply(lambda x: str(x).lower()) docs_keyword_exp2['text'] = \ docs_keyword_exp2['text'].apply(lambda x: str(x).lower())
On obtient enfin un tableau sur la forme souhaitée, qui nous permettra de faire des analyses et de la visualisation.
Chaque document et chaque jeton est numéroté, et nous avons tous les attributs à portée de main. Tu remarqueras probablement qu’il nous manque un peu plus de 50 documents. En fait, il faut compter les erreurs de téléchargement et l’absence de description de certains livres. Dans le cadre d’une analyse plus approfondie, j’aurais probablement utilisé une source de données additionnelles. Mais, pour les besoin de cette analyse, comme nous avons plus de 90% des documents concernés, je considère que c’est suffisamment représentatif pour ne pas nous en soucier davantage.
L’amour et l’analyse de la censure littéraire, ensemble sur un nuage ?
Nous allons maintenant créer une visualisation de données pour identifier les mots les plus fréquents parmi les descriptions. Celle-ci se nomme un nuage de mots.
Créons un dictionnaire de fréquence des mots, la structure de données nécessaire pour obtenir un nuage de mots.
word_frequency = docs_keyword_exp2['lemma'] \ .value_counts() \ .to_dict()
On peut maintenant générer directement un nuage de mots à l’aide de la librairie wordcloud
.
wordcloud = WordCloud(width=512, height=512, max_font_size=72, max_words=1000, background_color="#ece9dc") \ .generate_from_frequencies(word_frequency)
On utilise ensuite la librairie pyplot
pour faire l’affichage. Comme elle a déjà été chargée lors de l’utilisation de Seaborn, nous n’avons pas besoin de la charger à nouveau.
plt.figure(figsize=(10, 10)) plt.imshow(wordcloud, interpolation='bilinear') plt.axis("off") plt.savefig("out_img/wordcloud_words_regular.png", format="png") plt.show()
Les groupes de mots
Un des problèmes avec la visualisation par nuage de mots, lorsqu’elle est basée sur des jetons représentant des mots seuls, c’est qu’il manque un peu de contexte. On va alors préférer refaire l’analyse, mais en utilisant les groupes de mots identifiés par l’analyse de dépendances syntaxiques. Ces groupes sont appelés des noun_chunks
. On reprend donc notre analyse avec spaCy. La différence ici, c’est qu’un groupe de mots possède aussi une racine (chunk.root
), et que c’est celle-là dont on va extraire certains attributs.
docs2 = nlp.pipe(goodreadsbooks['description_clean']) i=0 docs_chunks = [] docs2_list = [] for doc in docs2: j=0 keyword = [] docs2_list.append(doc) for chunk in doc.noun_chunks: if(chunk.root.text in punctuation): continue if(chunk.root.pos_ in pos_tag): keyword.append({ 'token': j, 'text': chunk.text, 'root': chunk.root.text, 'pos': chunk.root.pos_, 'lemma': chunk.root.lemma_, 'tag': chunk.root.tag_, 'dep': token.dep_ }) j=j+1 docs_chunks.append({'document':i, 'keywords':keyword}) i=i+1
On effectue maintenant des transformations similaires.
docs_chunks_exp = \ pd.DataFrame(docs_chunks).explode('keywords').reset_index() docs_chunks_exp2 = pd.json_normalize(docs_chunks_exp['keywords']) docs_chunks_exp2['document'] = docs_chunks_exp['document'].values docs_chunks_exp2.reset_index(inplace=True) del docs_chunks_exp2["index"] docs_chunks_exp2['lemma'] = docs_chunks_exp2['lemma'] \ .apply(lambda x: str(x).lower()) docs_chunks_exp2['root'] = docs_chunks_exp2['root'] \ .apply(lambda x: str(x).lower()) docs_chunks_exp2['text'] = docs_chunks_exp2['text'] \ .apply(lambda x: str(x).lower())
Voici le tableau qui résulte de cette transformation. On remarque que tous les étiquettes morpho-syntaxiques sont des noms (NOUN) et que toutes les relations sont des racines (ROOT).
En reprenant le même code que précédemment, on peut aussi construire un nuage basé sur les groupes de mots.
Aller au cœur des mots
Tu remarqueras certainement une thématique centrale à ce vocabulaire qui provient des descriptions des livres. Ça parle de relations amoureuses à l’adolescence et de santé sexuelle. Tout simplement. C’est de ça que les sénateurs conservateur ont si peur ! La censure littéraire demandée est en réalité un caprice de mononc’ qui peine à cacher sa misogynie et son homophobie.
Au moment d’écrire ce texte, nous sommes à une semaine de la Saint-Valentin, une fête souvent significative pour les adolescents qui en sont à leurs premières aventures amoureuses.
Alors, je vais m’amuser a ajouter une thématique à cette visualisation.
La librairie wordcloud
permet d’utiliser un masque. C’est à dire une image sur laquelle le nuage de mots tire ses couleurs et son contour. Nous utiliserons donc une image de coeur pour les besoins de la cause !
Pour ce faire, nous allons d’abord importer l’image sous la forme d’une matrice.
my_mask = np.array(Image.open("mask_img/heart.png"))
Puis, nous allons régénérer notre nuage de mots. Nous traitons d’abord le contour avec l’argument mask
de la fonction WordCloud
. Puis, nous appliquons les couleurs extraites du masque à l’aide de la fonction ImageColorGenerator
et de la méthode recolor
.
wordcloud_mask = WordCloud(mode="RGB", max_words=1000, mask=my_mask) \ .generate_from_frequencies(word_frequency_chunks) image_colors = ImageColorGenerator(my_mask) plt.figure(figsize=(10, 10)) plt.imshow(wordcloud_mask.recolor(color_func=image_colors), interpolation='bilinear') plt.axis("off") plt.savefig("out_img/wordcloud_heart.png", format="png") plt.show()
Voici enfin le résultat obtenu !
J’espère que tu as apprécié cet article un peu différent des autres !
Si tu souhaites lire l’article de blog sur mon analyse de la censure littéraire au Texas, en lien avec ce billet technique, c’est ici !
DéconstruIT ⛏️🧱
Mastermind techno rebelle
Un accompagnement de six mois pour enfin réaliser tes projets technos tout en développant ton autonomie.
Libérons ensemble ton entreprise, ta clientèle et toi de l’oppression numérique.
Travaillons de manière durable et alignée sur tes valeurs dans un environnement sécuritaire, intime et engagé.
Tout en ayant bien du fun !