ABONAMENTE VIDEO REDACȚIA
RO
EN
×
▼ LISTĂ EDIȚII ▼
Numărul 89
Abonament PDF

AI și Machine Learning în C#

Dan Sabadis
Team Lead @ SDL
PROGRAMARE

Un algoritm machine learning (ML) este un program ce folosește inițial cantități mari de date, ca mai apoi să poată face "predicții" despre datele ce vor apărea ulterior în sistem. Un exemplu clasic este algoritmul de clasificare: introducem mii de poze cu câini și pisici în algoritmul AI (Artificial Intelligence), și astfel noi ne "antrenăm" programul. Apoi, când încărcăm o imagine nouă, programul va identifica cu o acuratețe de peste 99% dacă entitatea nouă este un câine sau o pisică. Acest proces de identificare sau clasificare se numește etichetare (labelling). Algoritmii ML pot fi considerați, inițial, destul de ciudați, deoarece nu facem programare propriu-zisă, ci ne încredem că un alt model de program creat de "specialiști" se poate auto-adapta, astfel încât nouă să ne revină doar sarcina de a-i oferi datele și de a urmări precizia identificării!

Există o serie de librării ML/AI, precum Keras sau PyTorch. Am ales TensorFlow deoarece este open-source din 2015 grație Google, în baza licenței Apache, fiind, de altfel, cea mai populară librărie pentru programarea ML. Se poate găsi la linkul: https://www.tensorflow.org/

Ca multe librării celebre, TensorFlow este scrisă în C++, iar cel mai popular port este pentru Python. Python este utilizat de cei mai mulți cercetători Big Data. Avem un port "oficial" pentru .Net (furnizat de Microsoft), dar în această serie de articole dorim să portăm o serie de exemple din Python în C# folosind o librărie independentă: "TensorFlow.NET". Această librărie respectă mai fidel filozofia limbajului de programare Python.

TensorFlow este cunoscută drept o librărie pentru calcule numerice ce folosește grafuri cu fluxuri de date (data flow graphs). Datele de intrare sunt transformate în "flux de date" prin aceste grafuri non-ciclice. Acest lucru presupune o schimbare de paradigmă în programare, orice date putând fi reprezentate printr-un graf în care nodurile sunt calcule, iar liniile ce unesc nodurile sunt numere. Calculele sunt numite "Operatori" ("Operators"), iar fluxul de date pe linii este numit "Tensors".

Următorul graf are două date de intrare: numerele reale 1.8 și 3.6. Datele trec prin graful aciclic, fiind transformate succesiv, până când ajung la rezultatul final, 15, după 6 operații. Unele noduri pot fi calculate independent de celelalte (precum x și abs sau round și floor), deci sunt potrivite pentru calcul paralel.

Această reprezentare grafică este strâns legată de biologia sistemului nervos, mai precis de neuroni și de rețelele neuronale specifice creierului și sistemului nervos al organismelor animale. Orice neuron are un punct electrochimic de intrare (în funcție de câți axoni conectați are) și se bazează pe forța semnalului de intrare ce poate sau nu să determine un semnal de ieșire de o anumită intensitate. Acesta este modelul replicat de rețelele neuronale artificiale, iar scopul este adaptarea modelului nodurilor din graf (conexiunile dintre neuronii artificiali) pentru a maximiza semnalul de ieșire (sau a minimiza pierderile).

Este regretabil că auzim în ziua de azi vorbindu-se despre faptul că "o companie își antrenează algoritmul AI". Algoritmul nu poate fi antrenat! Putem modifica ușor simularea unei rețele neuronale (graful aciclic) oferindu-i date și sperând că rețeaua se va modifica și adapta potrivit așteptărilor. Trebuie menționat și că nodurile grafului pot fi o singură Operație atomică sau un sub-graf. Vom începe cu cea mai pragmatică abordare: codul sursă.

Precondițiile mostrelor de cod: creați un proiect .Net Core Console și instalați pachetul Nuget

  1. Creați o aplicație .Net Core 3.0 Console Application cu Visual Studio 2019 Community Edition.

  2. Dați click-dreapta pe proiect și selectați Manage Nuget Packages. Dați click pe "Browse" și identificați librăria "TensorFlow.NET". Instalați-o din pachetul sursă nuget.org. La momentul redactării acestui articol, cea mai stabilă versiune era 0.10.10, aceasta fiind folosită în mostrele noastre, deși există și versiuni mai noi (0.11.+).

  3. De asemenea, instalați prin intermediul Nuget și SciSharp.TensorFlow.Redist v 1.15.0 (ultima versiune la momentul redactării).

  4. Compilați proiectul.

5.Adăugați la începutul fiecărui fișier C# următoarele declarații de tip using:

using System;
using NumSharp;
using Tensorflow;
using static System.Console;
using static Tensorflow.Python;

6.Compilați din nou. Acum putem scrie cod.

Trebuie să ne familiarizăm cu tipurile de primitive ale TensorFlow și cu blocurile de bază ale librăriei. În primul rând, avem tipul Tensor. Un tensor este un tablou (array) n-dimensional unde fiecare element trebuie să aibă același tip precum frații săi direcți. Un Tensor fără dimensiuni (sau 0-dimensional) se numește scalar. Un Tensor cu o dimensiune se numește tablou, cel cu două dimensiuni matrice (Matrix), iar cel cu trei sau mai multe dimensiuni multidimensional (N-dimensional).

var a = tf.constant(4.0f);
var b = tf.constant(5.0f);
var c = tf.add(a, b);
WriteLine($"c={c}");
using (var sess = tf.Session())
{
  var o = sess.run(c);
  WriteLine($"c={o}");
}

Ca în orice program C#, putem declara ca unele date să fie mereu constante, în acest caz elementele Tensor. După ce au fost create, aceste elemente nu pot fi schimbate (echivalentul tipului string care e imutabil în C#).

Prima declarație WriteLine va avea drept rezultat descrierea pentru tensor-ul c: c=tf.Tensor Add:0 shape=() dtype=TF_FLOAT. Elementul Tensor primește un identificator: Add:0, nu are formă (este 0-dimensional) și conține date de tip real (float).

Pe linia a treia am declarat o variabilă c de tip Tensor ce reprezintă rezultatul adunării a două constante Tensor: a și b. Prima declarație WriteLine nu afișează valoarea calculată a tensorului c! Acest lucru se realizează doar prin intermediul celei de-a doua declarații WriteLine, din cadrul unei sesiuni (Session), ce afișează valoarea calculată, anume 9. Trebuie reținut următorul lucru: toate calculele au loc doar în cadrul unei sesiuni. Până a ajunge la acest moment, doar definim grafurile ce stochează asocieri și operații ce nu vor fi calculate până nu sunt rulate într-o sesiune prin comanda Session.Run (linia 7). Metoda run realizează calculul pentru un nod din graf, în acest caz pentru Tensor c.

Un alt concept fundamental este cel de Placeholder. Acesta este un Tensor ce nu este o constantă, ce așteaptă o valoare și care va returna o altă valoare ce va fi folosită mai târziu într-o sesiune:

var a1 = tf.placeholder(tf.float32);
var b1 = tf.placeholder(tf.float32);
var c1 = tf.sub(a1, b1);
using (var sess = tf.Session())
{
  var o = sess.run(c1, new FeedItem(a1, 3.0f)
         , new FeedItem(b1, 2.0f));

  WriteLine($"o={o}");
}

În acest caz, după cum se vede și din linia 6, dăm valoarea 3 pentru Tensor a1, valoarea 2 pentru Tensor b1 și calculăm valoarea pentru Tensor c1 ce a fost definit în linia 3 ca diferență între tensor a1 și tensor b1. Rezultatul afișat va fi 1.

Următorul program va declara tensori constanți ce pot conține tipuri multiple cu dimensiuni multiple:

var t1 = new Tensor(3);
var t2 = new Tensor("Hello! TensorFlow.NET");
var nd = new NDArray(new int[] { 3, 1, 1, 2 });
var t3 = new Tensor(nd);
WriteLine($"t1: {t1}, t2: {t2}, t3: {t3}");
var nd2 = np.array(1f, 2f, 3f, 4f, 5f, 6f)
            .reshape(2, 3);

Console.WriteLine($"nd2: {nd2}");
var cc1 = tf.constant(3); // int
var cc2 = tf.constant(1.0f); // float
var cc3 = tf.constant(2.0); // double
var cc4 = tf.constant("Big Tree"); // string
var nd3 = np.array(new int[][]
{
  new int[]{3, 1, 1},
  new int[]{2, 3, 1}
});
var nd3Tensor = tf.constant(nd3); 
// dtype=int, shape=(2, 3)

Console.WriteLine($"nd3Tensor: {nd3Tensor}");

Elementul t1 conține un scalar întreg, t2 conține un șir, iar t3 conține un tablou (array) unidimensional. Observați că tipul NDArray este declarat în ansamblul NumSharp.Core (rezolvat automat drept dependință a ansamblului și a librăriei principale TensorFlow.Net). NumSharp este un port .Net al celebrei librării NumPy Python. NDArray este echivalentul unui tablou (array) .Net ajustat pentru operații de tip Python. Variabila nd2 conține o matrice cu (2,3) dimensiuni (2 rânduri fiecare cu 3 coloane). După cum se vede, putem transforma o mulțime unidimensională într-una bi-dimensională (o matrice). Tensorii cc1, cc2, cc3 și tensorul cc4 conțin o valoare de tip diferit: întreg, numere reale cu precizie de una sau două zecimale, șir de caractere. Reamintim faptul că un Tensor trebuie să aibă elemente de același tip, prin urmare cele 2 elemente din nodul nd3Tensor trebuie să aibă fiecare aceeași formă (tablou unidimensional cu 3 elemente) și același tip (în acest caz integru).

Un alt mod mai expresiv și mai elegant de a declara un tensor poate fi observat în următorul program:

var xp = tf.placeholder(tf.int32);
var y = xp * 3;
Python.tf_with(tf.Session(), session =>
{
  var result = session.run(y, feed_dict: 
    new FeedItem[]
    {
      new FeedItem(xp, 2)
    });
  WriteLine($"y: {result}");    //should be 6;
});

Declarăm tensorul y ca fiind triplul elementului placeholder xp. Rezultatul va fi afișat (valoarea 6) când valoarea de intrare, 2, ajunge la placeholderul tensor.

Următorul program prezintă ultimele două noțiuni fundamentale ale TensorFlow, variabilele și grafurile:

Python.tf_with(tf.Graph().as_default(), graph =>
{
  var variable = tf.Variable(31, name: "tree");
  tf.global_variables_initializer();
  WriteLine($"variable: {variable}");
  using (var session = tf.Session())
  {
    session.run(variable.initializer);
    variable.assign(12);
    var result = session.run(variable);
    WriteLine($"result: {result}"); 
    // should be 12 but is 31

    var result2 = session.run(variable.assign(12));
    WriteLine($"result2: {result2}");//12
    var saver = tf.train.Saver();
    saver.save(session, "./tensorflowModel.ckpt");
    }
 });

O variabilă este un tip special de date ce permite asignarea succesivă de valori multiple, fiind opusul constantei. Prima operație de afișare (printing) din declarația "using" (linia 11) afișează în continuare 31, deși am asignat valoarea 12 peste declararea valorii inițiale, 31. Acest lucru este posibil deoarece operația assign returnează o nouă valoare de tip ITensorOrOperation ce va stoca valoarea intenționată asignată. Puteți vedea valoarea intenționată pe linia 13 când se declanșează comanda session.run.

Al doilea și ultimul concept este Graful (Graph). La linia 15 avem o sesiune ce serializează graful pe hard drive. Astfel, îl putem reîncărca la o dată ulterioară pentru a efectua și alte operațiuni precum ajustarea grafului pentru maximizarea rezultatului numeric (sau minimizarea pierderilor).

Următorul exemplu este primul program AI real. Să presupunem că avem niște date de intrare cu prețul caselor dintr-un oraș. Dacă le punem într-un grafic (cu axele x și y) putem vedea ceva similar cu imaginea de mai jos. Axa y reprezintă prețul, iar axa x reprezintă proximitatea de centrul orașului.

Intuiția umană ne spune că există o corelație liniară (o relație cauzală) între aceste puncte, deci cu cât casa e mai aproape de centrul orașului, cu atât e mai mare prețul acesteia. Orice funcție liniară are formula matematică Y = M*X+B. Având în vedere datele de intrare, vrem să calculăm corect coeficienții M și B ce ne oferă panta (M) și linia interceptoare (B, unde linia intersectează axa x). Linia roșie ar trebui să fie pe la "mijloc", unde distanța medie a tuturor punctelor de pe linie (perpendiculara directă) ar trebui să aibă valoare minimă. Vom permite unei funcții AI, numită GradientDescentOptimizer, să calculeze această valoare minimă pentru a putea identifica linia roșie cu valori optime pentru M și B.

public static void 
  TensorFlowForDummiesLinearRegression()
{
  var N = 40;
  var x = tf.random_normal(new[] { N });  
  var m_real = tf.truncated_normal(
     shape: new int[1] { N }, mean: 2.0f);

  var b_real = tf.truncated_normal(new int[] { N },
      3.0f);

  var y = m_real * x + b_real;
  WriteLine($"x = {x.eval()}");
  WriteLine($"m_real = {m_real.eval()}, 
    \nb_real={b_real.eval()}");

  WriteLine($"y = {y.eval()}");

  //# Variables
  var m = tf.Variable(tf.random_normal(new int[0]));
  var b = tf.Variable(tf.random_normal(new int[0]));

  //# Define model and loss to be computed
  var model = tf.add(tf.multiply(x, m), b);    
  // equivalent to: model = X*m + b
  var loss = tf.reduce_mean(tf.pow(model - y, 2));    

  //# Create optimizer
  var learn_rate = 0.1f;
  var num_epochs = 200;
  var num_batches = N;
  var optimizer = tf.train
    .GradientDescentOptimizer(learn_rate)
    .minimize(loss);   

  //# Initialize variables
  var init = tf.global_variables_initializer();
  tf_with(tf.Session(), session =>
  {
    session.run(init);
    WriteLine($"before: m = {session.run(m)}, 
      b = {session.run(b)}");

    foreach (var epoch in range(num_epochs))
    {
      foreach (var batch in range(num_batches))
      {
        session.run(optimizer);
      }
      WriteLine($"{epoch}: m = {session.run(m)}, 
        b = {session.run(b)}");

    }

    // Display results
    WriteLine($"m = {session.run(m)}, 
      b = {session.run(b)}");
    });
  }

Fiind o funcție mare, o vom explica în detaliu. O puteți rula invocând funcția în metoda Main sau oriunde altundeva. Asigurați-vă doar că ați declarat la începutul fișierului C# declarațiile "using" pe care le-am menționat la pasul 4 în paragraful de mai sus.

La linia 3 generăm un tensor mulțime (1D) simplu cu 40 de numere aleatorii (de folosit) pe axa x. Se numește x. Tensorii m_real și b_real conțin 40 de numere aleatorii, dar ordinea lor aleatorie este ajustată spre valoarea dorită: 2 și 3. Apoi, finalmente la linia 7, definim Tensor y prin funcția liniară: m*x+b; La liniile 3-7 generăm 40 de perechi de puncte (x,y) în jurul liniei Y = 2*x+3. Oferim aceste 40 de puncte obiectului AI și ne așteptăm ca "magia" AI să își dea seama că cele 40 de puncte gravitează în jurul modelului (Y=2*x+3), pentru a calcula prin încercări succesive că m este aproximativ 2 iar b este aproximativ 3.

La liniile 9-11 afișăm cei 4 tensori definiți mai sus.

La liniile 14-15 am definit variabilele pe care ne așteptăm să le calculeze obiectul AI GradientDescentOptimizer prin încercări succesive. Le inițializăm cu valori aleatorii.

La liniile 18-19 definim modelul și pierderile ce vor fi calculate și minimizate. Pierderile reprezintă media distanței la pătrat dintre punctele y și model. Acesta e aspectul ce trebuie minimizat.

La linia 25 folosim nucleul acestui program, anume obiectul GradientDescentOptimizer. Această clasă este potrivită pentru scopul nostru: aproximarea funcțiilor liniare. Mai există o clasă în tf.train namespace, numită AdamOptimizer, ce reprezintă un algoritm AI mai potrivit pentru alte scenarii. Din păcate, există multe alte clase Optimizer definite în portul Python al librăriei TensorFlow. Sperăm ca și acestea să fie adăugate pe viitor în cadrul librăriei noastre .Net.

La linia 28 se realizează o invocare obligatorie pentru a inițializa toate variabilele globale. Apoi, la linia 31 rulăm inițializarea într-o sesiune. Fără aceste apeluri, programul nu va merge, dar scopul programului este de invoca succesiv session.run(optimizer) la linia 35.

După fiecare rulare, veți observa că programul calculează valori ușor diferite pentru m și b, dar acestea gravitează mereu în jurul valorilor așteptate: 2 pentru m și 3 pentru b.

Acum că am antrenat modelul și am găsit valorile aproximative pentru m și b, mai avem un ultim pas, care nu a fost inclus aici pentru a face lucrurile mai simple: testarea modelului nostru cu date de test pentru a decide dacă noile puncte gravitează în jurul liniei model (sau pentru scenariul nostru pentru a observa dacă un preț nou publicat pentru o casă face sau nu parte din oferta de piață: fie e prea mare, fie e prea mic pentru locația respectivă).

Următorul exemplu AI se referă la modelul polinomial și la regresie. Acest model se poate aplica unei varietăți de scenarii, de exemplu fluctuația prețului acțiunilor companiei. În acest caz, potrivirea unui polinom la un set de puncte este mai potrivit decâ potrivirea unei linii simple. În rest, procesul este la fel ca în programul precedent. Formula generică a unei funcții polinomiale: f(x) = wn xn + ... + w1 x + w0

Deci, punctele sunt distribuite precum în imaginea de mai jos și vrem să identificăm curba care minimizează distanța (pierderea) de la puncte spre linie (modelul ideal).

public static void TensorFlowForDummiesPolynomialRegression()
{
  var N = 40;
  var a_real = tf.truncated_normal(new int[1] { N },
    mean: 3.0f);

  var b_real = tf.truncated_normal(new int[1] { N },
    mean: -2.0f);
  var c_real = tf.truncated_normal(new int[] { N },
    mean: -1.0f);

  var d_real = tf.truncated_normal(new [] { N }, 
    mean: 1.0f);

  var x = tf.random_normal(new int[1] { N });
  var y = a_real * tf.pow(x, 3) + b_real * tf.pow(x,
    2) + c_real * x + d_real; 

  print($"x = {x.eval()}");
  print($"b_real = {b_real.eval()}");
  print($"y = {y.eval()}");

  //# Variables
  var a = tf.Variable(tf.random_normal(new int[0]));
  var b = tf.Variable(tf.random_normal(new int[0]));
  var c = tf.Variable(tf.random_normal(new int[0]));
  var d = tf.Variable(tf.random_normal(new int[0]));

  //# Compute model and loss
  var model = a * tf.pow(x, 3) + b * tf.pow(x, 2) + 
    c * x + d;
  var loss = tf.reduce_mean(tf.pow(model - y, 2));

  //# Create optimizer
  var learn_rate = 0.1f;
  var num_epochs = 800;
  var num_batches = N;
  var optimizer = tf.train.AdamOptimizer(learn_rate)
    .minimize(loss);

  //# Initialize variables
  var init = tf.global_variables_initializer();

  //# Launch session
  Python.tf_with(tf.Session(), session =>
  {
    session.run(init);
    foreach (var epoch in range(num_epochs))
    {
      foreach (var batch in range(num_batches))
      {
        var result = session.run(optimizer);
      }
    }

    var a_result = session.run(a);
    //    # Display results
    print($"a = {a_result}");
    print($"b = {session.run(b)}");
    print($"c = {session.run(c)}");
    print($"d = {session.run(d)}");
  });
}

Precum în programul anterior, generăm datele de antrenament: 40 de puncte (x,y) aliniate în jurul funcției polinomiale y = ax^3 + bx^2 + cx + d. Declarăm variabilele a, b, c și d care vor fi inițializate cu valori aleatorii. Apoi, clasa Optimizer le calculează și le ajustează gradual spre ținte: a spre 3, b spre -2, c spre -1, d spre 1.

Din imagine ne putem da seama că linia ar trebui să fie un polinom de grad 3, deci declarăm ca modelul Tensor să fie calculat de Optimizer. Observăm ceva interesant: GradientDescentOptimizer nu mai este folosit de AdamOptimizer. În exemplul Python, GradientDescentOptimizer dă rezultate mai bune decât AdamOptimizer, dar se pare că echipa .Net responsabilă de această librărie nu a implementat încă acești optimizatori, deci AdamOptimizer funcționează mai bine decât regresia polinomială. După cum am menționat , în librăria originală Python există o serie de Optimizers, dar portul .Net al TensorFlow are doar doi optimizatori la momentul redactării acestui articol.

Restul programului este identic cu al precedentului: lăsăm ca Optimizer să calibreze cei 4 coeficienți, iar la final să îi afișeze. Dacă rulați programul, veți vedea că variabilele calculate (coeficienții) variază mult, dar gravitează în jurul valorilor reale declarate la liniile 4-7. Adăugați mai mult de 40 de puncte ca date de antrenament. Cu cât declarați mai multe puncte, cu atât veți calcula mai exact predicțiile obiectului AdamOptimizer.

Aici încheiem primul articol din serie! În al doilea articol vom explica un algoritm mai avansat de clasificare, regresia logaritmică și vom continua evident cu mostre de cod.

Sponsori

  • comply advantage
  • ntt data
  • 3PillarGlobal
  • Betfair
  • Telenav
  • Accenture
  • Siemens
  • Bosch
  • FlowTraders
  • MHP
  • Connatix
  • UIPatj
  • MetroSystems
  • Globant
  • Colors in projects