Analisi quantitativa del testo – un prototipo

Nell’articolo “Analisi quantitativa del testo – un progetto” illustravo il progetto di un software con cui categorizzare in automatico un documento rispetto ad una serie di testi di riferimento.
Non si tratterebbe di un motore di ricerca, dove lo spider va a cercare informazioni sulla risorsa, ma al contrario di un sistema per cui è la risorsa a descriversi ad un ente che poi trae le sue conclusioni e attribuisce alla risorsa alcune etichette.

Ora comincio a scrivere un prototipo di questo progetto in PHP, in attesa di creare un vero progetto open source.

1. L’idea.

Innanzitutto mi piacerebbe se tutti i testi che sono da categorizzare andassero ad alimentare il corpus dei documenti di riferimento. In questo modo il corpus crescerebbe al diffondersi del servizio di categorizzazione: più testi vengono categorizzati, più testi finiscono nel corpus.

Sapendo in cosa consiste l’analisi statistica, poi, i client possono inviare al servizio di categorizzazione solamente dei dati statistici semi-lavorati, senza necessità di inviare tutto il testo.

Un server riceverebbe quindi da ogni client una serie di dati statistici che riguardano un testo (identificabile in maniera univoca, cosa che non è facile da ottenere); il server controllerebbe se dispone già dei dati statistici di quel testo, e in caso contrario li confronterebbe rispetto a tutti gli altri dati statistici di tutti gli altri testi di cui dispone (il suo corpus, in costante crescita). Il risultato del confronto è una serie di dati aggregati che vanno restituiti al client.

Il client, a questo punto, saprebbe calcolare la distanza che passa tra il testo che desidera categorizzare e il corpus di riferimento (e, volendo, potrebbe cancellare i dati statistici semi-lavorati di partenza). Ogni distanza significativa rappresenterebbe una voce caratteristica di quel testo.

2. La struttura dei dati.

CREATE DATABASE `tao` /*!40100 DEFAULT CHARACTER SET utf8 */;

DROP TABLE IF EXISTS `tao`.`cont`;
CREATE TABLE  `tao`.`cont` (
  `content_id` int(10) unsigned NOT NULL auto_increment,
  `title` varchar(45) NOT NULL,
  `author_id` int(10) unsigned NOT NULL,
  `isbn` varchar(17) NOT NULL,
  `mod_date` datetime NOT NULL,
  PRIMARY KEY  (`content_id`),
  KEY `Date` (`mod_date`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `tao`.`c1`;
CREATE TABLE  `tao`.`c1` (
  `content_id` int(10) unsigned NOT NULL,
  `record_id` int(10) unsigned NOT NULL auto_increment,
  `word1` varchar(45) NOT NULL,
  PRIMARY KEY USING BTREE (`content_id`,`record_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `tao`.`c2`;
CREATE TABLE  `tao`.`c2` (
  `content_id` int(10) unsigned NOT NULL,
  `record_id` int(10) unsigned NOT NULL auto_increment,
  `word1` varchar(45) NOT NULL,
  `word2` varchar(45) NOT NULL,
  PRIMARY KEY USING BTREE (`content_id`,`record_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `tao`.`c3`;
CREATE TABLE  `tao`.`c3` (
  `content_id` int(10) unsigned NOT NULL,
  `record_id` int(10) unsigned NOT NULL auto_increment,
  `word1` varchar(45) NOT NULL,
  `word2` varchar(45) NOT NULL,
  `word3` varchar(45) NOT NULL,
  PRIMARY KEY USING BTREE (`content_id`,`record_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `tao`.`o1`;
CREATE TABLE  `tao`.`o1` (
`content_id` int(10) unsigned NOT NULL,
`word1` varchar(45) NOT NULL,
`count` int(10) unsigned NOT NULL,
`percentage` int(10) unsigned NOT NULL,
PRIMARY KEY USING BTREE (`content_id`,`word1`),
KEY `Sum` (`count`),
KEY `Perc` (`percentage`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `tao`.`o2`;
CREATE TABLE  `tao`.`o2` (
`content_id` int(10) unsigned NOT NULL,
`word1` varchar(45) NOT NULL,
`word2` varchar(45) NOT NULL,
`count` int(10) unsigned NOT NULL,
`percentage` int(10) unsigned NOT NULL,
PRIMARY KEY USING BTREE (`content_id`,`word1`,`word2`),
KEY `Sum` (`count`),
KEY `Perc` (`percentage`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `tao`.`o3`;
CREATE TABLE  `tao`.`o3` (
`content_id` int(10) unsigned NOT NULL,
`word1` varchar(45) NOT NULL,
`word2` varchar(45) NOT NULL,
`word3` varchar(45) NOT NULL,
`count` int(10) unsigned NOT NULL,
`percentage` int(10) unsigned NOT NULL,
PRIMARY KEY USING BTREE (`content_id`,`word1`,`word2`,`word3`),
KEY `Sum` (`count`),
KEY `Perc` (`percentage`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

Le tabelle con il prefisso “c” contengono i dati statistici originali di ogni testo; le tabelle con il prefisso “o” contengono i risultati dei primi conteggi; il numero indica quante parole sono state considerate (n-gram).
La metafora utilizzata è quella della compilazione di un file: il punto “c” è il testo originale, il punto “o” è il conteggio della percentuale rispetto al totale interno di tutte le parole.

La tabella “cont” elenca tutti i dati relativi al testo originale (il cui contenuto è nella tabella “c” e il suo semi-lavorato nella tabella “o”), ma contiene anche dati relativi a raggruppamenti di testi: una riga sarà ad esempio relativa al contenuto “ALL” (raggruppamento obbligatorio e presente di default).

La tabella “cont”, nella nostra metafora della compilazione, funziona da “makefile”:

  • ad ogni modifica o inserimento di un testo (e relativo conteggio) ne va aggiornata la mod_date;
  • ad ogni modifica o inserimento di un testo (e relativo conteggio) vengono aggiornati il conteggio e la mod_date del contenuto “ALL”;
  • se la data del contenuto “ALL” è maggiore della data del testo in analisi è necessario aggiornare il confronto dei due valori percentuali.

Quest’ultimo controllo può essere lanciato all’apertura dell’applicazione, all’apertura di un singolo testo/contenuto oppure mediante un batch notturno.

3. Leggiamo un testo.

Innanzitutto è necessario caricare in memoria il testo da analizzare. Non si tratta, per ora, di realizzare un vero e proprio parser, bensì di preparare la struttura che ce lo renderà possibile:

<script language="php">

$dblink = mysql_connect( 'localhost', 'USERNAME', 'PASSWORD' );
mysql_select_db( 'DBNAME', $dblink );
if( ini_get( 'max_execution_time' ) )
{
	$time_out = ini_get( 'max_execution_time' );
} else {
	$time_out = 30;
}
ini_set( 'max_execution_time', 300 );

$fp = fopen( "TEXT_TO_PARSE.txt", "r" );
$content_id = 1;

$lines = array();
$current = 0;
while( $line = fgets( $fp ) )
{
	if( !ctype_space( $line[0] ) )
	{
		$current++;
		$lines[$current] = utf8_decode( trim( $line ) );
	}
}
fclose( $fp );

ini_set('max_execution_time', $time_out);
</script>

Apriamo in lettura un file (che deve essere UTF-8), creaiamo l’array $lines, e per ogni linea non vuota del file riempiamo una cella dell’array. In questo modo l’array $lines conterrà tutte le righe che compongono il testo da analizzare. Niente di difficile, sino a qui.

Ora possiamo dedicarci alle parole contenute nelle righe dell’array. Per analizzare le parole ci verrà comodo utilizzare un DataBase, strumento perfetto quando si tratta di contare, gestire e manipolare grandi quantità di dati. Il DB che utilizzerò è MySQL, che tra le altre cose mi permette di eseguire query in sintassi regexp (cosa che potrebbe sempre tornare utile, trattandosi di parole).

4. Salviamo le parole.

Per ora non stiamo ancora analizzando nulla: prima dobbiamo salvare le parole sulle apposite tabelle (la tabella “c” per le parole singole, la tabella “c” per le coppie di parole, la tabella “c” per le sequenze di tre parole, e così di seguito: quindi “c1″, “c2″, “c3″ eccetera).
Dopo, quando cominceremo a contare le occorrenze, avremo bisogno di sapere, per ogni parola, a quali comunità questa appartiene:

  • la lingua del testo in cui è apparsa
  • il genere letterario in cui è scritto il testo da cui proviene
  • gli argomenti che il testo da cui proviene affronta
  • la rete di citazioni in cui il testo da cui proviene si immerge
  • il testo da cui proviene

In questo modo ci sarà possibile fare analisi che scendano al livello di profondità che più ci interessa: sarà ad esempio possibile estrarre il lessico di frequenza di tutti i testi di argomento biblico oppure tutte le locuzioni più frequenti del testo X, eccetera.
Per creare un tabella che contenga tutte quelle informazioni di dettaglio (”cont”) sarà sufficiente fare riferimento al testo dal quale le parole provvengono: è il testo, difatti, che può essere caratterizzato da un genere, da alcuni argomenti, da uno o più autori, da una data di edizione… Sarà il testo a fare da crocevia per tutti i tipi di relazioni che potremmo voler stabilire tra le parole.

E quindi ora, nel momento di salvare le parole su di un DB, sarà sufficiente associarle al testo dal quale provvengono. Per il momento stabilisco che l’identificativo del testo sia arbitrariamente il numero 1.

5. Prima le parole singole…

Il codice PHP che scrive su “c1″ tutte le parole di un testo è il seguente:

<script language="php">
// @TODO: L'attuale check di eventuale presenza di questo content_id sul DB
// si basa su di un id che deve essere univoco!!
if( !mysql_fetch_array( mysql_query( "SELECT content_id FROM c1 WHERE content_id = '$content_id' limit 1" ) ) )
// @TODO: identificare i fine frase per permettere le analisi a livello
// più dettagliato che il testo. L'idea è di estrarre un array direttamente
// da tutto il testo originale scindendolo ogni volta che appare un punto:
// $fulltext = preg_replace( '/[\.\n]+/', '.', $fulltext );
// $lines = explode( ".", $fulltext );
{
	foreach( $lines as $key => $value)
	{
		$sql = "INSERT INTO c1 VALUES ";
		$value = preg_replace( '/[^\w]+/', ' ', $value );
		$value = preg_replace( '/[\s]+/', ' ', $value );
		$value = preg_replace( '/^[ ]+$/', '', $value );
		if ($value)
		{
			$line_arr = explode( " ", $value );
			foreach( $line_arr as $key2 => $item )
			{
				$sql .= "($content_id,0,'$item'),";
			}
		}
		mysql_query( substr( $sql, 0, -1 ) );
	}
	echo "EOF<br>";
} else {
	echo "Content already present.<br>";
}
</script>

Mi connetto al DB, prendo ogni cella dell’array $lines (per ora ipotizzo semplicemente di averlo: poi vedrò come passarmelo per davvero), accorpo tutte le diverse spaziature in uno spazio solo, creo un array che contenga tutte le parole e aggiungo le informazioni delle singole parole (ovvero id del testo e parola) alla query SQL che infine eseguo (per velocizzare l’esecuzione della query ne compongo una sola per tutta una riga).
Stampo a monitor un “End of File” che rassicuri l’utente.

6. …poi tutte le altre.

Il codice PHP che scrive su “c2″, “c3″, “c4″, “c5″ e “c6″ tutte le parole di un testo è il seguente:

if ($key-5 >= 0) {
 mysql_query("INSERT INTO corpustemp.c6 VALUES ('0', '".$line_arr[$key-5]."',
    '".$line_arr[$key-4]."', '".$line_arr[$key-3]."', '".$line_arr[$key-2]."',
    '".$line_arr[$key-1]."', '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c5 VALUES ('0', '".$line_arr[$key-4]."',
    '".$line_arr[$key-3]."', '".$line_arr[$key-2]."', '".$line_arr[$key-1]."',
    '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c4 VALUES ('0', '".$line_arr[$key-3]."',
    '".$line_arr[$key-2]."', '".$line_arr[$key-1]."', '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c3 VALUES ('0', '".$line_arr[$key-2]."',
    '".$line_arr[$key-1]."', '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c2 VALUES ('0', '".$line_arr[$key-1]."',
    '".$line_arr[$key]."')");
 } 

 elseif ($key-4 == 0) {
 mysql_query("INSERT INTO corpustemp.c5 VALUES ('0', '".$line_arr[$key-4]."',
    '".$line_arr[$key-3]."', '".$line_arr[$key-2]."', '".$line_arr[$key-1]."',
    '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c4 VALUES ('0', '".$line_arr[$key-3]."',
    '".$line_arr[$key-2]."', '".$line_arr[$key-1]."', '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c3 VALUES ('0', '".$line_arr[$key-2]."',
    '".$line_arr[$key-1]."', '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c2 VALUES ('0', '".$line_arr[$key-1]."',
    '".$line_arr[$key]."')");
 }

 elseif ($key-3 == 0) {
 mysql_query("INSERT INTO corpustemp.c4 VALUES ('0', '".$line_arr[$key-3]."',
    '".$line_arr[$key-2]."', '".$line_arr[$key-1]."', '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c3 VALUES ('0', '".$line_arr[$key-2]."',
    '".$line_arr[$key-1]."', '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c2 VALUES ('0', '".$line_arr[$key-1]."',
    '".$line_arr[$key]."')");
 }

 elseif ($key-2 == 0) {
 mysql_query("INSERT INTO corpustemp.c3 VALUES ('0', '".$line_arr[$key-2]."',
    '".$line_arr[$key-1]."', '".$line_arr[$key]."')");
 mysql_query("INSERT INTO corpustemp.c2 VALUES ('0', '".$line_arr[$key-1]."',
    '".$line_arr[$key]."')");
 }

 elseif ($key-1 == 0) {
 mysql_query("INSERT INTO corpustemp.c2 VALUES ('0', '".$line_arr[$key-1]."',
    '".$line_arr[$key]."')");
 }

Ok, mi rendo conto che questa parte del giocattolo è perfettibile (tanto per usare un eufemismo); ad ogni modo il risultato è tanti begli n-gram.

Al prossimo appuntamento con questo progetto comincerò ad ottenere tramite SQL dei semi-lavorati (da salvare poi nelle tabelle di tipo “o”) e a verificare se davvero il confronto tra i dati statistici di un testo e del suo corpus di riferimento fa emergere informazioni qualitative interessanti.

Grazie a chi è riuscito a seguirmi fino a qui… :)

Posted in codice, progetti Tagged: analisi del testo, categorizzazione, ipertesti, lessico di frequenza, n-gram, parole funzionali, profilazione delle parole, regexp, regular expression, tao

Share:

Add comment:

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

Chat Icon