IR - 11 - Lab I
Lecture Info
Data:
Sito corso: link
Introduzione: In questa prima lezione di laboratorio abbiamo visto del codice python per la costruzione di due indici inversi: uno non posizionale e l'altro posizionale.
1 Unigram Inverted Index
In questo esempio abbiamo indicizzato un insieme di documenti utilizzando un indice inverso non posizionale per poi implementare un una nostra query language. Il codice è stato in parte preso dalla seguente github repo, ed è disponibile sia come jupyter notebook, e sia come normale file python.
1.1 Imports
Le librerie utilizzate sono le seguenti
from nltk.corpus import stopwords from nltk.tokenize import word_tokenize from nltk.stem import PorterStemmer import nltk import os import string import numpy as np import copy import pandas as pd import pickle
1.2 Data Extraction
Il dataset utilizzato è il 20 NewsGroup, che rappresenta una collezione di quasi \(20.000\) articoli di news partizionati in modo uniforme rispetto a \(20\) diverse categorie. Per scaricarlo è possibile eseguire il seguente codice bash
curl http://qwone.com/~jason/20Newsgroups/20news-19997.tar.gz -o 20_newsgroups tar -xvf 20_newsgroups
Il codice python che segue deve essere eseguito all'interno della
stessa cartella in cui è stato scaricato il dataset. Si assume
inoltre che la cartella contenente il dataset è chiamata
20_newsgroups
. Tale codice non fa altro che inserire in una lista
le paths di tutti i documenti da indicizzare.
title = "20_newsgroups" paths = [] for (dirpath, dirnames, filenames) in os.walk(str(os.getcwd()) + '/' + title + '/'): for i in filenames: paths.append(str(dirpath)+str("/") + i)
Una volta eseguito nella lista paths abbiamo quindi tutte le paths
salvate nella lista paths
.
print(paths[0]) # home/leo/repos/ir/private/code/lab_1/20_newsgroups/comp.graphics/38704 print(paths[1]) # /home/leo/repos/ir/private/code/lab_1/20_newsgroups/comp.graphics/38352
1.3 Pre-Processing
Andiamo adesso a definire la catena di preprocessamento che vogliamo applicare al contenuto di ciascun documento prima di andarlo ad indicizzare.
def preprocess(data, query): if not query: data = remove_header(data) data = convert_lower_case(data) data = convert_numbers(data) data = remove_punctuation(data) # remove comma seperately data = remove_stop_words(data) data = remove_apostrophe(data) data = remove_single_characters(data) data = stemming(data) return data
Notiamo come l'ordine in cui effettuiamo questi steps è importante. Segue quindi l'implementazione di ciascun modulo.
1.3.1 Remove Header
Ogni articolo presente nel dataset è formato da una serie di
metadati iniziali. Per separare i metadati dal contenuto effettivo
del testo sono utilizzati due newline characters \n
.
Ad esempio, l'articolo 20_newsgroups/comp.graphics/37261
ha il
seguente formato
La rimozione dell'header viene quindi effettuata con la seguente funzione.
def remove_header(data): try: ind = data.index('\n\n') data = data[ind:] except: print("No Header") return data
1.3.2 Convert Lower Case
def convert_lower_case(data): return np.char.lower(data)
1.3.3 Convert Numbers
def convert_numbers(data): data = np.char.replace(data, "0", " zero ") data = np.char.replace(data, "1", " one ") data = np.char.replace(data, "2", " two ") data = np.char.replace(data, "3", " three ") data = np.char.replace(data, "4", " four ") data = np.char.replace(data, "5", " five ") data = np.char.replace(data, "6", " six ") data = np.char.replace(data, "7", " seven ") data = np.char.replace(data, "8", " eight ") data = np.char.replace(data, "9", " nine ") return data
1.3.4 Remove Punctuaction
def remove_punctuation(data): symbols = "!\"#$%&()*+-./:;<=>?@[\]^_`{|}~\n" for i in range(len(symbols)): data = np.char.replace(data, symbols[i], ' ') data = np.char.replace(data, " ", " ") data = np.char.replace(data, ',', '') return data
1.3.5 Remove Stop Words
La lista di stop words utilizzata ci è fornita dalla libreria
nltk
. Per poterla utilizzare è necessario eseguire il seguente
codice python, che scaricherà le stopwords nella cartella
$HOME/nltk_data/corpus/
.
import nltk nltk.download('downloads')
Per fare un esempio alcune delle stopwords presenti nella lista inglese sono le seguenti
stop_words = stopwords.words('english') print(stop_words[:10]) # ['i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', "you're"]
Il codice che processa il testo per rimuovere le stop words è quindi il seguente
def remove_stop_words(data): stop_words = stopwords.words('english') words = word_tokenize(str(data)) new_text = "" for w in words: if w not in stop_words: new_text = new_text + " " + w return np.char.strip(new_text)
1.3.6 Remove Apostrophe
def remove_apostrophe(data): return np.char.replace(data, "'", "")
1.3.7 Remove Single Characters
Qui utilizziamo la funzione word_tokenize()
che ci viene offerta
dalla libreria nltk
. Maggiori informazioni rispetto alla relativa
implementazione possono essere trovate qui.
def remove_single_characters(data): words = word_tokenize(str(data)) new_text = "" for w in words: if len(w) > 1: new_text = new_text + " " + w return np.char.strip(new_text)
1.3.8 Stemming
Nuovamente, il PorterStemmer()
ci viene offerto dalla libreria
nltk
. Maggiori informazioni possono essere trovate qui. Per far
funzionare lo stemming dobbiamo eseguire il seguente codice
python.
import nltk nltk.download('punkt')
def stemming(data): stemmer= PorterStemmer() tokens = word_tokenize(str(data)) new_text = "" for w in tokens: new_text = new_text + " " + stemmer.stem(w) return np.char.strip(new_text)
1.4 Index Construction
Per quanto riguarda la costruzione dell'indice, l'idea è quella di
utilizzare i data frame offerti dalla libreria pandas
. Per meggiori
informazioni è possibile utilizzare il seguente link Pandas DataFrame
Il codice che si occupa della creazione della struttura dati principale è il seguente
doc = 0 postings = pd.DataFrame() for path in paths: # open file, get text, close file file = open(path, 'r', encoding='cp1250') text = file.read().strip() file.close() # preprocess text preprocessed_text = preprocess(text, False) # Index each token in the document in our data structure. tokens = word_tokenize(str(preprocessed_text)) for token in tokens: if token in postings: p = postings[token][0] # get posting list associated with token p.add(doc) # add docID to posting list postings[token][0] = p # update posting list associated with token else: # create new posting list for token with a single docID postings.insert(value=[{doc}], loc=0, column=token) # increment docID variable doc += 1
La struttura creata è quella descritta in una delle prime lezioni. Per analizzarla meglio possiamo eseguire i seguenti comandi python
print(postings.info(verbose=True)) # class pandas.core.frame.DataFrame # RangeIndex: 1 entries, 0 to 0 # Data columns (total 485 columns): # # Column Dtype # --- ------ ----- # 0 license object # 1 privat object # 2 other object # 3 news object # 4 keep object # 5 dont object # 6 apparantli object # 7 success object # 8 determin object # 9 sell object # 10 softwar object # 11 concept object # ...
Ogni riga della tabella è poi formata da una lista di docIDs nel seguente modo
print(postings['zero']) # 0 {0, 2, 3, 6, 7, 8, 9} # Name: zero, dtype: object
Per salvare sul disco la struttura dati in modo da mantenere un
certo livello di persistenza possiamo utilizzare la libreria python
pickle
nel seguente modo
# write to file postings.to_pickle(title + "_unigram_postings") # read from file postings = pd.read_pickle(title + "_unigram_postings")
Infine, per interagire con la struttura dati definiamo le seguenti due funzioni:
La funzione get_posting(word) non fa altro che ritornare la posting list associata alla parola
word
:def get_posting(word): if word in postings: return postings[word][0] else: return []
La funzione print_word_postings(word) stampa la posting list associata alla parola
word
:def print_word_postings(word): preprocessed_word = str(preprocess(word, True)) print(preprocessed_word) postings = get_posting(preprocessed_word) print("Document Frequency:",len(postings)) print("Postings List:",postings)
1.5 Query Language
Andiamo adesso ad implementare la nostra query language. Le query che vogliamo eseguire sono semplici query booleane. Segue qualche esempio
\(\text{place}\)
\(\text{authority}\)
\(\text{place AND NOT authority}\)
\(\text{place OR authority}\)
\(\text{place OR authority AND NOT welcome}\)
La funzione principale che si occupa dell'esecuzione delle nostre query è la seguente.
def execute_query(query): command_tokens, query_tokens = generate_query_tokens(query) tup = generate_not_tuple(query_tokens, command_tokens) print("\nCommands:", command_tokens) print("\nQuery Words:", query_tokens) print("\nNot Tup:", len(tup)) final_set = binary_operations(query_tokens, command_tokens, tup) final_set = sorted(final_set) print("\nFinal Set:",final_set) print("-----------------------------------------") return final_set
ed è così descritta:
La chiamata alla funzione generate_query_tokens() serve per leggere la query e spezzarla in due sequenze di tokens: i
command_tokens
, che sono i token relativi ai comandi tipo \(\text{AND, OR, NOT}\), e iquery_tokens
, che sono i termini presenti nella query tipo \(\text{place, authority}\). Tale funzione è implementata nel seguente mododef generate_query_tokens(query): query = query.lower() tokens = word_tokenize(query) command_tokens = [] query_tokens = [] for t in tokens: if t not in ['and', 'or', 'not']: processed_token = preprocess([t], True) query_tokens.append(str(processed_token)) else: command_tokens.append(t) return command_tokens, query_tokens
La funzione get_not_tuple() invece esegue tutti i comandi della forma \(\text{NOT} \langle \text{token} \rangle\) e ritorna in un insieme i risultati dei comandi eseguiti indicizzati in base alla posizione del comando rispetto a tutti i comandi. Tale funzione è implementata nel seguente modo
def generate_not_tuple(query_tokens, command_tokens): tup = set() # as long as there are NOT command to execute while 'not' in commands: i = command_tokens.index('not') word = query_tokens[i] word_postings = get_not(word) # get result of NOT <word> tup.update(word_postings) command_tokens.pop(i) # remove command # Replace the word with an ID to signal that it has been # processed # # NOTE: numbers have been already removed in the preprocessing # stage. query_words[i] = i print("\nAfter Not Processing:",commands, query_words) return tup
Tale funzione esegue a sua volta la funzione get_not(word), che non fa altro che calcolare la posting list contenente il \(\text{docID}\) di tutti i documenti che non contengono la parola \(\text{word}\). Tale funzione è implementata come segue
def get_not(word): a = get_posting(word) b = set(range(len(paths))) return b.difference(a)
dove
paths
era la lista che ci eravamo calcolati inizialmente e che conteneva le paths di tutti i documenti che abbiamo indicizzato.Infine, la funzione binary_operations() viene utilizzata per eseguire i restanti comandi e calcolare il risultato finale. Tale funzione è implementata come segue
def binary_operations(query_tokens, command_tokens, tup): if not query_tokens[0]: return tup a = get_posting(query_tokens[0]) query_tokens.pop(0) for i in range(len(command_tokens)): if type(query_tokens[i])==int: b = tup else: b = get_posting(query_tokens[i]) if command_tokens[i] == 'and': a = a.intersection(b) elif command_tokens[i] == 'or': a = a.union(b) else: print("Invalid Command") return a
1.5.1 Examples
Andando ad eseguire alcune query esempio otteniamo i seguenti
risultati. Notiamo che le query non sono state eseguite su tutto
il dataset, ma solo su sui primi 50 documenti della sottocategoria
comp.graphics
.
execute_query("place") # ----------------------------------------- # Command Tokens: [] # # Query Tokens: ['place'] # # Not Tup: 0 # # Final Set: [40] # -----------------------------------------
execute_query("authority") # ----------------------------------------- # # Command Tokens: [] # # Query Tokens: ['author'] # # Not Tup: 0 # # Final Set: [1, 15, 34] # -----------------------------------------
execute_query("welcome") # ----------------------------------------- # # Command Tokens: [] # # Query Tokens: ['welcom'] # # Not Tup: 0 # # Final Set: [20, 42] # -----------------------------------------
execute_query("place or authority") # ----------------------------------------- # # Command Tokens: ['or'] # # Query Tokens: ['place', 'author'] # # Not Tup: 0 # # Final Set: [1, 15, 34, 40] # -----------------------------------------
execute_query("place and not authority") # ----------------------------------------- # # After Not Processing: ['and'] ['place', 1] # # Command Tokens: ['and'] # # Query Tokens: ['place', 1] # # Not Tup: 997 # # Final Set: [40] # -----------------------------------------
execute_query("place or authority and not welcome") # ----------------------------------------- # # After Not Processing: ['or', 'and'] ['place', 'author', 2] # # Command Tokens: ['or', 'and'] # # Query Tokens: ['place', 'author', 2] # # Not Tup: 998 # # Final Set: [1, 15, 34, 40] # -----------------------------------------
1.6 Exercises
Seguono alcuni possibili esercizi che consistono nel modificare il codice appena descritto:
Cambiare l'implementazione del codice che costruire le varie posting lists in modo tale da avere delle posting lists ordinate rispetto ai vari \(\text{docIDs}\).
Ottimizzare l'implementazione dell'operazione \(\text{NOT}\).
Implementare un concetto di priorità tra le operazioni utilizzando le parentesi.
2 Positional Index
In quest'altro esempio abbiamo invece indicizzato un insieme di documenti utilizzando un indice posizionale, in modo da poter rispondere a query formate da più parole della forma \(\text{Tor Vergata}\). Nuovamente, Il codice è stato in parte preso dalla seguente github repo, ed è disponibile sia come jupyter notebook e sia come normale file python.
Notiamo che il codice utilizzato per questo esempio è molto simile a quello utilizzato per il precedente. Al posto di riportare tutto quindi riportiamo solo le differenze. Ad esempio gli steps di importazione, data extraction e pre-processing sono essenzialmente gli stessi. Le differenze iniziano a partire dalla costruzione dell'indice.
2.1 Positional Index Construction
La costruzione dell'indice questa volta viene nel seguente modo
postings = pd.DataFrame() frequency = pd.DataFrame() doc = 0 for path in paths: file = open(path, 'r', encoding='cp1250') text = file.read().strip() file.close() preprocessed_text = preprocess(text, False) tokens = word_tokenize(str(preprocessed_text)) pos = 0 for token in tokens: if token in postings: p = postings[token][0] k = [a[0] for a in p] # get all docIDs if doc in k: for a in p: if a[0] == doc: a[1].add(pos) else: p.append([doc,{pos}]) frequency[token][0] += 1 else: postings.insert(value=[[[doc, {pos}]]], loc=0, column=token) frequency.insert(value=[1], loc=0, column=token) pos += 1 doc += 1
Per quanto riguarda l'interazione con la struttura dati abbiamo le seguenti funzioni utili
def get_word_postings(word): preprocessed_word = str(preprocess(word, True)) term_frequency = frequency[preprocessed_word][0] term_postings = postings[preprocessed_word][0] return term_frequency, term_postings
def get_posting(word): if word in postings: return postings[word][0] else: return []
def get_positions(posting_values, doc): for value in posting_values: if value[0] == doc: return posting_value[1] return {}
2.2 Query Language
In questo esempio siamo interessati ad implementare un linguaggio di query che ci permette di rispondere a query del tipo
\(\text{Los Angeles}\)
\(\text{Tor Vergata}\)
\(\text{Mountain View}\)
Per fare questo utilizziamo la seguente funzione
def run_query(query): processed_query = preprocess(query, True) query_tokens = word_tokenize(str(processed_query)) if len(query_tokens) == 1: query_postings = get_posting(query_tokens[0]) result = [a[0] for a in query_postings] return result init_word = query_tokens[0] init_matches = gen_init_set_matchings(init_word) query_tokens.pop(0) total_matched_docs = match_positional_index(init_matches, query_tokens) total_matched_docs = sorted(total_matched_docs) return total_matched_docs
tale funziona controlla se la query ha un solo termine. In quel
caso calcola tramite la funzione get_posting()
quali sono i
\(\text{docIDs}\) che contengono tale termine e gli ritorna come
risultato. Altrimenti, se la funzione ha più una parola, calcola
tutti i \(\text{docIDs}\) che contengono almeno il primo termine
tramite la funzione gen_init_set_matchings()
, che è implementata
come segue
def gen_init_set_matchings(word): init = [] word_postings = get_posting(word) for word_posting in word_postings: for positions in word_posting[1]: init.append((word_posting[0], positions)) return init
tale funzione non fa altro che ritornare una lista di coppie di
elementi della forma (docID, pos)
.
Per finire viene poi chiamata la funzione match_positional_index
,
che utilizza la lista di coppie init_matches
e i restanti
query_tokens
per calcolare quali sono i \(\text{docIDs}\) che
contengono esattamente tutti i token della query nel giusto
ordine. Una possibile implementazione per tale funzione è la
seguente
def match_positional_index(init, query_tokens): matched_docs = [] for p in init: doc, pos = p[0], p[1] count = 0 for query_token in query_tokens: pos = pos+1 query_token_pos = get_posting(query_token) docs_list = [z[0] for z in query_token_pos] if doc in docs_list: doc_positions = get_positions(query_token_pos, doc) if pos in doc_positions: count += 1 else: count += 1 break if count == len(query_tokens): matched_docs.append(p[0]) return set(matched_docs)
2.2.1 Examples
A seguire riportiamo alcuni esempi di esecuzione (alcuni risultati sono stati tagliati). Come prima cosa andiamo ad analizzare le posting lists dei termini \(\text{Mountaing}\) e \(\text{View}\).
get_word_postings("Mountain") # mountain # Document Frequency: 12 # Postings List: [[52, {2044, 3092, 5006}], # [179, {861, 669}], # [302, {954}], # [371, {403}], # [428, {2174}], # [430, {159}], # [472, {388}], # [573, {2174}], # [634, {48}], # [860, {4953, 3039, 1991}], # [919, {839}], # [968, {20}]]
get_word_postings("View") # view # Document Frequency: 68 # Postings List: [[0, {36}], # [2, {88}], # [51, {19}], # [52, {2032, 2705, 3093, 5007}], # [107, {330, 837, 214, 311}], # [113, {672, 258, 359, 625, 342, 283, 253}]]
Procediamo quindi con l'esecuzione di tre query. Le prime due sono relative ai singoli termini analizzati in precedenza, mentre la terza query trova tutti i documenti che contengono la combinazione \(\text{Mountain View}\).
run_query("Mountain") # mountain # Query tokens: ['mountain'] # Total Document Mathces: [52, 179, 302, 371, # 428, 430, 472, 573, # 634, 860, 919, 968]
run_query("View") # view # Query tokens: ['view'] # Total Document Mathces: [0, 2, 51, 52, 107, 113, 123, 125, 156, # 167, 169, 179, 181, 208, 258, 263, # 268, 302, 311, 318, 330, 364, 370, # 384, 428, 430, 446, 453, 461, 462, # 465, 475, 478, 496, 520, 558, 571, # 573, 581, 585, 652, 684, 689, 690, 708, # 715, 734, 752, 755, 763, 768, 790, 805, # 811, 822, 848, 860, 887, 890, 891, 905, # 920, 927, 930, 958, 973, 975, 992]
run_query("Mountain View") # mountain view # Query tokens: ['mountain', 'view'] # Initial Matches: [(52, 2044), (52, 3092), (52, 5006), # (179, 861), (179, 669), (302, 954), # (371, 403), (428, 2174), (430, 159), # (472, 388), (573, 2174), (634, 48), # (860, 4953), (860, 3039), (860, 1991), # (919, 839), (968, 20)] # Total Document Matches: [52, 179, 302, 428, 430, 573, 860]
2.3 Exercises
Seguono alcuni possibili esercizi che consistono nel modificare il codice appena descritto:
Cambiare l'implementazione del codice che costruire le varie posting lists in modo tale da avere delle posting lists ordinate rispetto ai vari \(\text{docIDs}\).
Ottimizzare l'implementazione dell'operazione merge che viene effettuata all'interno della funzione
match_positional_index()
.Integrare gli indici posizinali con il linguaggio di query utilizzato nel precedente esempio.