Sunteți pe pagina 1din 15

Ministerul Educației, Culturii și Cercetării

al Republicii Moldova

Universitatea Tehnică a Moldovei

Departamentul Informatică și Ingineria Sistemelor

Raport
Lucrarea de an
ASAD
Tema: Information Retrieval

A efectuat: st. Gr. IA-171 Ungureanu Mihail

A verificat: Lector Superior Victoria Bobicev

Chișinău 2019
Scopul lucrării: De creat un program care extrage informația din documentele propuse
conform sarcinei.

Sarcina:
1. De creat vocabularul de cuvinte din documentele date.
2. De creat matricea cuvinte-documente conform variantei date.
3. De creat un script care rezolvă problema dată în variana respectivă.
4. De rulat scriptul și de verificat lucrul lui.
5. De evaluat rezultatele corectării calculînd Precision și Recall.
6. De analizat rezultatul obținut de script și de facut schimbările necesare pentru a mări
Precision și Recall.
7. De scris concluzia în care :
a. De analizat erorile scriptului
b. De propus căile de rezolvare a problemelor evidențiate

Resursele utilizate: 70 fișiere din folderul variantei respective.

Boolean retrieval.

emnificația termenului de recuperare a informațiilor poate fi foarte largă. Pur și


simplu ,extrageti un card de credit din portofel, astfel încât să puteți introduce
numărul cardului este o formă de recuperare a informațiilor. Cu toate acestea, ca un
domeniu academic de studiu, poate fi definit astfel :
Recuperarea informațiilor (IR) găsește materiale (de obicei documente) de o natură
nestructurată (de obicei text) care satisface o nevoie de informație din colecțiile
mari (de obicei stocate pe computere).
Așa cum am definit în acest fel, recuperarea informațiilor era doar o activitate
cativa oameni implicati in: bibliotecari de referinta, paralegali, si cautatori
profesionisti similari. Acum lumea sa schimbat și sute de milioane
din oameni se angajează în recuperarea informațiilor în fiecare zi când folosesc o
rețea web motor de căutare sau căutați în e-mailul lor
Recuperarea informațiilor devine rapidă
forma dominantă a accesului la informații, depășind căutarea tradițională în baza
de date (așa cum se întâmplă atunci când un grefier vă spune: "Îmi pare rău,Pot să-
mi caut comanda numai dacă îmi dai codul de comandă ").
IR poate acoperi, de asemenea, și alte tipuri de date și probleme de informare
care este specificată în definiția de bază de mai sus. Termenul "date nestructurate"
se referă la date care nu au clar, semantic deschis, ușor-pentru-un computer
structura. Este opusul datelor structurate, exemplul canonic alcare este o bază de
date relațională, a companiilor de tip uzual utilizate pentru a menține stocurile de
produse și înregistrările de personal. În realitate, aproape nici un fel de date sunt cu
adevărat "nestructurate". Câmpul de preluare a informațiilor acoperă, de asemenea,
suportul utilizatorilor în navigare sau filtrarea colecțiilor de documente sau
prelucrarea ulterioară a unui set de documente preluate. Având în vedere un set de
documente, gruparea este sarcina de a veni cu o grupare bună a documentelor pe
baza conținutului acestora. Este similară aranjarea cărților pe un raft în funcție de
tema lor. Având în vedere un set de subiecte, nevoile de informare permanente sau
alte categorii (cum ar fi adecvarea textelor pentru diferite grupe de vârstă),
clasificarea este sarcina de a decide ce clase, dacă există, aparține fiecărui set de
documente. Acesta este adesea abordat de prima clasificând manual unele
documente și sperând apoi să le poată clasifica noi documente în mod automat.
Sistemele de recuperare a informațiilor pot fi de asemenea diferențiate de scara de
la pe care le operează, și este util să se facă distincția între trei scale. căutarea pe
web, sistemul trebuie să furnizeze căutări pe miliarde de documentestocate pe
milioane de calculatoare. Probleme distinctive trebuie să se adunedocumente
pentru indexare, posibilitatea de a construi sisteme care să funcționeze eficient la
această scară enormă și manipularea anumitor aspecte ale webului, cum ar
fiexploatarea hipertextului și fără a fi păcăliți de către furnizorii de site-uri care
manipulează conținutul paginii într-o încercare de a-și intensifica clasamentul
motorului de căutare,având în vedere importanța comercială a web-ului. La cealaltă
extremă este recuperarea informațiilor personale. În ultimii ani, sistemele de
operare pentru consumatori au integrat informații (cum ar fi Apple Mac OS X
Spotlight sau Windows Vista Instant
Căutare). Programele de e-mail nu oferă numai căutarea, ci și clasificarea textului:
acestea oferă cel puțin un filtru de spam (spam) și, de asemenea, de asemenea
furnizați fie mijloace manuale, fie automate pentru clasificarea corespondenței
astfel încât să poată să fie plasate direct în foldere speciale. Problemele deosebite
includ manipularea gamei largi de tipuri de documente pe un computer personal
tipic, și făcând întreținerea sistemului de căutare liberă și suficient de ușoară în
ceea ce privește pornirea, procesarea și utilizarea spațiului pe disc pe care îl poate
rula pe unul fără a-i enerva pe proprietar. Între acestea se află spațiul
întreprinderii,instituțional și de căutare specifică domeniului, unde ar putea fi
prevăzută recuperareacolecții, cum ar fi documente interne ale unei corporații, o
bază de date cu brevete,sau articole de cercetare privind biochimia. În acest caz,
documentele vor fi de obicei stocate pe sisteme de fișiere centralizate și una sau o
mână dedicatămașinile vor oferi o căutare în colecție. Această carte conține tehnici
de valoare asupra întregului spectru, dar acoperirea noastră a unor aspectede
căutare paralelă și distribuită în sistemele de căutare pe scară largă este relativ
ușoară datorită literaturii relativ mici publicate asupra detaliilora unor astfel de
sisteme. Cu toate acestea, în afara unei mii de companii de căutare web
adezvoltatorul de software este cel mai probabil să întâlnească scenariile personale
de căutare și de întreprindere.

Exemplu .
O cartepe care mulți o dețin sunt lucrările colecțiilor lui Shakespeare. Să
presupunem că ați vrut să determinați care piese ale lui Shakespeare conțin
cuvintele Brut și Cezar și NU Calpurnia. O modalitate de a face acest lucru este de
a începe la începând și să citească prin tot textul, notând pentru fiecare piesă dacă
conține Brutus și Caesar și exclude din considerație dacă conține Calpurnia. Cea
mai simplă formă de recuperare a documentelor este pentru un computer pentru a
face acest tip de scanare liniară prin documente. Acest proces este frecvent GREP,
denumit grepping prin text, după comanda Unix grep, care efectuează acest proces.
Grepping prin text poate fi un proces foarte eficient, în special având în vedere
viteza computerelor moderne și adesea permite folosirea utilă posibilități de
potrivire a tiparelor de machete prin utilizarea expresiilor regulate. Cu calculatoare
moderne, pentru interogarea simplă a colecțiilor modeste (mărimea lucrărilor
colecțiilor lui Shakespeare este puțin sub un milion de cuvinte de text în total), nu
ai nevoie de nimic mai mult. Dar pentru multe scopuri, aveți nevoie de mai mult:
1. Pentru a procesa rapid colecții de documente mari. Cantitatea de date online a
crescut cel puțin la fel de repede ca viteza computerelor, și am face-o Acum îți
place să fii în căutarea colecțiilor totale de ordinul miliardelor la trilioane de
cuvinte.
2. Pentru a permite operații de potrivire mai flexibile. De exemplu, este
impracticabil pentru a efectua interogarea Romani NEAR concetățeni cu grep, în
cazul în care NEAR ar putea fi definite ca "în termen de 5 cuvinte" sau "în aceeași
propoziție".
3. Pentru a permite recuperarea clasificată: în multe cazuri doriți cel mai bun
răspuns la unnevoia de informații între multe documente care conțin anumite
cuvinte.
Modalitatea de a evita scanarea liniară a textelor pentru fiecare interogare este de
indexare documente în avans. Să rămânem cu Lucrările Colecționate ale lui
Shakespeare, și folosiți-o pentru a introduce elementele de bază ale modelului de
recuperare booleană. Presupune înregistrăm pentru fiecare document - aici o piesă
a lui Shakespeare - fie că este vorba conține fiecare cuvânt din toate cuvintele
folosite de Shakespeare (folosit de Shakespeare aproximativ 32.000 de cuvinte
diferite). Rezultatul este o incidență binară pe termen lung.

Fig.1.1
Cum procesăm o interogare utilizând un index inversat și un modul boolean de
bază model de recuperare? Luați în considerare procesarea interogării conjunctive
simple:
(1.1) Brutus și Calpurnia peste indicele inversat .
1. Localizați Brutus în dicționar
2. Recuperați înregistrările
3. Localizați Calpurnia în dicționar
4. Recuperați înregistrările
5. Intersectează cele două liste de postări, după cum se arată în Figura 1.5.
LISTA LISTEI Operațiunea de intersecție este una crucială: trebuie să se
intersecteze eficient Înscrierile în INTERSECTION listează astfel încât să poată
găsi rapid documente care conțin ambii termeni. (Această operațiune este denumită
uneori liste de fuziuni: acest nume ușor contraintuitiv reflectă folosirea
algoritmului de îmbinare pentru termen o familie generală de algoritmi care
combină mai multe liste sortate prin avansarea intercalată a pointerilor prin fiecare;
aici fuzionăm listele cu o operație logică AND.).

2.Delimitarea documentelor și decodificarea secvențelor de caractere


Documentele digitale care sunt intrările într-un proces de indexare sunt de obicei
octeți într-un fișier sau pe un server web. Primul pas al procesării este de a converti
acest lucru octet într-o secvență liniară de caractere. În cazul textului simplu în
limba engleză în codarea ASCII, acest lucru este banal. Dar de multe ori lucrurile
devin mult mai multe complex. Secvența de caractere poate fi codificată de una
dintre schemele de codificare diferite de byte sau multibyte, cum ar fi Unicode
UTF-8 sau diverse naționale sau standarde specifice furnizorilor. Trebuie să
determinăm codarea corectă. Acest lucru poate fi considerat o problemă de
clasificare a mașinilor, dar este adesea tratată de metode euristice, de utilizator
selecție sau prin utilizarea metadatelor de documente furnizate. Odată ce codarea
este determinată, decodificăm secvența de octeți la o secvență de caractere. Am
putea salvați alegerea codificării deoarece oferă unele dovezi cu privire la limba în
care este scris documentul.

Este posibil ca caracterele să fie decodificate dintr-o reprezentare binară


cum ar fi fișiere Microsoft Word DOC și / sau un format comprimat, cum ar fi
fișiere zip.
Din nou, trebuie să determinăm formatul documentului și apoi să îl potrivim
trebuie utilizat un decodor. Chiar și pentru documentele cu text simplu,
decodificare suplimentarăpoate fi necesar să fie făcut. În documentele XML,
entitățile de caractere, cum ar fi & amp; trebuie să fie decodificate pentru a da
caracterul corect,
și anume pentru & amp; În cele din urmă, este posibil ca textul documentului să fie
necesar
să fie extrase din alte materiale care nu vor fi prelucrate. Asta ar putea fi
manipularea dorită pen tru fișierele XML, dacă marcarea va fi ignorată; noi
ar dori cu siguranță să facă acest lucru cu postscript sau fișiere PDF. Noi vom
nu se ocupă mai mult de aceste chestiuni din această carte și vor presupune de
acum înainte că documentele noastre sunt o listă de caractere. Produse comerciale
de obicei trebuie să susțină o gamă largă de tipuri de documente și codificări,
deoarece utilizatorii doresc ca lucrurile să lucreze doar cu datele lor ca atare.
Adesea, ei se gândesc doar la documente ca text în interiorul aplicațiilor și nu sunt
chiar conștienți de modul în care este codificat pe disc. Această problemă este, de
obicei, rezolvată prin licențierea unei biblioteci software care se ocupă de
formatele de decodificare a documentelor și codificarea caracterelor.
2.1 Tokenizarea.
Având o secvență de caractere și o unitate de document definită, tokenizarea este
sarcina de a le tăia în bucăți, numite jetoane, poate în același timp
aruncarea anumitor personaje, cum ar fi punctuația.

Aceste jetoane sunt adesea denumite în mod liber termeni sau cuvinte, însă este
important să faceți o distincție de tip / token de câteva ori. Un jeton este o instanță
a unei secvențe de caractere într-un anumit document care sunt grupate
împreună ca o unitate semantică utilă pentru procesare. Un tip este clasa tuturor
Termos TERM conținând aceeași secvență de caractere. Un termen este un tip
(probabil normalizat) care este inclus în dicționarul sistemului IR.

Setul de indici termenii ar putea fi complet diferiți de token-uri, de exemplu, ei ar


putea fi identificatori semantici într-o taxonomie, dar în practică în sistemele
moderne de IR sunt strâns legate de jetoanele din document. Cu toate acestea, în loc
să fie exact tokenul care apare în document, acestea sunt de obicei derivate
de la acestea prin diverse procese de normalizare .De exemplu, în cazul în care
documentul care urmează să fie indexat este de a dormi permițând
vis, atunci există 5 jetoane, dar numai 4 tipuri . Cu toate acestea, dacă este omisă
din index ci vor exista doar 3 termeni: somn, posibilitate și vis.
Întrebarea principală a fazei de tokenizare este ceea ce reprezintă token-urile corecte
a folosi? În acest exemplu, pare destul de trivial: tăiați pe spațiul alb și
arunca caractere de punctuație. Acesta este un punct de plecare, dar chiar și pentru
Engleză există o serie de cazuri dificile.
Pentru majoritatea limbilor și a anumitor domenii în cadrul lor există neobișnuite
jetoane specifice pe care dorim să le recunoaștem ca termeni, cum ar fi programarea
limbajele C ++ și C #, nume de aeronave cum ar fi B-52 sau un nume de spectacol
T.V. ca M * A * S * H - care este suficient de integrat în cultura populară pe care
voi găsiți utilizări precum spitalele în stil M * A * S * H. Tehnologia informatică a
introdus noi tipuri de secvențe de caractere pe care probabil un tokenizor ar trebui
să le aibă tokenize ca un singur jeton, inclusiv adrese de e-mail
(jblack@mail.yahoo.com), adrese URL web
(http://stuff.big.com/new/specials.html), adrese IP numerice (142.32.48.231),
numerele de urmărire a pachetelor (1Z9999W99845399981) și multe altele. Unul
posibil soluția este de a omite din indexarea jetoanelor, cum ar fi sumele monetare,
numerele și adresele URL, deoarece prezența lor extinde foarte mult dimensiunea
vocabularului. Cu toate acestea, acest lucru vine la un cost mare în ceea ce privește
restrângerea a ceea ce poate caută. De exemplu, oamenii ar putea dori să caute într-
o bază de date de bug-uri pentru numărul liniei în care apare o eroare. Elemente
precum data unui e-mail, care au un tip semantic clar, sunt deseori indexate separat
ca document metadate . HYPHENS În limba engleză, despărțirea este folosită pentru
diverse scopuri, de la împărțirea vocalelor în cuvinte (co-educație) la adunarea
substantivelor ca nume (HewlettPackard) într-un dispozitiv copyediting pentru a
afișa gruparea de cuvinte (hold-him-backand-drag- manevra de plecare). Este ușor
să simțiți că primul exemplu ar trebui să fie privită ca un singur simbol (și este într-
adevăr mai frecvent scris ca doar coeducație), ultimul ar trebui să fie separat în
cuvinte și că cazul de mijloc este neclare. Manipularea în mod automat a cratimelor
poate fi astfel complexă: poate fie se face ca o problemă de clasificare, sau mai
frecvent de unele euristice cum ar fi permiterea prefixelor scurte cu linii înclinate pe
cuvinte, dar nu mai mult cu formulare împrăștiate.

Codul sursă:
package lab2;

import javafx.util.Pair;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;

public class Main {

private static final String path = "/home/mihPC/Documents/Univer/ASAD/Lab02/var9/";


private static final String outputMatrixFile = "home/mih97/IdeaProjects/asd/src/lab2/cuvinte.txt";
private static final String outputKeyWordsFile =
"home/mih97/IdeaProjects/asd/src/lab2/cuvinte_cheie.txt";
private static final String outputInterrogationResultsFile =
"home/mih97/IdeaProjects/asd/src/lab2/cuvinte_interogari.txt";
private static final String keyRubric = "cotidian";
private static final int nInterrogations = 3;
private static final int nWordsPerInterrogation = 3;
public static void main(String[] args) throws IOException {
ArrayList<File> files = getFileList(path);

// Set of words from all files


// ArrayList<String> distinctWords = new ArrayList<>(getWordsSetFromFiles(files));
// Collections.sort(distinctWords);

// All distinct words from a specific rubric


ArrayList<String> keyWords = new ArrayList<>(getKeyWordsFromRubric(files, keyRubric));
Collections.sort(keyWords);

// Random key words for interrogations


ArrayList<String> randomKeyWords = new ArrayList<>();

// Write result to file


// writeMatrixToFile(outputMatrixFile, files, distinctWords);
// writeKeyWordsToFile(outputKeyWordsFile, keyWords);

BufferedWriter writer = new BufferedWriter(new FileWriter(outputInterrogationResultsFile));


for (int i = 0; i < nInterrogations; i++) {
randomKeyWords.clear();
for (int j = 0; j < nWordsPerInterrogation; j++)
randomKeyWords.add(keyWords.get(new Random().nextInt(keyWords.size())));
Collections.sort(randomKeyWords);

ArrayList<Pair<Pair<String, Integer>, String>> results = executeInterrogation(files,


randomKeyWords, keyRubric);

writeInterrogationResultsToOutputFile(writer, randomKeyWords, keyRubric, results);


}
writer.close();
}

// Write interrogation results to file


private static void writeInterrogationResultsToOutputFile(final BufferedWriter writer, final
ArrayList<String> randomKeyWords, final String keyRubric,
final ArrayList<Pair<Pair<String, Integer>,
String>> results) throws IOException {
writer.write("\n\nCuvintele cheie din rubrica " + keyRubric + ": " + randomKeyWords + "\n");
for (Pair<Pair<String, Integer>, String> element : results) {
writer.write("\nCuvantul: " + element.getValue() + "\n");
writer.write(element.getKey().getKey() + " - " + element.getKey().getValue());
}
}

// Execute interrogation
private static ArrayList<Pair<Pair<String, Integer>, String>> executeInterrogation(final
ArrayList<File> files, final ArrayList<String> wordsToCheck,
final String keyRubric) throws
IOException {
ArrayList<Pair<Pair<String, Integer>, String>> filesThatContainGivenWords = new ArrayList<>();

for (File file : files) {


if (file.getName().contains(keyRubric)) {
for (String word : wordsToCheck) {
if (tokenizeFile(file).contains(word))
filesThatContainGivenWords.add(new Pair<>(new Pair<>(file.getName(),
getWordFrequencyInFile(file, word)), word));
}
}
}
return filesThatContainGivenWords;
}

// Write key words to output file


private static void writeKeyWordsToFile(final String fileName, final ArrayList<String> keyWords)
throws IOException {
BufferedWriter writer = new BufferedWriter(new FileWriter(fileName));

writer.write("Cuvintele cheie pentru rubrica cotidian:\n\n");


for (String word : keyWords)
writer.write(word + "\n");
writer.close();
}

// Gets all unique words from files with a specific rubric that are not in any other files
private static Set<String> getKeyWordsFromRubric(final ArrayList<File> files, final String keyRubric)
throws IOException {
Set<String> distinctWordsFromRubric = new HashSet<>();
Set<String> keyWords = new HashSet<>();

// Retrieve all unique words from files with a specific rubric


for (File file : files) {
if (file.getName().contains(keyRubric))
distinctWordsFromRubric.addAll(tokenizeFile(file));
}

// Check for word appearance in other files


for (File file : files) {
if (!file.getName().contains(keyRubric)) {
for (String word : distinctWordsFromRubric) {
if (getWordFrequencyInFile(file, word) == 0)
keyWords.add(word);
}
}
}
return keyWords;
}

// Create a set of words from multiple files


private static Set<String> getWordsSetFromFiles(final ArrayList<File> files) throws IOException {
Set<String> uniqueWords = new HashSet<>();

for (File file : files)


uniqueWords.addAll(tokenizeFile(file));

return uniqueWords;
}

// Write word matrix to output file


private static void writeMatrixToFile(final String fileName, final ArrayList<File> files,
final ArrayList<String> distinctWords) throws IOException {
BufferedWriter writer = new BufferedWriter(new FileWriter(fileName));

// Write file name columns


writer.write("\t\t\t\t\t\t\t\t\t");
for (File file : files) {
writer.write("\t" + String.format("%20s", file.getName().replace(".txt", "")) + "\t\t");
}

// Actual matrix
writer.write("\n");
for (int i = 0; i < distinctWords.size(); i++) {
writer.write(String.format("%20s", distinctWords.get(i)));
for (int j = 0; j < files.size(); j++) {
writer.write(String.format("\t\t\t\t\t\t\t\t%d", getWordFrequencyInFile(files.get(j),
distinctWords.get(i))));
}

writer.write("\n");
}
writer.close();
}

// Get word frequency in a file


private static int getWordFrequencyInFile(final File file, final String word) throws IOException {
// Optimized
int count = 0;
// count = (int) tokenizeFile(file).parallelStream().filter(token -> token.equals(word)).count();

// Slow
for (String token : tokenizeFile(file)) {
if (token.equals(word))
count++;
}
return count;
}

// Get all files from a specified path as an ArrayList of files


private static ArrayList<File> getFileList(final String path) {
return new ArrayList<>(
Arrays.asList(Objects.requireNonNull(new File(path).listFiles()))
);
}

// Tokenize all words from file and return them as ArrayList of Strings
private static ArrayList<String> tokenizeFile(final File file) throws IOException {
String fileContents = new String(
Files.readAllBytes(Paths.get(file.getAbsolutePath())),
StandardCharsets.UTF_8
);
// [\\W]+ - Everything except delimiter characters, for ASCII characters only
// \\P{L}+ - This is for non ASCII characters (not tested)
return new ArrayList<>(Arrays.asList(fileContents.toLowerCase().split("[^a-zA-Z]+")));
}

}
Figura 1. Matricea de cuvinte
Figura 2. Cuvintele cheie din rubrica cotidian
Figura 3. Rezultatul interogărilor

Concluzii:
În această lucrare de laborator noi ne-am inițiat în domeniul information retrieval.
Am învățat cum se extrage corect informația. Conform sarcinei, am avut de creat o
aplicație care va extrage matricea booleana cuvinte-documente, în care se vor afla
frecvențele acestor cuvinte pentru fișierele lor respective, toate cuvintele cheie
pentru rubrica cotidian (cuvintele care se conțin în fișierele cu rubrica cotidian și nu
se conțin în orice alt fișier), și de creat 3 interogări, fiecare a câte 3 cuvinte extrase
din cuvintele cheie și verificarea frecvenții lor în rubrica cotidian (se va indica doar
denumirea fișierului și frecvența fiecărui cuvând în fișierul respectiv). Luând în
considerație faptul că avem 70 de fișiere de verificat, fiecare din fișiere conținând
articole de dimensiuni medii, algoritmul este lent. Crearea și scrierea matricii
booleane cuvinte-documente se efectuează în aproximativ 10 minute. Extragerea
cuvintelor cheie din rubrica cotidian se efectuează în aproximativ 4 minute. Iar
interogările, în total se efectuează în aproximativ 3 minute (luând în considerație că
sunt 3 interogări, putem spune că fiecare interogare durează o minută). În total
programul se execută în aproximativ 20 de minute. Acest rezultat poate să difere în
dependență de puterea procesorului la un singur nucleu (programul se execută pe un
singur fir de execuție).

Surse Bibliografice :

Allan, James. 2005. HARD track overview in TREC 2005: High accuracy retrieval
from documents. In Proc. TREC. 174, 519

Allan, James, Ron Papka, and Victor Lavrenko. 1998. On-line new event detection
and tracking. In Proc. SIGIR, pp. 37–45. ACM Press. DOI:
doi.acm.org/10.1145/290941.290954. 399, 519, 526, 528 Allwein, Erin L., Robert

E. Schapire, and Yoram Singer. 2000. Reducing multiclass to binary: A unifying


approach for margin classifiers. JMLR 1:113–141. URL:

www.jmlr.org/papers/volume1/allwein00a/allwein00a.pdf. 315, 519, 530, 531

Alonso, Omar, Sandeepan Banerjee, and Mark Drake. 2006. GIO: A semantic web
application using the information grid framework. In Proc. WWW, pp. 857–858.
ACM Press. DOI: doi.acm.org/10.1145/1135777.1135913. 373, 519, 522

S-ar putea să vă placă și