首页 > Illustrator > 想学ai怎么样入门-面向开发人员的 AI 强化学习入门指南
2024
08-21

想学ai怎么样入门-面向开发人员的 AI 强化学习入门指南

本文是我学习过程的一个总结,希望对大家有所帮助。

最近,AI变得非常受欢迎,所以我突然对AI感兴趣,所以我开始了AI学习之旅,我从来没有学习过机器学习想学ai怎么样入门,基本上对AI一无所知,但幸运的是,在这个信息爆炸时代,我可以通过在互联网上搜索来找到很多学习材料。

点赞这个链接:资料很多,但就像大海捞针。学习之前,我们首先要明确我们的目的是什么。比如这篇文章的标题是强化学习入门,那么我们需要定义什么是入门。在众多强化学习中,学习深度强化学习的第一个算法就是DQN。这个算法也是简单易学、实用的,所以这篇文章旨在把它作为入门来了解。下面是我的学习计划

如果您没有像我这样的基本知识,几乎忘记了所有概率理论和线性代数的知识,那么您可以看一下相关课程,如果您不关注该公式,则可以忽略这一步骤。

如果你对机器学习没有基础知识,可以先看 Andrew Ng 的课程大致了解,再看李宏毅的课程作为补充。如果只是想学习强化学习,只需要看神经网络之后的前面几节即可。本视频课程预计需要 25 小时左右。

完成课程后,你可以跟着《动手深度学习》这本书学习上面我们学到的概念并编写一些代码。如果你刚刚入门,只需阅读前五章即可。本文中的很多信息也是从这本书中整理出来的,大约需要 10 个小时。

接下来可以看看Bilibili上王树森的深度学习课程,可以先看前面几节,学习强化学习的基本知识点,大概5个小时;

在这个阶段,你可能还很困惑,需要开始做一些项目了。你可以读一下《动手强化学习》这本书,它是开源的。我只读了 DQN 部分,大概花了十个小时。

概念

对于新学习一个基础内容,我们先从概念开始。

强化学习能做什么?

加固学习(RL)是机器学习的重要分支。

游戏:强化学习在游戏领域取得了许多突破性成果,例如 DeepMind 的 AlphaGo 在围棋比赛中击败世界冠军,OpenAI 的 Dota 2 AI 在电子竞技比赛中击败职业选手。这些成功表明强化学习可以帮助智能体学习复杂的策略和行为,甚至超越人类的表现。

机器人技术:强化学习在机器人技术领域中具有广泛的应用,例如机器人控制,导航和自主学习。

自动驾驶:强化学习可用于自动驾驶汽车的控制和决策,通过与环境的互动,自动驾驶汽车可以学会在复杂的道路环境中保持安全行驶、避开障碍物、遵守交通规则。

推荐系统:强化学习可用于个性化推荐系统,通过学习用户行为和偏好来智能地推荐合适的内容。例如,网站可以使用强化学习算法来优化新闻、广告或产品推荐,以提高用户满意度和留存率。

自然语言处理:强化学习也广泛用于自然语言处理领域,例如对话系统,机器翻译,文本摘要等。通过强化学习,该模型可以学会生成更符合人类语言习惯并提高语言理解和产生质量的文本。

资源管理:强化学习可以用来优化资源管理问题,例如数据中心的能源管理、通信网络中的流量调度等。通过学习和优化策略,强化学习可以实现资源的高效利用,降低成本,提高性能。

金融:强化学习在金融领域也有一定的应用,比如股票交易、投资组合优化等,通过强化学习,代理可以学会根据市场变化调整投资策略,从而实现收益最大化。

以上是Chatgpt告诉我的强化学习的应用。

强化学习玩只狼;

强化学习玩空洞骑士;

无论如何,我认为上面的内容非常酷(绝对不是因为我不擅长)。简而言之,强化学习(RL)是一种机器学习算法,用于描述和解决涉及代理与环境之间交互的问题。在强化学习中,代理通过不断与环境交互、观察环境并执行动作来学习最佳策略,以实现最大化某个累积奖励的目标。

强化学习的三个要素

具体来说,强化学习通常涉及以下三个要素:

状态:描述代理所处环境的状态。

行动(ACTION):智能方可以采取的行动。

奖励:智能体根据动作和观察结果获得的奖励。

强化学习的核心思想是基于试错学习,即智能体通过尝试不同的动作并观察结果来逐步调整自己的行为策略,以获得更高的奖励。通常强化学习算法使用奖励或价值函数来评估某种行为策略的好坏,并在学习过程中不断更新和调整策略,以达到最大化累积奖励的目标。

构建一个只有行动和奖励的环境

我们可能很难从一开始就直接从三个元素开始吉祥物设计,因此我们可以从一个只有动作和奖励的简单环境开始,并且是一个多臂强盗(MAB)问题。

大家应该都接触过老虎机,这里我们假设这些老虎机都是纯老虎机,每次中奖的概率和拉杆的次数无关,一共有K台老虎机,每台老虎机的奖励概率都不一样,每拉一个杆,都可以从该杆对应的奖励概率分布中获得一个奖励r。

每当您选择一台插槽机,然后将其与动作A相对应。

由于我们拉动的杠杆并不总是最好的,因此拉动当前杠杆和最佳杠杆之间的预期奖励有所不同,我们也称为“遗憾”。

为了最大化累积奖励,我们需要计算老虎机的预期奖励。

The reason for the above -mentioned increase in the number of incremental expectations is actually the following formula (1) to derive:

希望更新的问题解决了,那么我们该处理行动的问题,那么在这台老虎机中我们应该选择哪个?我们看看下面ε贪心方法。

ε-贪婪方法

对于一台有 10 个臂的老虎机,我们必须拉动所有的杠杆才能知道哪个杠杆可能获得最大的奖励。通常,如果我仅依靠有限数量的交互观察中已知的信息,当前最优的杠杆可能不是全局最优的,这也很容易理解。我们需要增加探索方法来采取行动,即让我们选择的动作有一定的概率去尝试一些未尝试过的选项,这样我们才有可能找到期望值更高的选项。

因此,在ε-greedy方法中,尝试未选择的操作称为探索,并且在现有数据中找到最大的期望值称为剥削方法。

例如,将ε设置为0.1,以确保10%的时间用于探索,而90%的时间用于剥削,因为勘探始终在进行中,而不是限制在当前的互动观察中,当时尝试的数量足够大,目前的最佳杆可以接近最佳杆:

但是,如果ε的价值保持不变,则实际上会有问题,如果设置为0.1,那么无论它是多么最佳,都有10%的概率是随机的。

随着实验次数的增加,我们对期望的估计会越来越准确。对于有限 K 臂老虎机的情况,我们可以尝试选择一种贪婪算法,其中 ε 值随时间衰减。例如,我们可以将其设置为反比例衰减,公式为:ε = 1/t。这样,我们可以看到这个算法比原始随机值明显更好:

最后我们根据上面的方法逐步计算出了每个老虎机的期望,用期望了一张表,然后我们就可以根据这个最大期望的老虎机进行行动了。

These methods mentioned above are not state. Let's join the state to see how to train.

Satisfy three elements of limited step environment

在这里,我们还添加了状态,也就是说,在每个动作之后,代理的状态将会改变,因此应如何做出决定?

马尔可夫决策过程(MDP)

在我们继续之前,让我们首先看一下MDP是什么。

我们用St来表示所有可能状态组成的状态集。例如某一时刻t的状态St通常依赖于时刻t之前的状态。我们表示在已知历史信息(S1,…St)的情况下,下一时刻的状态为St+1的概率。当且仅当某一时刻的状态只依赖于前一时刻的状态时,称随机过程具有马尔可夫性,表示为:

也就是说,下一个状态仅取决于当前状态,而不受过去状态的影响。

我们通常使用元组(S,P)来描述Markov过程,其中S是有限的状态,P是状态过渡矩阵。

The element in the i-th row and j-th column of the matrix P is:

表示从状态转移到状态的概率,我们称为状态转移函数。从某个状态出发,到达其他的概率和必须为1,即状态转移矩阵P的每一行的和为1。

通过将奖励功能和折扣因子γ添加到马尔可夫过程中,我们可以在马尔可夫奖励过程中获得马尔可夫奖励过程。

上面的公式实际上与对未来的预测相一致,因此我们的退货折扣会更大,因此我们每次推动时都会将折扣因子乘以一个。

In the Markov reward process, the expected return of a state (ie the expected future cumulative reward starting from this state) is called the value of this state. The values ​​of all states constitute the value function. The input of the value function is a certain state, and the output is the value of this state. We write the value function as:

状态,输出是这个状态的值。

一方面,即时奖励的期望正是奖励函数的输出

其中,即时奖励的期望就是奖励函数的输出;另外,等式中剩余部分可以根据从状态出发的转移概率得到,即可以得到:

The above equation is the famous Bellman equation in the Markov reward process. We represent the value of all states as a column vector, so we can write the Bellman equation in matrix form:

马尔可夫决策过程(MDP)将动作A添加到上述马尔可夫奖励过程(MRP)。那么马尔可夫决策过程(MDP)由元组(S,A,P,r,γ)组成,其中:

MDP obtains St+1 and Rt based on the reward function and state transition function and feeds back to the agent. The agent's goal is to maximize the cumulative reward, so it will select an action function from the action set A according to the current state, which is called a strategy. This strategy needs to be trained by the agent during the action process.

The above strategy description may be relatively abstract. Let's take an example. When we are playing the Cliff Walking game below, our goal is to walk from the lower left corner to the lower right corner. The current state is the position below. Then I can take three actions: left, right, and down. So which action should I choose to maximize my game benefits is the strategy.

The policy of an agent is usually represented by letters. The policy is a function that represents the probability of taking action a under the input state s. In MDP, due to the existence of the Markov property, the policy only needs to be related to the current state and does not need to consider the historical state.

我们用表示在MDP中基于策略的状态函数(状态值函数),定义为从状态出发遵循策略能够获得期望的值的回报,数学表达式为:

在MDP中,由于动作的存在,我们额外定义了一个动作价值函数(action-value function)。我们用表示在MDP遵循策略π时,对当前状态执行动作得到的期望返回:

现在在使用π策略中,状态的相等在该状态下基于策略π采取所有动作的概率与相应的价值价值相乘再求和的结果:

使用 π 时,状态下采取动作一个值等于即时奖励加上经过衰减后的所有可能的下一个状态的策略概率与相应的值的乘积:

通过简单的推导,我们可以得到两个价值函数,即贝尔曼期望方程公式(3):::

上面两个函数需要记住,后来的一些增强学习算法都是由此衍生而来的。

学习的目标通常是找到一个策略,使智能体从初始状态出发能够获得最多的期望回报。那么我们就需要找到最优的状态价值函数,以及强化的动作价值函数,可以记为:

为了使最大,我们需要在当前的状态动作对(s,a)之后都执行最优策略。于是我们得到了最优状态值函数和最优动作值函数之间的关系:

The optimal state value is to choose the state value of the one that makes the best action value at this time: the state value:

In fact, we can also draw Belman's optimal equation formula (4):

上面这个求解看起来很好,但是实际上我们需要知道所有的状态转移函数和奖励函数,这在很多情况下是无法实现的,所以我们需要使用蒙特卡洛方法做一个估计估计。

蒙特卡罗方法

蒙特卡洛方法它是一种通过随机污染物来解决统计问题的方法。其基本思想是通过在特定区域内进行随机污染物,然后根据污染物结果得出一定量的值。

比如下面我们需要计算一个半径为1的圆的面积,我们知道圆的面积公式为A=πr²,其中r是半径,在这个例子中半径r=1,而我们其实是想估算出π的值,所以我们可以在下面的正方形中随机采样大量的点,统计出落在圆内的点的数量,然后除以采样点的总数,这个比例就是圆的面积与正方形面积的比值。随着采样点数量的增加想学ai怎么样入门,我们的估算值会越来越接近圆的真实面积,也就是π*r²,然后我们就可以根据面积和半径计算出π的值了。

So we can extend it, the value expectations of the state are equal to

在蒙特卡洛方法中,我们从状态开始模拟 N 个统计,每个统计的累积折扣回报分别为 G_1, G_2, …, G_N。状态的价值估计 V(s) 可以表示为:

计算返回的期望时,除了可以把所有的返回加起来除以次数,还有一种增量更新的方法。对于每个状态S和返回G,进行如下计算公式(5):

这种增量更新的方式我们在上面已经讲过,只是将它推导到价值函数中。根据上面的公式,我们将替换成α,表示对价值估计更新的步长,可以将它取为一个常数,这样可以在当前步结束即可进行计算。又有下面公式(6):

因此,您可以使用当前获得的奖励加上下一个状态的价值来估计收益作为当前状态的回报,即:

其中通常被称为时序差分(temporal difference,TD)误差(error),时序差分算法将其与步长的乘积作为状态价值的更新量,这样我们只需要知道下一个状态的价值和当前状态的价值是多少就可以更新最新的价值了。下面我会用这个公式带入到我们的强化学习的算法中。

Q 学习算法

到这里来到了我们第一个强化学习算法,它是我们下面将要讲到的DQN 的弱化版本。Q-Learning是强化学习算法中value-based的算法,value 指的就是我们上面提到的动作价值Q(s,a),就是在某一个时刻的状态s 下,采取动作a 能够获得收益的期望。

为了能够得到这个收益的期望,Q-learning 使用了一张表,里面记录了所有的状态和动作所能带来的Q 值,我们把这张表叫做Q 表。比如对于我们的悬崖漫步来说共有48 个格子加上上下左右4 个动作:

Then we can use it to initialize our form, vertical coordinates indicate grid serial number, horizontal coordinates indicate action:

比如当前agent在s1状态,那么接下来要采取的action就会是在这个table中找Q值,如果向下的action最大,那么就往下走。这时候我们把Q(s1, down)乘以一个衰减值gamma(比如0.9),再加上到达下一个状态时得到的reward R,这个值就作为Q(s1, down)在现实中的值,也就是‘Q reality’。而之前根据Q table得到的Q(s1, down)就是Q table预估的值,也就是‘Qestimate’。有了Q reality和Qestimate,就可以更新Q(s1, down)的值了。公式如下:

上面的公式中就是Q Reality,α 是学习率表情包设计,来这次的托盘有多少是要被学习的,alpha 是一个小于1 的数,γ 是决定未来奖励的衰减值,我们在上面介绍过。

那么有了这个公式在加上我们每一步中得到的四元组就可以不停的更新我们的Q 表,数据中的S 和A 是给定的条件,R 和S'皆由环境采样得到。Q-learning 算法的具体流程如下:

结束于

The following code is generated by ChatGPT4. I changed it a little:

import numpy as np

import gymnasium as gym
env = gym.make('CliffWalking-v0',render_mode="human")
ACTIONS = env.action_space.n

# 定义环境参数
ROWS = 4
COLS = 12
# 定义 Q-learning 参数
EPISODES = 500
ALPHA = 0.1
GAMMA = 0.99
EPSILON = 0.1
# 初始化 Q-table
q_table = np.zeros([ROWS*COLS, ACTIONS])

# 定义一个函数根据 Q-table 和 ε-greedy 策略选择动作
def choose_action(state, q_table, epsilon):
    if np.random.random() < epsilon:
     # 随机选择一个动作
      return np.random.randint(ACTIONS)
    else:
     # 选择具有最大 Q 值的动作
       return np.argmax(q_table[state])

# 定义一个函数实现 Q-learning 算法
def q_learning():
    # 进行多次训练以更新 Q-table
    for episode in range(EPISODES):
        # 从起始状态开始
        state, _ = env.reset()
        done = False
        count = 0
        # 在每个训练回合中不断采取动作,直到达到终点
        while not done and count < 200:
            count += 1
            # 选择一个动作
            action_index = choose_action(state, q_table, EPSILON)
            # 获取下一个状态、奖励和是否到达终点
            next_state, reward, done, _, _ = env.step(action_index)

            # 更新 Q-table
            q_table[state][action_index] = q_table[state][action_index] + ALPHA * (
                reward + GAMMA * np.max(q_table[next_state]) - q_table[state][action_index]
            )
            # 更新当前状态
            state = next_state
    # 返回训练好的 Q-table
    return q_table

q_learning()

After starting our program training 100 times, you can find that you can actually play the game very well:

深度强化学习 DQN

在上面我们讲了在Q-learning 算法中我们以矩阵的方式建立了一张存储每个状态下所有动作Q 值的表格。表格中的每一个动作价值Q(s,a)表示在状态下选择动作然后继续遵循某一策略预期能够得到的期望回报。

然而,这种用表格存储动作价值的做法只在环境的状态和动作都是离散的,并且空间都比较小的情况下适用,如果是状态或者动作数量非常大的时候,这种做法就不适用了。

价值函数的方法是通过使用Q(s,a)通过使用函数而不是Q表来求解过度的状态空间。

其中w 称为权重,也就是我们在神经网络里面需要训练收敛的值,在上面的Q-learning 中我们的强化学习是训练Q 表,在神经网络里面训练收敛的就是w 值。通过神经网络和Q-learning 结合就是DQN(Deep Q-Network)了。

在Q-learning 中我们更新Q 表是利用每步的reward 和当前Q 表来迭代的,那么同样我们也可以用这种方法来设计我们的Loss Function:

上面的公式其实就是一个均方误差,真实值与预测值之间的差的平方,和我们上面的Q-learning 时序差分(temporal difference,TD)函数其实很像。

有了上面的公式之后我们就可以像Q-learning 一样利用四元组来训练我们的模型了。但是在一般的有监督学习中,假设训练数据是独立同分布的,我们每次训练神经网络的时候从训练数据中随机采样一个或若干个数据来进行梯度下降,随着学习的不断进行,每一个训练数据会被使用多次。

为了更好地将Q-learning 和深度神经网络结合,DQN 算法采用了经验回放(experience replay)方法,具体做法为维护一个回放缓冲区,将每次从环境中采样得到的四元组数据存储到回放缓冲区中,训练Q 网络的时候再从回放缓冲区中随机采样若干数据来进行训练。

因此在更新网络参数的同时目标也在不断地改变,这非常容易造成神经网络训练的不稳定性,DQN 便使用了目标网络和训练网络一起。

具体过程如下:

Let's take Cartpole as an example to explain the code of DQN.

For CartPole, the output mainly consists of four:

数值服务最小最大

卡位

-4.8

4.8

推车速度

—INF

信息文件

极点角度

〜-0.418弧度(-24°)

~ 0.418 弧度(24°)

极点角速度

—INF

信息文件

行动也只有两个,向左或向右,所以我们的模型也可以构建的很简单。下面来看看具体的代码,代码也是用chatgpt 生成的,我稍微改了一下。我们的DQN 的网络模型采用一层128 个神经元的全连接并以ReLU 作为激活函数,由于游戏不是很复杂所以选用简单的两层网络结构就行了:

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import random
import gymnasium as gym

class DQN(nn.Module):
    def __init__(self, input_dim, output_dim):
        super(DQN, self).__init__()
        self.fc1 = nn.Linear(input_dim, 128)
        self.fc2 = nn.Linear(128, output_dim)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = self.fc2(x)
        return x

# 超参数
num_episodes = 2000 #循环次数
current_epoch  = 0 #目前循环的次数
learning_rate = 0.001 #学习率
gamma = 0.99   # 折扣因子
epsilon_start = 1.0  # epsilon-贪婪策略 开始值
epsilon_end = 0.01  # epsilon-贪婪策略 最小值
epsilon_decay = 0.995 # epsilon-贪婪策略 下降值

# 创建环境
env = gym.make("CartPole-v1", render_mode="rgb_array")
# 初始化DQN模型
input_dim = env.observation_space.shape[0]
output_dim = env.action_space.n

# 初始化训练网络
model = DQN(input_dim, output_dim)
# 设置优化器
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
# 加载上次训练的参数
if os.path.exists("model.pth"):
    checkpoint  = torch.load("model.pth")
    model.load_state_dict(checkpoint['net'])
    optimizer.load_state_dict(checkpoint['optimizer'])
    current_epoch = checkpoint["epoch"]
    print("load succ")
# 初始化目标网络
target_model = DQN(input_dim, output_dim)
target_model.load_state_dict(model.state_dict())

We also need a cache area to store data sampled from the environment:

class ReplayBuffer:
    ''' 经验回放池 '''
    def __init__(self, capacity):
        self.buffer = collections.deque(maxlen=capacity)  # 队列,先进先出

    def add(self, state, action, reward, next_state, done):  # 将数据加入buffer
        self.buffer.append((state, action, reward, next_state, done))

    def sample(self, batch_size):  # 从buffer中采样数据,数量为batch_size
        transitions = random.sample(self.buffer, batch_size)
        state, action, reward, next_state, done = zip(*transitions)
        return np.array(state), action, reward, np.array(next_state), done
    def size(self):
        return len(self.buffer)

然后就是我们的训练函数,从缓存区域分批获取数据,使用DQN算法进行训练:

def train(replay_buffer, batch_size):
    if replay_buffer.size() < batch_size:
        return
    # 批量获取参数
    states, actions, rewards, next_states, dones = replay_buffer.sample(batch_size)

    states = torch.tensor(states, dtype=torch.float)
    actions = torch.tensor(actions).view(-11)
    rewards = torch.tensor(rewards,   dtype=torch.float).view(-11)
    next_states = torch.tensor(next_states,   dtype=torch.float)
    dones = torch.tensor(dones,  dtype=torch.float).view(-11)

    # 训练网络 Q 值
    q_values = model(states).gather(1, actions)
    # 目标网络 Q 值
    next_q_values = target_model(next_states).max(1)[0].view(-11)
    target_q_values = rewards +  gamma * next_q_values * (1 - dones) # TD误差目标
    loss = torch.mean(torch.nn.functional.mse_loss(q_values, target_q_values))  # 均方误差损失函数

    optimizer.zero_grad() # PyTorch中默认梯度会累积,这里需要显式将梯度置为0
    loss.backward() # 反向传播更新参数
    optimizer.step()

最后就是我们的主循环函数了,在每个episode 中,我们选择一个动作(使用ε-greedy 策略),执行该动作,并将结果存储在replay buffer 中:

# 主循环
replay_buffer = ReplayBuffer(10000)
epsilon = epsilon_start
batch_size = 64
bestReward = 500
for episode in range(current_epoch,num_episodes):
    state,_ = env.reset()
    done = False
    episode_reward = 0

    while not done:
        # 选择动作
        if random.random() < epsilon:
            action = env.action_space.sample()
        else:
            state_tensor = torch.FloatTensor(state)
            q_values = model(state_tensor)
            action = torch.argmax(q_values).item()

        # 执行动作
        next_state, reward, done,_, _ = env.step(action)
        if reward>bestReward:
            bestReward = reward
        # 添加经验到replay buffer
        replay_buffer.add(state, action, reward, next_state, done)

        # 更新状态
        state = next_state
        episode_reward += reward

        # 训练DQN
        train(replay_buffer, batch_size)

    # 更新目标网络
    if episode % 10 == 0:
        target_model.load_state_dict(model.state_dict())
        if  reward > bestReward: #保存已训练好的参数和优化器
            print("save cart")
            state_dict = {"net": target_model.state_dict(), "optimizer": optimizer.state_dict(), "epoch": episode}
            torch.save(state_dict, "model.pth")

    # 更新epsilon
    epsilon = max(epsilon_end, epsilon * epsilon_decay)

    print(f"Episode {episode}, Reward: {episode_reward}")

env.close()

After training, use the saved Model.pth parameter to actually use it:

# 创建环境
env = gym.make("CartPole-v1", render_mode="human")
# env = gym.make("CartPole-v1", render_mode="rgb_array")
# 初始化DQN模型
input_dim = env.observation_space.shape[0]
output_dim = env.action_space.n

# 初始化训练网络
model = DQN(input_dim, output_dim)
if os.path.exists("model.pth"):
    checkpoint = torch.load("model.pth")
    model.load_state_dict(checkpoint['net'])
    print("load succ")

state,_ = env.reset()
done = False
while not done:
    state_tensor = torch.FloatTensor(state)
    q_values = model(state_tensor)
    action = torch.argmax(q_values).item()
    # 执行动作
    next_state, reward, done, _, _ = env.step(action)
    state = next_state

总结

这一篇文章我们从最开始的K 臂老虎机入手讲解了强化学习的基本原理,然后切入到Q-learning 中学习如何使用Q 表来进行强化学习,最后再借助神经网络将Q 表替换成用函数来拟合计算Q 值。

参考

%E4%BB%8Ethompson-sampling%E5%88%B0%E5%A2%9E%E5%BC%BA%E5%AD%A6%E4%B9%A0-%E5%86%8D%E8%B0%88%E5%A4%9A%E8%87%82%E8%80%81%E8%99%8E%E6%9C%BA% E9%97%AE%E9%A2%98-23a48953bd30

%E8%92%99%E5%9C%B0%E5%8D%A1%E7%BE%85%E6%96%B9%E6%B3%95

最后编辑:
作者:nuanquewen
吉祥物设计/卡通ip设计/卡通人物设计/卡通形象设计/表情包设计