Clustering textuel avec Oracle Text

La fouille de textes ou TDM (Text Data Mining) est un sujet qui revient régulièrement dans les projets d’extraction d’information à partir de sources non structurés.

Oracle Text est un moteur d’indexation textuelle disponible dans toutes les éditions du SGBD Oracle. Outre ses capacités d’analyse, ce moteur offre justement quelques fonctionnalités de datamining qui ont l’avantage énorme de ne pas dépendre de l’option Advanced Analytics. A ce titre, elles sont accessibles sur toutes les éditions du produit (SE, SE2, EE) sans licence additionnelle! Ce point apparaissait très clairement dans la documentation du produit jusqu’à la version 11.2:

Oracle Data Mining is an option of the Enterprise Edition of Oracle Database. To use Oracle Data Mining, you must have a license for the Data Mining option. To use Oracle Text and its data mining capabilities, you do not need to license the Data Mining option.

Mais la mention a été supprimée dans la documentation officielle 12c…

Cela m’a conduit à poster une question sur ODC et il m’a été confirmé par un Product Manager que la règle de licensing n’avait pas été changée!

Pour fêter ça, tentons un petit clustering k-means à partir de données textuelles!!! … et tant qu’à faire, utilisons une instance exécutant un moteur SE2!

🙂

 
SQL> SELECT version, edition FROM v$instance;

VERSION         EDITION
--------------- -------
12.1.0.2.0      SE

SQL>
SQL> SELECT banner
  2    FROM v$version
  3   WHERE banner LIKE 'Oracle%';

BANNER
--------------------------------------------------------------------------------
Oracle Database 12c Standard Edition Release 12.1.0.2.0 - 64bit Production

SQL>
SQL> SELECT comp_name, version, status
  2    FROM dba_registry
  3   WHERE comp_id = 'CONTEXT';

COMP_NAME            VERSION         STATUS
-------------------- --------------- ----------
Oracle Text          12.1.0.2.0      VALID

SQL>

Ici, je vais utiliser une série de recettes de cuisine et mon idée est de parvenir à diviser le jeu de données sur la caractéristique salé/sucré. Pour cela, j’ai fait un peu de webscrapping à partir du site www.lebruitdufrigo.fr. Les recettes y sont déjà classées par famille de plats et j’ai extrait les listes d’ingrédients des entrées, plats, desserts, tartes salées et pâtisseries.

Globalement, les catégories desserts et pâtisseries sont plutôt sucrées. Les autres recettes sont plutôt salées. Un clustering basé sur les ingrédients devrait pouvoir faire ressortir cette différence.

Les données sont accessibles ici au format Table Externe DataPump: recettes_ext

On peut les recharger simplement:

SQL> CREATE OR REPLACE DIRECTORY extdir AS '/tmp';

Directory created.

SQL>
SQL> CREATE TABLE recettes_ext
  2  (
  3      recette_id NUMBER,
  4      TYPE_PLAT VARCHAR2 (30),
  5      RECETTE VARCHAR2 (100),
  6      INGREDIENTS VARCHAR2 (1000)
  7  )
  8  ORGANIZATION EXTERNAL
  9      (TYPE oracle_datapump
 10       DEFAULT DIRECTORY extdir
 11       LOCATION ('recettes_ext.dp'));

Table created.

SQL>

La première étape consiste à « nettoyer » le texte. Oracle Text va prendre en charge la plupart des opérations de nettoyage (suppression des éléments de ponctuation et des mots vides etc…). En revanche, la présence de chiffres indiquant des quantités (ex. 100g, 30cl…) va devoir être traitée manuellement. En effet, dans notre contexte, ces derniers n’ont pas vraiment de valeur ajoutée. On va les filtrer à l’aide d’une expression régulière :

SQL> CREATE TABLE recettes_clean
  2  AS
  3      SELECT recette_id,
  4             CASE
  5                 WHEN type_plat IN ('Patisserie', 'Dessert') THEN 'Sucré'
  6                 ELSE 'Salé'
  7             END
  8                 categorie_plat,
  9             REGEXP_REPLACE (ingredients, '[[:digit:]]', '')
 10                 ingredients
 11        FROM recettes_ext;

Table created.

SQL>
SQL> ALTER TABLE recettes_clean
  2      ADD CONSTRAINT pk_recettes_clean PRIMARY KEY (recette_id);

Table altered.

SQL>

Les « mots vides » sont des mots très commun (comme des articles par exemple) qu’il convient de supprimer de l’analyse. Oracle propose une liste prédéfinie mais celle-ci a été enrichie à partir de sources trouvées sur internet: http://www.ranks.nl/stopwords/french

Les données sont accessibles ici (au format Table Externe DataPump): MOTSVIDES_EXTTAB

On peut alors crée une STOPLIST à partir de ces mots:

SQL> CREATE TABLE motsvides_exttab_dp
  2  (
  3      MOT VARCHAR2 (100)
  4  )
  5  ORGANIZATION EXTERNAL
  6      (TYPE oracle_datapump
  7       DEFAULT DIRECTORY extdir
  8       LOCATION ('motsvides_exttab.dp'));

Table created.

SQL>
SQL> BEGIN
  2      ctx_ddl.create_stoplist (stoplist_name   => 'RECETTE_STOPLIST',
  3                               stoplist_type   => 'BASIC_STOPLIST');
  4
  5      FOR rec IN (SELECT mot FROM motsvides_exttab_dp)
  6      LOOP
  7          ctx_ddl.add_stopword (stoplist_name   => 'RECETTE_STOPLIST',
  8                                stopword        => rec.mot);
  9      END LOOP;
 10  END;
 11  /

PL/SQL procedure successfully completed.

SQL>

On va y adjoindre une série d’abréviations des unités de mesure (g, kg, l etc…) et de termes de cuisine spécifiques (pincée, morceau, boite etc…): cuisine_stoplist_exttab

SQL> CREATE TABLE cuisine_stoplist_exttab
  2  (
  3      MOT VARCHAR2 (100)
  4  )
  5  ORGANIZATION EXTERNAL
  6      (TYPE oracle_datapump
  7       DEFAULT DIRECTORY extdir
  8       LOCATION ('cuisine_stoplist_exttab.dp'));

Table created.

SQL>
SQL> BEGIN
  2      FOR rec IN (SELECT mot FROM cuisine_stoplist_exttab)
  3      LOOP
  4          ctx_ddl.add_stopword (stoplist_name   => 'RECETTE_STOPLIST',
  5                                stopword        => rec.mot);
  6      END LOOP;
  7  END;
  8  /

PL/SQL procedure successfully completed.

SQL>
SQL> COMMIT;

Commit complete.

SQL>

Pour l’étape de tokenization, le découpage du texte utilisera les espaces comme séparateurs. C’est la méthode par défaut qui est employé par le BASIC_LEXER.

D’autre part, on va activer:

  • le paramètre BASE_LETTER qui permet de convertir les caractères accentués dans la lettre sous-jacente (é -> e).
  • la « racinisation » de manière à associer les déclinaisons d’un même terme (haché, hachés, hachée, hacher etc…): INDEX_STEMS
SQL> BEGIN
  2      ctx_ddl.create_preference ('RECETTE_LEXER', 'BASIC_LEXER');
  3      ctx_ddl.set_attribute ( 'RECETTE_LEXER', 'INDEX_STEMS', 'FRENCH');
  4      ctx_ddl.set_attribute ('RECETTE_LEXER', 'BASE_LETTER', 'YES');
  5  END;
  6  /

PL/SQL procedure successfully completed.

SQL>

Un index textuel (type CTXSYS.CONTEXT) est alors créé sur la liste des ingrédients des recettes :

SQL> CREATE INDEX recettes_ctxidx
  2      ON recettes_clean (ingredients)
  3      INDEXTYPE IS ctxsys.context
  4          PARAMETERS ('
  5      lexer           RECETTE_LEXER
  6      stoplist        RECETTE_STOPLIST
  7    ')
  8  /

Index created.

SQL>

Ce dernier va servir de support à l’algorithme k-means lors de la clusterisation. Celle-ci va être réalisée à l’aide de la procédure CTX_CLS.CLUSTERING. On précise le nombre de clusters souhaités (ici, 2) ainsi que le recours aux racines des termes (STEM_ON) à l’aide d’une préférence créée préalablement à l’aide du package CTX_DDL :

SQL> BEGIN
  2      ctx_ddl.create_preference ('TXT_CLUSTERING', 'KMEAN_CLUSTERING');
  3      ctx_ddl.set_attribute ('TXT_CLUSTERING', 'CLUSTER_NUM', '2');
  4      ctx_ddl.set_attribute ('TXT_CLUSTERING', 'STEM_ON', 'TRUE');
  5      ctx_cls.clustering (index_name    => 'RECETTES_CTXIDX',
  6                          docid         => 'RECETTE_ID',
  7                          doctab_name   => 'KMEANS_CLUST_RES',
  8                          clstab_name   => 'KMEANS_CLUST_DETAILS',
  9                          pref_name     => 'TXT_CLUSTERING');
 10  END;
 11  /

PL/SQL procedure successfully completed.

SQL>

La procédure de clustering produit deux tables (paramètres DOCTAB_NAME et CLSTAB_NAME). La première contient l’ID du cluster auquel chaque enregistrement est assigné.

On peut alors produire un tableau de contingence (à l’aide de l’opérateur SQL PIVOT) :

SQL> SELECT *
  2    FROM (SELECT categorie_plat, clusterid
  3            FROM recettes_clean a, KMEANS_CLUST_RES b
  4           WHERE a.recette_id = b.docid)
  5         PIVOT
  6             (COUNT (*) FOR clusterid IN (3 Groupe_1, 2 Groupe_2));

CATEGORIE_PLAT    GROUPE_1   GROUPE_2
--------------- ---------- ----------
Sucré                  181          7
Salé                    12        344

SQL>

La dichotomie est claire. Le cluster GROUPE_1 correspond manifestement aux recettes plutôt sucrées et le cluster GROUPE_2 correspond aux recettes salées. Seule 19 recettes (sur 544 – soit 3.5%) ne sont pas associées au groupe auquel on pourrait s’attendre!

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

÷ ten = one