Форум программистов, компьютерный форум, киберфорум
Наши страницы
Storm23
Войти
Регистрация
Восстановить пароль
Рейтинг: 5.00. Голосов: 8.

Neural Network Racing - Part 3

Запись от Storm23 размещена 11.04.2018 в 01:40

После создания модели автомобиля и трека все готово для главного - создания ИИ на базе нейронной сети для управления машиной.

Нейронная сеть

Для управлением автомобилем, будем использовать полносвязную нейронную сеть с тремя слоями. Топология 6-6-6-3.
Число входных нейронов - 6, число выходных - 3.

На вход нейронной сети будем подавать показания пяти сенсоров (то есть расстояния до ближайших стен трека) и шестой показатель - собственная скорость автомобиля в данный момент.
Три выходных нейрона будут определять угол поворота руля, выжимание газа, и тормоз.
Все выходные нейроны могут выдавать значения от -1 до 1. Для входных нейронов ограничений на значения - нет.

Наша сеть будет выглядеть так:



Изначально веса связей задаются случайным образом.
Полный код нейронной сети:
Класс NeuralNetwork
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
using System;
using System.Collections.Generic;
using System.IO;
 
namespace GeneticNN_NS
{
    /// <summary>
    ///     Class representing a fully connected feedforward neural network.
    /// </summary>
    public class NeuralNetwork
    {
        #region Constructors
 
        /// <summary>
        ///     Initialises a new fully connected feedforward neural network with given topology.
        /// </summary>
        /// <param name="topology">
        ///     An array of unsigned integers representing the node count of each layer from input to output
        ///     layer.
        /// </param>
        public NeuralNetwork(ActivationFunctionType activationFunction, params uint[] topology)
        {
            Topology = topology;
 
            //Calculate overall weight count
            WeightCount = 0;
            for (var i = 0; i < topology.Length - 1; i++)
                WeightCount += (int) ((topology[i] + 1) * topology[i + 1]); // + 1 for bias node
 
            //Initialise layers
            Layers = new NeuralLayer[topology.Length - 1];
            for (var i = 0; i < Layers.Length; i++)
                Layers[i] = new NeuralLayer(topology[i], topology[i + 1])
                {
                    NeuronActivationFunctionType = activationFunction
                };
        }
 
        #endregion
 
        #region Members
 
        /// <summary>
        ///     The individual neural layers of this network.
        /// </summary>
        public NeuralLayer[] Layers { get; }
 
        /// <summary>
        ///     An array of unsigned integers representing the node count
        ///     of each layer of the network from input to output layer.
        /// </summary>
        public uint[] Topology { get; }
 
        /// <summary>
        ///     The amount of overall weights of the connections of this network.
        /// </summary>
        public int WeightCount { get; }
 
        #endregion
 
        #region Methods
 
        /// <summary>
        ///     Processes the given inputs using the current network's weights.
        /// </summary>
        /// <param name="inputs">The inputs to be processed.</param>
        /// <returns>The calculated outputs.</returns>
        public float[] ProcessInputs(float[] inputs)
        {
            //Check arguments
            if (inputs.Length != Layers[0].NeuronCount)
                throw new ArgumentException("Given inputs do not match network input amount.");
 
            //Process inputs by propagating values through all layers
            var outputs = inputs;
            foreach (var layer in Layers)
                outputs = layer.ProcessInputs(outputs);
 
            return outputs;
        }
 
        public void SetWeights(IList<float> weights)
        {
            //Check if topology is valid
            if (WeightCount != weights.Count)
                throw new ArgumentException(
                    "The given genotype's parameter count must match the neural network topology's weight count.");
 
            var iWeight = 0;
            foreach (var layer in Layers) //Loop over all layers
            {
                var c0 = layer.Weights.GetLength(0);
                var c1 = layer.Weights.GetLength(1);
                for (var i = 0; i < c0; i++) //Loop over all nodes of current layer
                for (var j = 0; j < c1; j++) //Loop over all nodes of next layer
                    layer.Weights[i, j] = weights[iWeight++];
            }
        }
 
        public float[] GetWeights()
        {
            var weights = new float[WeightCount];
 
            var iWeight = 0;
            foreach (var layer in Layers) //Loop over all layers
            {
                var c0 = layer.Weights.GetLength(0);
                var c1 = layer.Weights.GetLength(1);
                for (var i = 0; i < c0; i++) //Loop over all nodes of current layer
                for (var j = 0; j < c1; j++) //Loop over all nodes of next layer
                    weights[iWeight++] = layer.Weights[i, j];
            }
 
            return weights;
        }
 
        /// <summary>
        ///     Sets the weights of this network to random values in given range.
        /// </summary>
        /// <param name="minValue">The minimum value a weight may be set to.</param>
        /// <param name="maxValue">The maximum value a weight may be set to.</param>
        public void SetRandomWeights(float minValue, float maxValue)
        {
            if (Layers != null)
                foreach (var layer in Layers)
                    layer.SetRandomWeights(minValue, maxValue);
        }
 
        /// <summary>
        ///     Returns a new NeuralNetwork instance with the same topology and
        ///     activation functions, but the weights set to their default value.
        /// </summary>
        public NeuralNetwork GetTopologyCopy()
        {
            var copy = new NeuralNetwork(ActivationFunctionType.Identity, Topology);
 
            for (var i = 0; i < Layers.Length; i++)
                copy.Layers[i].NeuronActivationFunctionType = Layers[i].NeuronActivationFunctionType;
 
            return copy;
        }
 
        /// <summary>
        ///     Copies this NeuralNetwork including its topology and weights.
        /// </summary>
        /// <returns>A deep copy of this NeuralNetwork</returns>
        public NeuralNetwork DeepCopy()
        {
            var newNet = new NeuralNetwork(ActivationFunctionType.Identity, Topology);
 
            for (var i = 0; i < Layers.Length; i++)
                newNet.Layers[i] = Layers[i].DeepCopy();
 
            return newNet;
        }
 
        /// <summary>
        ///     Returns a string representing this network in layer order.
        /// </summary>
        public override string ToString()
        {
            var output = "";
 
            for (var i = 0; i < Layers.Length; i++)
                output += "Layer " + i + ":\n" + Layers[i];
 
            return output;
        }
 
        public void SaveWeights(Stream stream)
        {
            var bw = new BinaryWriter(stream);
            //version
            bw.Write((byte) 0);
            //number of layers
            bw.Write(Layers.Length);
            //topology
            for (var i = 0; i < Topology.Length; i++)
                bw.Write(Topology[i]);
            //weights
            foreach (var w in GetWeights())
                bw.Write(w);
            //
            bw.Flush();
        }
 
        public void LoadWeightsSafe(Stream stream)
        {
            var bw = new BinaryReader(stream);
            //version
            bw.ReadByte();
            //number of layers
            var layerCount = bw.ReadUInt32();
            //topology
            var topology = new uint[layerCount + 1];
            for (var i = 0; i < topology.Length; i++)
                topology[i] = bw.ReadUInt32();
            //weight count
            var weightCount = 0u;
            for (var i = 0; i < topology.Length - 1; i++)
                weightCount += (topology[i] + 1) * topology[i + 1];
 
            //read weights
            var counter = 0;
            for (var iLayer = 0; iLayer < layerCount; iLayer++)
            {
                var c0 = topology[iLayer] + 1;
                var c1 = topology[iLayer + 1];
                for (var i = 0; i < c0; i++) //Loop over all nodes of current layer
                for (var j = 0; j < c1; j++) //Loop over all nodes of next layer
                {
                    var w = bw.ReadSingle();
                    counter++;
 
                    if (iLayer < Layers.Length)
                    {
                        var layer = Layers[iLayer];
                        if (i < layer.Weights.GetLength(0) && j < layer.Weights.GetLength(1))
                            if (i == c0 - 1) //bias ?
                                layer.Weights[layer.Weights.GetLength(0) - 1, j] = w;
                            else
                                layer.Weights[i, j] = w;
                    }
                }
            }
        }
 
        #endregion
    }
}


Класс NeuralLayer - отдельный слой НС
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
using System;
 
namespace GeneticNN_NS
{
    /// <summary>
    ///     Class representing a single layer of a fully connected feedforward neural network.
    /// </summary>
    public class NeuralLayer
    {
        #region Constructors
 
        /// <summary>
        ///     Initialises a new neural layer for a fully connected feedforward neural network with given
        ///     amount of node and with connections to the given amount of nodes of the next layer.
        /// </summary>
        /// <param name="nodeCount">The amount of nodes in this layer.</param>
        /// <param name="outputCount">The amount of nodes in the next layer.</param>
        /// <remarks>All weights of the connections from this layer to the next are initialised with the default double value.</remarks>
        public NeuralLayer(uint nodeCount, uint outputCount)
        {
            NeuronCount = nodeCount;
            OutputCount = outputCount;
 
            Weights = new float[nodeCount + 1, outputCount]; // + 1 for bias node
        }
 
        #endregion
 
        #region Members
 
        private static readonly Random randomizer = new Random();
 
        /// <summary>
        ///     The activation function used by the neurons of this layer.
        /// </summary>
        /// <remarks>The default activation function is the sigmoid function (see <see cref="SigmoidFunction" />).</remarks>
        public ActivationFunctionType NeuronActivationFunctionType = ActivationFunctionType.SigmoidFunction;
 
        /// <summary>
        ///     The amount of neurons in this layer.
        /// </summary>
        public uint NeuronCount { get; }
 
        /// <summary>
        ///     The amount of neurons this layer is connected to, i.e., the amount of neurons of the next layer.
        /// </summary>
        public uint OutputCount { get; }
 
        /// <summary>
        ///     The weights of the connections of this layer to the next layer.
        ///     E.g., weight [i, j] is the weight of the connection from the i-th weight
        ///     of this layer to the j-th weight of the next layer.
        /// </summary>
        public float[,] Weights { get; private set; }
 
        #endregion
 
        #region Methods
 
        /// <summary>
        ///     Processes the given inputs using the current weights to the next layer.
        /// </summary>
        /// <param name="inputs">The inputs to be processed.</param>
        /// <returns>The calculated outputs.</returns>
        public float[] ProcessInputs(float[] inputs)
        {
            //Check arguments
            if (inputs.Length != NeuronCount)
                throw new ArgumentException("Given xValues do not match layer input count.");
 
            //Calculate sum for each neuron from weighted inputs and bias
            var sums = new float[OutputCount];
 
            for (var j = 0; j < OutputCount; j++)
            {
                float sum = 0;
                for (var i = 0; i < inputs.Length; i++)
                    sum += inputs[i] * Weights[i, j];
 
                //Add bias (always on) neuron to inputs
                sum += 1 * Weights[NeuronCount, j];
 
                sums[j] = sum;
            }
 
            //Apply activation function to sum
            switch (NeuronActivationFunctionType)
            {
                case ActivationFunctionType.SigmoidFunction:
                    for (var i = 0; i < sums.Length; i++)
                        sums[i] = SigmoidFunction(sums[i]);
                    break;
 
                case ActivationFunctionType.TanHFunction:
                    for (var i = 0; i < sums.Length; i++)
                        sums[i] = TanHFunction(sums[i]);
                    break;
 
                case ActivationFunctionType.SoftSignFunction:
                    for (var i = 0; i < sums.Length; i++)
                        sums[i] = SoftSignFunction(sums[i]);
                    break;
 
                case ActivationFunctionType.Identity:
                    break;
            }
 
            return sums;
        }
 
        /// <summary>
        ///     Copies this NeuralLayer including its weights.
        /// </summary>
        /// <returns>A deep copy of this NeuralLayer</returns>
        public NeuralLayer DeepCopy()
        {
            //Copy weights
            var copiedWeights = new float[Weights.GetLength(0), Weights.GetLength(1)];
 
            for (var x = 0; x < Weights.GetLength(0); x++)
            for (var y = 0; y < Weights.GetLength(1); y++)
                copiedWeights[x, y] = Weights[x, y];
 
            //Create copy
            var newLayer = new NeuralLayer(NeuronCount, OutputCount);
            newLayer.Weights = copiedWeights;
            newLayer.NeuronActivationFunctionType = NeuronActivationFunctionType;
 
            return newLayer;
        }
 
        /// <summary>
        ///     Sets the weights of the connection from this layer to the next to random values in given range.
        /// </summary>
        /// <param name="minValue">The minimum value a weight may be set to.</param>
        /// <param name="maxValue">The maximum value a weight may be set to.</param>
        public void SetRandomWeights(float minValue, float maxValue)
        {
            double range = Math.Abs(minValue - maxValue);
            for (var i = 0; i < Weights.GetLength(0); i++)
            for (var j = 0; j < Weights.GetLength(1); j++)
                Weights[i, j] = minValue + (float) (randomizer.NextDouble() * range);
                    //random double between minValue and maxValue
        }
 
        /// <summary>
        ///     Returns a string representing this layer's connection weights.
        /// </summary>
        public override string ToString()
        {
            var output = "";
 
            for (var x = 0; x < Weights.GetLength(0); x++)
            {
                for (var y = 0; y < Weights.GetLength(1); y++)
                    output += "[" + x + "," + y + "]: " + Weights[x, y];
 
                output += "\n";
            }
 
            return output;
        }
 
        #endregion
 
        #region Activation Functions
 
        /// <summary>
        ///     The standard sigmoid function.
        /// </summary>
        /// <param name="xValue">The input value.</param>
        /// <returns>The calculated output.</returns>
        public static float SigmoidFunction(float xValue)
        {
            if (xValue > 10) return 1.0f;
            if (xValue < -10) return 0.0f;
            return 1.0f / (1.0f + (float) Math.Exp(-xValue));
        }
 
        /// <summary>
        ///     The standard TanH function.
        /// </summary>
        /// <param name="xValue">The input value.</param>
        /// <returns>The calculated output.</returns>
        public static float TanHFunction(float xValue)
        {
            if (xValue > 10) return 1.0f;
            if (xValue < -10) return -1.0f;
            return (float) Math.Tanh(xValue);
        }
 
        /// <summary>
        ///     The SoftSign function as proposed by Xavier Glorot and Yoshua Bengio (2010):
        ///     "Understanding the difficulty of training deep feedforward neural networks".
        /// </summary>
        /// <param name="xValue">The input value.</param>
        /// <returns>The calculated output.</returns>
        public static float SoftSignFunction(float xValue)
        {
            return xValue / (1 + Math.Abs(xValue));
        }
 
        #endregion
    }
 
    [Serializable]
    public enum ActivationFunctionType
    {
        Identity,
        SigmoidFunction,
        TanHFunction,
        SoftSignFunction
    }
}


Я не буду подробно останавливаться на том, как работает НС. Это хорошо описано в различных источниках. Например на вики.
Отмечу лишь, что я использую функцию SoftSign как функцию активации нейронов:



Эта функция хорошо подходит для сетей глубокого обучения (deep learning NN).

Число слоев и топология НС 6-6-6-3 были найдены экспериментально.
Если задать меньшее число слоев или уменьшить число нейронов в промежуточных слоях - сеть начинает плохо обучаться и не выходить на приемлемое поведение. Если же увеличить число слоев или нейронов - начинается хаотичное поведение и медленное обучение.

Обучение нейронной сети с помощью генетического алгоритма

Обычно сеть обучается с помощью специальных методов - типа метода обратного распространения ошибки. Но здесь мы будем применять другой подход - генетический алгоритм.

Суть в том, что мы генерируем набор различных случайных нейронных сетей (популяцию) и тестируем их (давая каждой НС по машинке, и давая ей порулить). Далее мы оцениваем эффективность каждой сети по какому либо признаку (для управления автомобилем в гонках я беру максимальный путь, который смогла проехать машинка вдоль трека, не сталкиваясь со стенками).
Далее происходят процессы отбора лучших НС. Из них формируется новая популяция, веса связей в нейронах случайным образом меняются (с помощью мутаций и рекомбинации генов). Новая популяция снова тестируется на треке и процесс повторяется.

В результате, с каждым новым поколением мы отбираем лучшие НС, и эволюционный процесс дает нам все лучшие и лучшие результаты, которые будет показывать наша популяция НС.

Каждое новое поколение НС формируется с помощью трех процедур - селекции, рекомбинации и мутации.

Метод BuildNextGeneration
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
        /// <summary>
        ///     Build next generation of NN
        /// </summary>
        public void BuildNextGeneration()
        {
            //build genotypes
            var genotypes = new List<Genotype>(PopulationCount);
            for (var i = 0; i < PopulationCount; i++)
                genotypes.Add(new Genotype(NNs[i].GetWeights()) {Evaluation = Evaluations[i]});
 
            //calc fitness
            DefaultFitnessCalculation(genotypes);
 
            //sort population
            genotypes.Sort();
 
            //Apply Selection
            var intermediatePopulation = RemainderStochasticSampling(genotypes);
 
            //Apply Recombination
            var newPopulation = RandomRecombination(intermediatePopulation, PopulationCount);
 
            //Apply Mutation
            MutateAllButBestTwo(newPopulation);
 
            //set weights to NN
            for (var i = 0; i < PopulationCount; i++)
                NNs[i].SetWeights(newPopulation[i]);
        }


Рассмотрим их более подробно.

Селекция
Задача селекции - отобрать лучшие генотипы (НС) из популяции и создать на их основе новую популяцию такого же размера как и исходная. Для отбора используется метод, который называется Remainder Stochastic Sampling.

Это происходит так: после того как прошел процесс моделирования, для всех особей (НС) задается оценка их успешности (для гонок - это пройденный путь вдоль трека). Далее считается средняя оценка по популяции. Затем их популяции отбираются только те особи, у которых оценка выше средней (их приблизительно половина). И далее эти особи клонируются, при чем число клонов - пропорционально оценке родителя. Итоговое число популяции при этом делается равным размеру исходной популяции. Таким образом мы отбираем и размножаем лучшие НС, и при этом сохраняем размер популяции.

Мутация
Это простой процесс в котором из популяции выбирается определенное число НС и в них случайные связи меняются на случайную небольшую величину. В результате мы получаем новую НС с немного другими весами:



Рекомбинация
Это процесс обмена генами (то есть весами нейронных связей) между двумя НС. По другому этот процесс также называется кроссинговер или просто скрещивание (crossover).
Для рекомбинации из популяционного набора отбираются случайным образом две НС и происходит обмен весами между ними:



В результате мы получаем две новые особи (НС) которые сочетают в себе связи двух НС - предков.

Для удобства работы с генетическими нейронными сетями, я вынес работу с НС в отдельный проект и создал класс GeneticNN. Этот класс инкапсулирует всю работу по генетическому отбору НС и имеет простой внешний интерфейс.
При создании GeneticNN нужно указать топологию нейронной сети и размер популяции. GeneticNN сам создаст необходимое число нейронных сетей с нужной топологией.
Затем мы начинаем обучение НС, используя всего три метода:
  • public NeuralNetwork GetNN(int index) - получение НС для особи с индексом index
  • public void SetEvaluation(int index, float evaluation) - задание оценки успешности для особи index
  • public void BuildNextGeneration() - генерация следующего поколения НС

Таким образом, алгоритм обучения НС выглядит так:
1) Создаем объект GeneticNN с нужной топологией и нужным размером популяции.
2) Получаем нейронные сети для каждой особи методом GetNN(), и с помощью них проводим моделирование поведения каждой особи.
3) Устанавливаем оценку моделирования для каждой особи методом SetEvaluation.
4) Генерируем новое поколение методом BuildNextGeneration
5) Начинаем следующую итерацию обучения, переходя к пп2.
6) После проведения некоторого числа итераций, отбираем особь с максимальной оценкой. Эта НС и будет лучшим результатом обучения.

Полностью код GeneticNN можно найти в присоединенном файле. Разумеется, GeneticNN можно использовать не только для создания НС управления автомобилем, а для любых задач в которых необходимо обучить и использовать НС.

Результаты обучения нейронной сети

Для обучения сети я сделал специальный трек, который включает в себя как левые повороты так и правые, длинные прямые на которых нужно разгоняться и короткие сегменты дороги где нужно усиленно рулить. Также есть шиканы и шпильки. Этот трек хорошо подходит для универсального обучения. НС которая успешно рулит на этом треке будет хорошо рулить и на других треках.
Размер популяции - 60 особей.

Первая популяция нейронных сетей ведет себя довольно неуклюже. Большинство из них - просто сразу врезается в стены.
Однако уже на 2-3 поколении появляются особи, которые могут более-менее уверенно держаться в рамках трека:

Первые поколения


Машинка лидера выделена желтым цветом. Нижняя строка указывает текущие параметры движения лидера - скорость, состояние тормоза, рулежка.

Еще через пару поколений у нас уже есть НС которые могут проехать трек целиком ни разу не врезавшись в стены:

Десятое поколение

(в конце ролика показан режим быстрого моделирования)

Дальнейшая эволюция идет уже на достижение максимальной скорости преодоления трека.
Через десяток поколений, наша популяция уже ездит вот так:



Видно, что машинки уверенно держат трассу, избегают заносов и умеют из них выходить. На длинных участках они максимально разгоняются, а перед входом поворот - тормозят.

Забегая немного вперед, я могу сказать, что на трассе в игре обогнать этих ботов управляемых НС - очень трудно. Они едут почти идеально, на предельно возможной скорости.

Обучение сети занимает примерно пару минут абсолютного времени.

Обученная сеть на одном треке, также уверенно ездит на других треках.
Вот примеры езды все той-же НС, которая обучалась на первом треке:

На B - образном треке:



На треке с длинными прямыми и шпильками в конце:



На треке в виде восьмерки:



Полный код GeneticNN, редактор треков и обучающий симулятор - в присоединенном файле. Примеры треков - в папке Env.
Также исходники доступны на github: NNRacing.

To be continue ...
Вложения
Тип файла: zip NNRacing.zip (3.62 Мб, 59 просмотров)
Размещено в Без категории
Просмотров 505 Комментарии 2
Всего комментариев 2
Комментарии
  1. Старый комментарий
    Аватар для Sanya_sa
    Все просто и доступно!
    Думаю, если человек может обьяснить сложные вещи простыми словами - значить он действительно в них разбирается.
    А здесь не только слова, но и реальные примеры, которые работают.

    Отличный цикл статей! Спасибо!
    Запись от Sanya_sa размещена 11.04.2018 в 14:06 Sanya_sa вне форума
    Обновил(-а) Sanya_sa 11.04.2018 в 14:06 (опечатка)
  2. Старый комментарий
    Аватар для Storm23
    Цитата:
    Сообщение от Sanya_sa Просмотреть комментарий
    Отличный цикл статей! Спасибо!
    Всегда пожалуйста
    Запись от Storm23 размещена 12.04.2018 в 09:32 Storm23 вне форума
 
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin® Version 3.8.9
Copyright ©2000 - 2018, vBulletin Solutions, Inc.
Рейтинг@Mail.ru