TSM - Livrare continuă pentru modele de date

Andrei Olar - Software Architect @ ComplyAdvantage

Pornim de la un fișier de cod sursă. Fișierul conține informații care pot fi înțelese datorită modului în care sunt aranjate unitățile sale de bază. Ne referim la faptul că o persoană care citește acest fișier înțelege ce este acolo dacă i se dă timp suficient. Fișierele de cod sursă pot fi interpretate, citite și de un lexer (analizator lexical ce convertește un stream de date sau un șir de caractere în unități de bază sau tokens, adică elemente ce pot fi mapate pe un model de date). Un lexer este un program de computer, deci un sistem ce poate citi și înțelege conținutul unui fișier de cod sursă.

Un alt concept, rezultatul intermediar, este rareori la îndemâna noastră, deoarece noi lucrăm mai mult cu codul sursă. Simplificând, rezultatul intermediar este rezultatul unui parser.

Pentru a înțelege cum arată rezultatul produs de un parser, trebuie să mai detaliem ce face un lexer. Să ne amintim că un lexer este un program de computer program ce poate citi și înțelege conținutul unui fișier de cod sursă. Când un lexer analizează un fișier de cod sursă, acesta va produce o reprezentare abstractă a acestui fișier. În loc să interpreteze sursa, un lexer va mapa datele de intrare pe structurile de date pe care le înțelege. Aceste structuri de date se numesc în mod curent unități de bază sau tokens. Rezultatul unui lexer se va numi de aici înainte sursă cu elemente token/parsabile (tokenized source).

Un parser este un program ce preia sursa cu elemente token/parsabile și o mapează pe o reprezentare. Prin urmare, codul sursă nu este analizat direct, ci se interpretează un model de date al codului sursă. Vom obține un rezultat indiferent de succesul sau eșecul analizei: erori, alarme sau ceea ce parserul a reușit să proceseze din datele de intrare. Rezultatul unui parser se mai numește arbore sintactic abstract (abstract syntax tree) sau arbore parsat (parse tree). După cum vă puteți da seama, este vorba de o structură ierarhică de date ce conține toate informațiile din fișierul de cod sursă, datele fiind rearanjate în manieră generică, ceea ce permite o procesare mai ușoară. Așadar, avem structuri de date și pentru datele de intrare și pentru datele de ieșire.

Abia începând de aici devine incitantă teoria compilatoarelor. Există multe proceduri și procese pentru care un arbore parsat este sursă pentru rezultatul intermediar sau final. Compilatoarele hiper-performante (precum Haskell, Scala sau C++) oferă o serie de avantaje în termeni de optimizare a rezultatului pentru programatorii care le folosesc. Limbajele mai flexibile la nivel de compilare (precum Python sau Javascript) nu vor procesa arborele sintactic abstract prea mult, iar rezultatul nu va fi optimizat la fel de mult. Ideea de bază este că toate compilatoarele vor parsa codul sursă în arbore sintactic abstract, fiind câteva compilatoare care nu vor mai efectua alte transformări. Așadar, ne vom referi la arborele parsat prin termenul rezultat intermediar.

Deoarece în cadrul acestui articol abordăm modelele de date, arborele parsat este etapa unde lucrurile devin serioase. La acest moment, avem date structurate suficient de generic pentru a putea fi interpretate de orice program ce operează pe rezultatul intermediar.

Prin urmare, dacă codul sursă poate fi înțeles de oameni și poate fi structurat destul de flexibil prin intermediul unor structuri de date (trees) generice și ușor procesabile, de ce nu s-ar putea ca orice element să aibă un model de date? Aceasta va fi premisa acestui articol.

Livrarea continuă

Codul sursă se poate compila individual pe calculatorul oricărui programator. Principala problemă este că acest proces devine obositor și plictisitor în timp (chiar nesigur pentru proiectele la scară largă). Tastarea de fiecare dată a comenzii de compilare pentru fiecare fișier și asocierea acestora într-un fișier binar nu mă încântă.

Acest proces se poate îmbunătăți folosind un instrument de build (e.g. gradle, msbuild, npm, distutils). Aceste instrumente automatizează compilarea artefactelor sursă în artefacte rezultat, oferind modalitățile pentru a rula procesul repetitiv. Deoarece, în mod tipic, compilarea este idempotentă, ne dorim ca această funcționalitate să fie transferată și la nivel de proces de build. Vrem ca pentru o listă de surse să se producă același rezultat în orice moment.

Idempotența unui build ne permite să gestionăm puterea versionării codului sursă pentru a livra versiuni ale aplicației, care corespund unei anumite versiuni a codului sursă care le-a generat.

Folosind sisteme distribuite moderne de versionare precum Git sau Mercurial, lansarea de software prin aceste mecanisme oferă avantaje importante în ceea ce privește recuperarea și securitatea sistemelor de producție. Un alt aspect mai popular presupune abilitatea de a monitoriza sistemele de versionare în ceea ce privește schimbările la nivel de build și evenimentele care declanșează buildul.

Să ne imaginăm livrarea continuă precum realizarea și lansarea unei aplicații în mediul de producție în mod automat pe măsură ce codul sursă se modifică. Simplificăm extrem de mult conceptul, iar website-ul lui Jez Humble oferă detalii pentru cei ce vor să aprofundeze subiectul. Chiar dacă nu am citi nimic în plus, putem deja să constatăm care ar fi avantajele:

Există mult mai multe detalii ce fac din livrarea continuă o activitate de succes. Vom prezenta în continuare un punct de vedere personal, extrem de restrâns a unor elemente cheie pentru a exemplifica.

Colectarea de date

În industria IT centrată pe date, când vorbim de strângerea de date, ne gândim la unul sau mai multe din următoarele aspecte:

În forma sa elementară, colectarea datelor se reduce doar la salvarea/stocarea informațiilor găsite pe Internet. De obicei, nu acordăm prea multă atenție acestui aspect, rezumăndu-ne la a spune "depozitează datele undeva". În anumite ocazii, adăugăm gestionarea stării și a versiunii datelor colectate, doar așa, în caz că se întâmplă ceva. Formulele cele mai elaborate de colectare a datelor implică auditul proceselor de colectare sau păstrarea unui istoric legat de proveniența datelor.

Când vorbim de colectarea datelor, de obicei, ne concentrăm pe modelul static (sau structural) al datelor colectate. Acest aspect poate fi urmărit cu un interes limitat (crawling și stocare) sau un interes mărit (gestionarea stării). De prea puține ori trecem dincolo de aceste aspecte și mai adăugăm informații precum originea datelor. Chiar și atunci, punem accent doar pe ceea ce s-a întâmplat în trecut.

Nu analizăm aproape deloc procesul care a dus la colectarea datelor. La o primă vedere, totul se aseamănă cu procesul de compilare descris mai sus. Principala diferență cantitativă la nivel de proces este că, în loc să avem o fază de scanare/lexer, avem două faze: crawl și scrape. Din punct de vedere calitativ, extragerea nu este identică cu parsarea. Principala diferență este că extragerea informației nu presupune existența unui rezultat intermediar. Programul care extrage informația funcționează, de regulă, indiferent de tipul informației culese - nu se bazează pe date de intrare de tip token sau pe altă formă de standardizare.

Pe de altă parte, rezultatul extragerii poate și ar trebui să aibă calitățile rezultatului parserului, adică:

Biblioteci precum Scrapy oferă paradigme ușor de utilizat pentru crawling, scraping și extragerea informației. Prin urmare, așa cum există paradigme pentru crearea de lexers sau parsers, există paradigme pentru crearea de crawlers, scrapers și extractors. Puteți crea toate aceste elemente și de la zero, dar poate aduce costuri mari pe termen lung.

Așadar, dacă avem instrumentele, de ce nu avem pentru colectarea de date aceleași procese precum cele uzate la construirea de aplicații software? Mai mult, de ce nu avem sisteme idempotente de build pentru datele pe care le colectăm? Finalmente, să nu uităm că pentru livrarea continuă avem nevoie doar de:

Versionarea datelor

Git sau Mercurial sunt instrumente excepționale pentru versionarea codului sursă. În mod similar, avem nevoie de instrumente la fel de performanțe pentru versionarea surselor de date. Să considerăm, de exemplu, că avem un website pe care declanșăm crawling și scraping. Cum urmărim schimbările de pe website spre deosebire de schimbările la nivel de crawler/scraper?

Aici intervine Data Version Control (controlul versionării datelor): este potrivit pentru multe tipuri de date și face ceea ce Git sau alte sisteme VCS fac pentru codul sursă. Stochează meta-informații despre sursa (website-ul) datelor pe care le colectăm. Folosește Git pentru a stoca meta-informații și folosește sisteme mari de stocare de fișiere (precum HDFS sau S3) pentru a stoca datele propriu-zise.

Având în vedere că are multe funcționalități asemănătoare cu cele Git, este la fel de ușor de utilizat. Un repository DVC se inițializează în felul următor:

$ git init .
$ dvc init .
$ git commit -a -m “Initialized DVC repository”

Monitorizarea unei surse de date e la fel de simplă. Tot ceea ce trebuie să știți este să inițializați un remote DVC și să adăugați un fișier. De exemplu, pentru a adăuga date într-un Amazon S3 bucket, rulați comenzile următoare:

$ dvc remote add -d s3remote s3://mybucket/myproject
$ git commit .dvc/config -m "Configure S3 remote"

DVC oferă suport și pentru alte elemente la distanță, de exemplu:

Astfel, datele sunt mereu disponibile și există o foarte bună redundanță la stocare.Monitorizarea unei surse de date presupune în primul rând descărcarea datelor. Prin urmare, etapele crawling și scraping se desfășoară înainte de a adăuga datele într-un sistem de control al versiunii. Folosind Scrapy, putem lansa o comandă similară cu aceasta:

$ scrapy fetch http://example.com/your-data-source.html

După ce datele sunt descărcate local, trebuie să le adăugăm în DVC. Presupunând că le-am descărcat în subdirectorul data/source dintr-un repository ce ne aparține, putem să adăugăm datele spre versionare rulând:

$ dvc add data/source
$ git add source.dvc

După versionarea datelor, toate comenzile utilizate pentru manipularea codului sursă devin disponibile pentru manipularea datelor. Transmiterea (pushing) și extragerea (pulling) versiunilor de date între mașina locală și sistemele la distanță (S3, HDFS, etc) define facilă. Având o versiune anume de DVC metadata (stocată în Git), putem obține ușor versiunea corectă a datelor și referințele acestora. Deci, acum avem o legătură între versiunea sursei în sine și versiunea datelor provenite din acea sursă.

Când spunem "date" vorbim despre date de intrare și despre date de ieșire. De exemplu, versionarea conținutului din directorul data/source nu este diferită de versionarea data/output. Acesta este un lucru extraordinar, deoarece avem posibilitatea de a manipula orice versiune a rezultatului (output) folosind aceleași comenzi pe care le-am folosi când interacționăm cu sursa acestuia. Paralela cu dezvoltarea de aplicații software se face prin folosirea Nexus sau Docker pentru a versiona artefactele rezultate în urma procesului de build.

Procese de build pentru date

Pe lângă versionarea datelor, o altă caracteristică cheie a livrării continue este un sistem de build idempotent. Precum în cazul dezvoltării de aplicații, programatorul este cel responsabil să facă sistemul de build cu adevărat idempotent sau nu. Din această perspectivă, diferența dintre colectarea datelor și scrierea de software nu este prea mare. Am putea folosi aceleași instrumente pe care le folosim în dezvoltarea unei aplicații (msbuild sau gradle sunt opțiuni bune) și pentru colectarea datelor. Instrumentele folosite pentru versionarea datelor (precum DVC) sunt independente de tehnologiile cu ajutorul cărora se execută pașii de build. Aceste instrumente vor funcționa atâta timp cât există un executabil oarecare pe care îl rulează la un anumit stadiu pentru a transforma datele de intrare într-o formă de date de ieșire.

Spre exemplu, presupunem că am creat un downloader (sau un spider, sau un crawler) disponibil la app/crawler. Am folosit scrapy pentru a obține prin crawl datele inițiale, rulând app/crawler. Presupunem că am scris un extractor disponibil la app/extract. Acesta preia directoarele ce stochează datele de intrare și pe cele care stochează datele de ieșire drept argumente. Pentru a versiona rezultatul cu ajutorul Data Version Control rulăm comanda următoare:

$ dvc run -f source.dvc \
          -d app/crawler -d data/source \
          -o data/output \
          app/extract data/source data/output

Comanda de mai sus descrie următorii pași secvențiali care fac ca DVC să folosească source.dvc pentru monitorizarea versiunilor datelor de intrare. DVC inspectează apoi dacă sistemul crawler și sursa de date au versiunile corecte. Dacă versiunile nu sunt corecte, se aduce versiunea corectă. În cele din urmă, DVC folosește și monitorizează data/output ca locație pentru datele de ieșire.

Cel mai important de reținut este că acum am corelat versiunea datelor de intrare cu versiunea aplicației ce transformă datele de intrare într-un rezultat și, finalmente, cu versiunea datelor de ieșire. Când ceva se modifică la nivel de sursă, se va genera o nouă versiune a rezultatului. Practic nu vom avea niciodată două rezultate diferite pentru aceeași versiune a datelor sursă, atâta timp cât rulăm comenzile prin intermediul DVC.

Pe lângă ceea ce avem în domeniul dezvoltării de aplicații, DVC introduce conceptul pipeline. Acest concept este similar cu build pipelines, permițându-vă să conectați mai mulți pași de build pentru modelele voastre de date, deci nimic nou până acum. Diferența constă în faptul că toți pașii parcurși cu un DVC pipeline au versionare de date, precum în exemplul de mai sus.

Acest lucru presupune că nu doar sursa și rezultatul sunt corelate la nivel de versiune, ci că toți pașii necesari pentru a produce un rezultat vor fi corelați la nivel de versiune. Vom avea un mecanism extrem de puternic similar celui de stabilire a provenienței datelor, dar aplicat la nivelul procesării de date.

Când lucrăm cu AI sau ML, nu putem monitoriza ușor interferențele din timpul procesării de date. Mai mult, dacă dorim să revenim la o stare anterioară a unei schimbări făcute, trebuie doar să rulăm o versiune anterioară de data pipeline (DVC permite acest lucru prin comanda 'repro'). Când un sistem AI generează informații neașteptate, putem recupera starea anterioară prin mecanismul menționat. Acest proces este similar cu modalitatea de recuperare rapidă a unei stări anterioare în cazul apariției de probleme la nivelul unei aplicații software.

Totuși, nu trebuie să ne bazăm integral pe DVC. În loc de sau pe lângă DVC, am putea folosi alte instrumente pentru gestionarea execuției de build pipelines. Este aproape obligatoriu să folosim un software de gestiune a livrării continue precum ArgoCD. Printre rânduri se poate înțelege deja și cât de greu este procesul de a colecta datele inițiale. Acest lucru se poate rezolva folosind instrumente precum Argo Workflows, Kubeflow sau Apache Airflow și rulând comenzi DVC ca pași ai unui workflow.

Indiferent dacă alegem să folosim DVC pipelines sau dacă folosim o abstracție de nivel superior precum un workflow manager, rezultatul este un pipeline de livrare continuă - cel puțin în termenii definiției de mai devreme. Când ceva se modifică în sistemul nostru de versionare de date, putem rula un DVC pipeline sau un workflow, iar datele colectate (procesate și transformate pentru etapa următoare) pot fi consumate de S3 sau de alți furnizori pentru care DVC oferă suport. Astfel, trecem de la sursa de date la mediul de producție în mod automat.

Concluzie

Pe măsură ce dezvoltarea software s-a maturizat și au apărut practici precum integrarea continuă sau livrarea continuă, o evoluție similară s-a putut observa și în cazul științei datelor. Deoarece totul are un model de date, chiar și codul sursă, nu trebuie să reinventăm roata pentru a genera modele de date.

Cu instrumente de versionare a datelor, Amazon S3 precum și sisteme de gestionare a proceselor, putem crea pipelines pentru modelele de date. Dacă sunt implementate corect, aceste pipelines pot aduce aceleași beneficii pentru știința datelor, proiecte AI sau ML precum cele pe care le-au adus pentru dezvoltarea software din ultimele decenii. Cu cât trecem la această abordare mai repede, cu atât mai bine.

Bibliografie

[1] Continuous Delivery for Machine Learning, Danilo Sato, Arif Wider, Christoph Windheuser

[2] Hidden Technical Debt in Machine Learning Systems, D. Sculley, Gary Holt, Daniel Golovin, Eugene Davydov, Todd Phillips, Dietmar Ebner, Vinay Chaudhary, Michael Young, Jean-François Crespo, Dan Dennison

[3] Continuous Delivery, Martin Fowler

Instrumente menționate în articol

[1] Documentația DVC

[2] Documentația Kubeflow

[3] Documentația Scrapy

[4] Argo

[5] Apache Airflow