Yapay Zekaya Oyun Oynamayı Öğretmek

Not: Derin sinir ağlarının öğrenme işleminin ağır bir işlem olmasından dolayı işlem kapasitesi sınırlı cihazlarda ( telefon, tablet vs. ) kasmalar olabilir.

Yazar

Harun Yiğit
27 Mart 2024

Toplamda 93 beğeni

Yapay Zekaya Giriş

Son zamanlarda yapay zeka ile donatılmış makinelerin bir çok işi başardığına tanıklık etmeye başladık. Özellikle derin sinir ağlarının gelişmesiyle makineler bir hayli güç ve nitelik kazandı. Bu yazımızda biz de yapay zekaya bir şeyler öğretip yeni beceriler kazandıracağız ama bu aşamaya geçmeden önce derin sinir ağlarını biraz yakından tanıyalım.

Derin Sinir Ağları

Derin sinir ağları, makine öğrenmesi algoritmalarının aksine, direkt olarak insan beyninin bilgisayar ortamında taklit edilmeye (modellenmeye) çalışıldığı yapay zeka algoritmalarıdır. Bir derin sinir ağını, nöronlar, bu nöronları birbirine bağlayan parametreler ve birtakım işlemler olarak kısaltabiliriz. Bir derin sinir ağında onlarca , yüzlerce hatta on binlerce nöron bulunabilir. Bu nöronlar kendi katmanlarından sonraki ve önceki katmanlardaki diğer nöronlarla bağlıdır.
Modelimize herhangi bir veri girildiğinde bu nöronlar giriş nöronlarından başlamak üzere sıra sıra birbirlerini dürter ve bu işlem çıkış nöronlarına kadar devam eder. En sonunda çıkış nöronlarındaki değerler modelimizin tahmin değerleridir.

Yukarıdaki resimde bir sinir ağının görselleştirilmiş halini görebilirsiniz.

Modelimize bir input girerek çıktı alma işlemine İleri Besleme Algoritması, modelimize girilen input ile oluşan çıktıdaki hatayı dağıtarak eğitme işlemine Geriye Besleme Algoritması denmektedir.

İleri Besleme Algoritması

Yapay sinir ağlarının çıktı vermesi olayına "İleri Besleme" denmektedir. İleri besleme algoritması kısaca modelimize input girerek çıktı alma işlemidir. İleri besleme algoritmamızı iki aşamaya bölebiliriz;

  • Giriş katmanına inputun girilmesi,
  • Giriş katmanından başlayarak nöronların yeni z değerlerinin atanması

İnputumuzu giriş katmanımıza girdiğimizde, giriş katmanımızdaki nöronların z değerleri girdiğimiz inputtaki değerleri sırasıyla almaktadır. Artık yeni girişimizi bütün modele yayabiliriz. İlk katmandan sonraki katmanların z değerlerinin atanması işlemine geçilir. Bu kısımda her bir nöronun z değeri, kendinden önceki katmanda bulunan nöronların z değerleri ve bu nöronumuz ile arasındaki ağırlık (w) değeri ile çarpımlarının toplamının aktivasyon fonksiyonuna tabi tutulmuş halini alır. Bu işleme ikinci katmandan başlanıp son katmanda bitirilir.

Yukarıdaki resimde ileri besleme algoritmasını görebilirsiniz.

İleri besleme algoritmasında hangi aktivasyon fonksiyonunu seçeceğimiz konusu probleme göre değişmektedir ancak geriye besleme algoritmasında, ileri besleme algoritmasının türevini kullanacağız. Çoğunlukla sigmoid kullanılıyor olup, biz de bu blog yazısında sigmoid üzerinden ilerleyeceğiz ( sigmoid(x) : 1 / 1+ e -x ).

Yukarıdaki resimde aktivasyon fonksiyonlarını görebilirsiniz.

Hangi derin sinir ağı modelini kullanacağınız probleme göre değişkenlik gösterir. Biz bu problemde Classification ( sınıflandırma ) modelini kullanacağız. Sınıflandırma modellerimizde çıkış katmanında elimizdeki sınıflar kadar nöron vardır ve çıkış katmanındaki her bir nöron bir sınıfı temsil etmektedir. İleri besleme algoritması sonunda çıkış katmanındaki nöronlar arasında en yüksek değeri taşıyan nöronun temsil ettiği sınıf, modelimizin tahminidir.

Örnek bir sınıflandırma modeli.

Modelimize öğretmek istediğimiz oyunda oyuncunun 3 kabiliyet hakkı var;

  • Sağ Oynama
  • Sol Oynama
  • Sabit (oynamama)

Bu yüzden modelimizin çıkış katmanında 3 nöron bulunduracağız.

Modelimizin çıkış katmanı.

Yukarıdaki fotoğrafta çıkış nöronları arasında 'Sol' sınıfını temsil eden nöron en yüksek z değerine sahip olduğu için modelimiz oyunda 'Sol Oynama' kabiliyetini kullanacaktır.

Geriye Besleme Algoritması

Şimdilik modelimiz İleri Besleme algoritması ile hangi kabiliyeti oynaması gerektiğini seçebiliyor. Ancak şuanda hiç bir şey bilmiyor ve tamamen rastgele oynuyor. Çünkü modelimizi eğitmedik. Geriye Besleme algoritması burada devreye giriyor. Modelimizi eğitmek için Geriye Besleme algoritmasını inceleyelim;
Geriye besleme algoritmasında yapmaya çalıştığımız şey, modelimizin verdiği kararlar ile vermesi gereken kararlar arasındaki hatayı modele yaymaktır. Bunun için bu işlemleri sayısal verilerle ifade etmemiz gerekiyor. Elimizde bir veri seti var;

  • x (girdi) değerleri: topun orijin koordinatındaki konumu ile çubuğun orijin koordinatındaki konumu arasındaki fark ve topun aksis koordinatındaki konumu ile çubuğun aksis koordinatındaki konumu arasındaki fark
  • y (örnek çıktı): x girdilerine göre oynanması gereken kabiliyet

Bu durumda y değerlerimiz sayısal veri değil ancak bizim sayısal verilere ihtiyacımız var.
Örnek bir veri:

  • x: [0.034, 0.129]
  • y: 'sag'

Bu örnekte oynanması gereken kabiliyet veri setine göre 'Sağ Oynama' kabiliyeti, bu yüzden geriye besleme algoritmasında 'Sağ Oynama' kabiliyetini temsil eden nöronumuzu buna benzer girdilerde 1'e yaklaştırmak, diğer iki nöronumuzu 0'a yaklaştırmak istiyoruz.
Geriye Besleme algoritmasının ilk aşamasında çıkış katmanında nöronların delta değerleri bulunur;

delta: sigmoid_derivative(z) * (z - y);

Yukarıdaki denklemde aktivasyon fonksiyonu olarak sigmoid kullandığımız için deltayı alırken de sigmoid fonksiyonunun türevini kullandık. y değeri nöronumuzdan beklediğimiz değer ve z değeri nöronumuzun z değeri. Delta alınırken, girdiye göre veri setinde olması gereken sınıfı temsil eden nöron için y değerimiz 1, diğer nöronlar için y değerimiz 0'dır.

Çıkış katmanındaki nöronların deltalarını bulduktan sonra, gizli katmanlardaki nöronların deltalarını bulabiliriz. Her bir nöronun deltasını bulmadan önce, error değerini bulmamız gerekir, error değeri, nöronun bulunduğu katmandan bir sonraki katmanda bulunan bütün nöronların deltaları ile, nöronumuz arasındaki ağırlığın çarpımlarının toplamı ile bulunur.

Bir nöronun error değeri hesaplanması.

Bir sonraki adım error değeri hesaplanan nöronun delta değerini bulmaktır. Delta değeri, nöronun error değeri ile z değerinin sigmoid fonksiyonunun türevine tabi tutulmuş haliyle çarpılmasıyla bulunur.

Ara katmandaki bir nöronun delta değeri hesaplanması.

Ara katmandaki nöronların delta değerlerini hesapladıktan sonra sıra deltalara bağlı olarak nöronlar arasındaki ağırlıkları güncellemede, bu işleme sondan ikinci katmandan başlıyoruz.
Bir nöron ile kendisinden sonraki katmanda bulunan bir nöronun arasındaki güncel ağırlık, seçili nöronun z değeri ile sonraki katmanda bulunan nöronun deltasının çarpımının, learning_rate ( genelde 0.6 ) hyperparametresiyle çarpımıyla bulunur.

Ara katmandaki bir nöronun bir sonraki katmandaki herhangi bir nöron ile arasındaki ağırlığın güncellenmesi

Bu işlemi bütün ağ boyunca yaptığınızda veri setini bir kere epoch etmiş olursunuz, ancak modelimizin daha doğru sonuçlar verebilmesi için birçok kez epoch edilmesi gerekir. Bu yüzden başta kötü sonuçlar veren ama zamanla iyileşen sonuçlar ortaya koyan bir model oluşur.

Herşeyi birleştirince ortaya çıkan modelimiz :)

Soru & Cevap

Neden Aktivasyon Fonksiyonları Kullanıyoruz?

Aktivasyon fonksiyonları, modeimizin çıktılarını linear ( doğrusal ) halden non-linear ( doğrusal olmayan ) hale getirir. Bu sayede sinir ağın non-linear problemleri de anlayabilecek hale gelir. Bu sayede gerçek dünya problemlerini çözebilme kapasitesine sahip olur.
Bir diğer neden ise çıktıyı türevleyebilmek için türevlenebilen bir fonksiyon içerisine almamız gerekmektedir. Sonuçta öğrenme olarak bahsettiğimiz olay, sinir ağındaki parametrelerin, hatayı minimize edecek şekilde güncellenmesidir. Bir "y" değerinin "x" değerine göre en yüksek ve en düşük olduğu noktaları bulabilmemizi sağlayan şey türevdir. Yapay sinir ağlarında da bu amaç ile kullanılmaktadır.
Ayrıca aktivasyon fonksiyonlarının diğer iki özelliği ise, modelimizin çıktılarını sıkıştırmak veya modeli seyrekleştirmektir. Örneğin sigmoid ve tanh fonksiyonları, modelimizin çıktılarını sıkıştırırken, ReLU ve Leaky ReLU fonksiyonları modelimizi seyrekleştirir.
Relu fonksiyonları negatif değerleri 0'a eşitlerken,Leaky Relu fonksiyonu negatif değerleri 0'ya yaklaştırır. Bu sayede pozitif değerlerimiz daha baskın hale gelir.

Neden Deltaları Kullanıyoruz?

Deltalar temel olarak çıkış katmanında hesaplanan hatanın ağın geri kalanına yayılmasında rol alıyorlar. Bir nöronun ağırlıklarını güncelledikten sonra kendinden bir önceki katmandaki norönların ağırlıklarını güncelleyebilmek için bu nörona ulaşmış hatayı deltalar aracılığı ile önceik katmandaki nöronlara ulaştırabiliyoruz. Bu sayede çıkış katmanından alınan hata deltalar aracılığı ile ağın geri kalanına yayılmış oluyor.
Kısacası deltalar hatayı taşıyıcı görevini görüyorlar.

Neden Epoch Ediyoruz? Ağırlıklar Tek Seferde Güncellenmez Mi?

Bir türev probleminde problemin minimum noktasına ulaşmak için formülün türevini bir kez almamız yeterli oluyor, bunun nedeni bu problemlerin analitik çözümlerinin olmasıdır. Örneğin bir bahçenin duvarlarını en az maliyetle kaplayabileceğimiz ve farklı sizelarda karelerin maliyetlerini bildiğimiz bir problemde, bu problemi çözmek için bir kez türev almak yeterli olacaktır. Çünki bu problem analitik çözüme sahiptir. Bu problemde bahçemizin alanını analitik bir formül ile ( geometrik şekillerin alan formülleri ) bulabiliriz. Ancak yapay sinir ağlarında problemimizin analitik bir çözümü yoktur. Bu yüzden ağırlıklarımızı güncellemek için bir çok kez epoch etmemiz gerekmektedir.
Zaten amacımız analitik olarak formülize edemediğimiz problemleri çözmek olduğu için bu durumda epoch etmek zorundayız.

Neden Learning Rate Kullanıyoruz?

Learning rate değerimiz, ağırlıkların ne hızda güncelleneceğini belirler. Bu hız binevi bir adım büyüklüğüdür. Eğer adımımız çok büyük ( learning değeri çok yüksek ) olursa minimum hata noktasını atlayabiliriz. Aynı şekilde adımımız çok küçük olursa minimum hata değerine ulaşmamız daha fazla zaman alır veya ulaşmamız mümkün olmayan sürelere çıkabilir.
Tabiki de başlangıçta adımlarımızı büyük tutup daha sonrasın küçültebiliriz. Bu yönteme de learning rate decay ( öğrenme hızı azalması ) denir ve bu yöntemde learning rate her bir kaç epochta bir azalmaktadır.

Neden Ağırlıklar Rastgele Başlatılır?

Eğer ağımızdaki her bir nöron aynı değer ile başlamış olsaydı ağımızın ağırlıkları geniş bir uzaya yayılamaz ve ağımızın genelleme yeteneği kısıtlı kalırdı. Yani daha fazla epoch gerektirirdi ki bazı durumlarda daha fazla epoch da çözüm olmayabilirdi. Bir diğer bakış açısıyla eğer nöronlarımızı simetrik, veya bir formüle dayalı olarak başlatsaydık, yine aynı durumla karşılaşırdık. Ağımızın ağırlıkları geniş bir uzaya yayılamaz ve o formülün etrafında sıkışıp kalırdı.
Bu yüzden ağırlıklarımızı rastgele başlatıyoruz ki ağımızın parametreleri daha geniş bir uzaya yayılsın ve ağımızın genelleme yeteneği artmış olsun.

Modelimi İstediğim Kadar Büyütemez Miyim?

Evet modelimizi istediğimiz kadar büyütebiliriz. Ancak bu durumda daha büyük işlem kapasitesine sahip cihazlar gerekmektedir.
Örneğin ben bu modelde hem oyunu öğrenebilecek kadar büyük hem de mobil cihazların işlem kapasitesinde de çalışabilicek kadar küçük seçmeye, yani bir denge kurmaya çalıştım.

Tabiki tek sorun bu değil, daha büyük modeller küçük problemlerde daha fazla overfitting ( aşırı öğrenme, ezberleme ) yapabilirler. Overfitting durumu modelin kendisine verilen verisetini ezberlemesi ve genelleme yapma yeteneğinin körelmesi anlamına gelmektedir. Ayrıca büyük modeller daha fazla veri ile eğitilmelidirler.
Bu yüzden modelimizin sizeunu problemin büyüklüğüne ve karmaşıklığına göre seçmeliyiz.

Modelin Kodları

Sigmoid fonksiyonu ve türevinin tanımlanması ve gerekli random fonksiyonları


        



        function random(min, max) {
            return Math.floor(Math.random() * (max - min)) + min;
        }
        function random_f(min, max) {
            let df = 0;
            while (df == 0) {
                df = random(min, max) * Math.random();
            }
            return df;
        }
        function sigmoid(x) {
            return 1 / (1 + Math.exp(-x));
        }
        function sigmoid_derivative(y) {
            return y * (1.0 - y);
        }
        
        
            
                    
        

Katman sınıfının oluşturulması





        class Layer {
            constructor(size) {
                this.z_ = Array(size);
                this.delta_ = Array(size)
                this.biases = [];
                this.weights = [];
                for (let i = 0; i < size; i++)
                    this.biases.push(random_f(-4, 4));
            }
        }
            
        
        

Modelin oluşturulması


        
        
        
        class model_DL {
            constructor(layer_sizes, learning_rate = 0.6, labels, first_weights = 0) {
                this.Layers = [];
                this.layer_sizes = layer_sizes;
                this.weights = {};
                this.learning_rate = learning_rate;
                this.labels = labels;
                this.Layers.push(new Layer(layer_sizes[0]));
                for (let i = 1 ; i < layer_sizes.length;i++) {
                    let size = layer_sizes[i];
                    let layer = new Layer(size);
                    for (let j = 0; j < size; j++) {
                        let w_ = [];
                        for (let p = 0; p < layer_sizes[i-1]; p++){
                           w_.push(random_f(-4, 4));
                        }
                        layer.weights.push(w_);
                    }
                               this.Layers.push(layer);
                }
                let ind = 0;
                if (first_weights != 0) {
                    for (let p of this.Layers) {
                        for (let a = 0; a < p.weights.length; a++) {
                            for (let i = 0; i < p.weights[a].length; i++) {
                                p.weights[a][i] = first_weights[ind];
                                ind++;
                            }
                        }
                    }
                 }
            }
        }
        
        var model_hidden_layer_sizes = [2, 12, 10, 8, 3];
        var model = new model_DL(layer_sizes = model_hidden_layer_sizes, 
        learning_rate = 6e-1, 
        labels = ["sag", "sol", "sabit"]);

            
        
        

İleri besleme algoritmasının tanımlanması





        get_activ(x_data, weights, b) {
            let g = x_data.map(function (x, index) {
                return weights[index] * x
            })
            let sum_ = g.reduce((sum_, x) => sum_ + x) + b
            return sigmoid(sum_);
        }
        set_data(x) {
            this.katmanlar[0].z_ = x.concat();
            let i = 0;
            while (i < this.katmanlar.length - 1) {
                let katman = this.katmanlar[i];
                let skatman = this.katmanlar[i + 1];
                let p = 0;
                while (p < skatman.z_.length) {
                    this.katmanlar[i + 1].z_[p] = this.get_activ(katman.z_, 
                    skatman.weights[p], skatman.bler[p]);
                    p++
                }
                i++
            }
        }        
            
           
        
        

Predict (tahmin) fonksiyonunun tanımlanması





        output_layer() {
            return this.Layers[this.Layers.length - 1];
        }
        predict(x) {
            this.set_data(x);
            return this.labels[this.output_layer().z_.indexOf(Math.max(...this.output_layer().z_))];
        }
        

Geriye besleme algoritmasının tanımlanması





        epoch(x_data, y_data) {
            let index = 0;
            while (index < x_data.length) {
                let x = x_data[index];
                let y = y_data[index];
                this.predict(x);
                let i = 0;
                while (i < this.output_layer().z_.length) {
                    let h = this.output_layer().z_[i];
                    let y_actual = y == this.labels[i] ? 1 : 0;
                    let delta = sigmoid_derivative(h) * (h - y_actual);
                    this.Layers[this.Layers.length - 1].delta_[i] = delta;
                    i++;
                }
            
                let g = this.Layers.length - 2;
                while (g > -1) {
                    let delta_ = this.Layers[g + 1].delta_;
                    let after_layer = this.Layers[g + 1];
                    let layer_ = this.Layers[g];
                    this.Layers[g].delta_ = this.Layers[g].z_.map(function (x1, index1) {
                        return after_layer.weights.map(function (x, index) { return x[index1] * delta_[index] }).reduce((sum_, x) => sum_ + x) * sigmoid_derivative(layer_.z_[index1]);
                    });
                    g--;
                }
                i = 1;
                while (i < this.Layers.length) {
                    let delta_ = this.Layers[i].delta_;
                    let learning_rate = this.learning_rate;
                    let Layers = this.Layers;
                    let gradients = delta_.map(function (x, index) {
                        return Layers[i - 1].z_.map(function (x1, index) {
                            return x * x1 * learning_rate;
                        });
                    });
                
                    this.Layers[i].weights = this.Layers[i].weights.map(function (x, index1) {
                        return x.map(function (x, index) {
                            return x - gradients[index1][index];
                        });
                    }).concat();
                    this.Layers[i].biases = this.Layers[i].biases.map(function (x, index) { return x - (delta_[index] * learning_rate) })
                    i++;
                }
                index++
            }
        }

        
        

Poqob pp

2024-03-28

Kanki süper açıklamışsın teşekkür ederim.