14、AlphaGo Zero:强化学习与树搜索的融合

AlphaGo Zero:强化学习与树搜索的融合

1. AlphaGo Zero简介

在DeepMind推出第二代AlphaGo(代号Master)后,其惊人的棋风引发了全球围棋爱好者的关注。Master的棋局充满了令人惊喜的新招,它虽然基于人类棋局起步,但通过强化学习不断提升,发现了人类未曾使用过的新走法。这自然引出了一个问题:如果AlphaGo完全不依赖人类棋局,仅通过强化学习进行学习,它能否达到超越人类的水平?能否重新发现人类大师的棋路,还是会走出一种难以理解的新风格?2017年AlphaGo Zero(AGZ)的问世回答了这些问题。

AGZ基于改进的强化学习系统,从零开始训练,不借助任何人类棋局。尽管它最初的表现比任何人类初学者都差,但它稳步提升,很快超越了之前的所有版本的AlphaGo。AGZ最令人惊讶的是它以更少的资源实现了更多的功能。与原始的AlphaGo相比,AGZ在很多方面都更加简单,它摒弃了手工特征平面、人类游戏记录和蒙特卡罗滚动算法,仅使用一个神经网络和一个训练过程,却比原始的AlphaGo更强大。这主要得益于两方面:一是使用了超大规模的神经网络,最强版本的网络容量相当于约80个卷积层,是原始AlphaGo网络的四倍多;二是采用了创新的强化学习技术,从一开始就将树搜索与强化学习相结合。

2. 构建用于树搜索的神经网络

AGZ使用具有一个输入和两个输出的单一神经网络。一个输出产生落子的概率分布,另一个输出产生一个表示游戏是否对黑方或白方有利的单一值,这与演员 - 评论家学习的结构相同。不过,AGZ网络的输出与之前的网络有一个小区别,主要体现在游戏中的“跳过”操作上。在之前的自我对弈实现中,需要硬编码“跳过”逻辑来结束游戏,但AGZ在自我对弈中使用树搜索,因此可以将“跳过”视为与其他落子相同的操作,让机器人学习何时“跳过”是合适的。这意味着动作输出需要为棋盘上的每个点和“跳过”操作返回一个概率,网络将产生一个大小为19×19 + 1 = 362的向量来表示棋盘上的每个点和“跳过”操作。

为了实现这一点,需要对棋盘编码器进行轻微修改,使用新的函数 encode_move decode_move_index 来替代之前的 encode_point decode_point_index 。以下是修改后的棋盘编码器代码:

class ZeroEncoder(Encoder):
    ...
    def encode_move(self, move):
        if move.is_play:
            return (self.board_size * (move.point.row - 1) + (move.point.col - 1))
        elif move.is_pass:
            return self.board_size * self.board_size
        raise ValueError('Cannot encode resign move')

    def decode_move_index(self, index):
        if index == self.board_size * self.board_size:
            return Move.pass_turn()
        row = index // self.board_size
        col = index % self.board_size
        return Move.play(Point(row=row + 1, col=col + 1))

    def num_moves(self):
        return self.board_size * self.board_size + 1

除了对“跳过”操作的处理,AGZ网络的输入和输出与之前的网络相同。对于网络的内层,AGZ使用了极深的卷积层堆栈,并采用了一些现代技术来使训练更平滑。由于大型网络需要更多的计算资源,若没有与DeepMind相同的硬件,可尝试使用较小的网络。在棋盘编码方面,可以使用多种编码方案,AGZ使用了最简单的编码器,仅包含黑白棋子的位置和当前轮到谁下棋的信息,但也可以尝试使用特定于游戏的特征平面,这可能会加快学习速度。

3. 用神经网络引导树搜索

在强化学习中,策略是指导智能体做出决策的算法。在之前的强化学习示例中,策略相对简单,而AGZ的策略包含一种树搜索形式。神经网络的作用是引导树搜索,而不是直接选择或评估落子。在自我对弈中加入树搜索,使对弈更加真实,也让训练过程更加稳定。

AGZ的树搜索算法建立在之前的研究基础上,与蒙特卡罗树搜索(MCTS)和原始AlphaGo的树搜索算法有相似之处,以下是三种算法的比较:
|算法|分支选择|分支评估|
| ---- | ---- | ---- |
|MCTS|UCT分数|随机模拟|
|AlphaGo|UCT分数 + 策略网络的先验概率|价值网络 + 随机模拟|
|AlphaGo Zero|UCT分数 + 组合网络的先验概率|组合网络的价值|

树搜索算法的总体思路是找到能带来最佳结果的落子,通过检查后续可能的落子序列来确定。但可能的序列数量极其庞大,因此需要在只检查一小部分可能序列的情况下做出决策。AGZ的树搜索算法在每一轮中向树中添加一个新的棋盘位置,随着轮数的增加,树不断扩大,算法的估计也更加准确。

游戏树中的每个节点代表一个可能的棋盘位置,从该位置可以知道哪些后续落子是合法的。算法会为每个后续落子创建一个分支,无论是否已经访问过。每个分支会跟踪以下信息:
- 落子的先验概率,表示在尝试访问之前对该落子的预期好坏程度。
- 在树搜索期间访问该分支的次数,可能为零。
- 所有通过该分支的访问的预期值,这是通过树的所有访问的平均值,为了便于更新这个平均值,会存储值的总和,然后除以访问次数得到平均值。

以下是定义分支统计信息的代码:

class Branch:
    def __init__(self, prior):
        self.prior = prior
        self.visit_count = 0
        self.total_value = 0.0

接下来是表示搜索树节点的代码:

class ZeroTreeNode:
    def __init__(self, state, value, priors, parent, last_move):
        self.state = state
        self.value = value
        self.parent = parent
        self.last_move = last_move
        self.total_visit_count = 1
        self.branches = {}
        for move, p in priors.items():
            if state.is_valid_move(move):
                self.branches[move] = Branch(p)
        self.children = {}

    def moves(self):
        return self.branches.keys()

    def add_child(self, move, child_node):
        self.children[move] = child_node

    def has_child(self, move):
        return move in self.children

    def expected_value(self, move):
        branch = self.branches[move]
        if branch.visit_count == 0:
            return 0.0
        return branch.total_value / branch.visit_count

    def prior(self, move):
        return self.branches[move].prior

    def visit_count(self, move):
        if move in self.branches:
            return self.branches[move].visit_count
        return 0
4. 树搜索的具体过程
4.1 遍历树

在每一轮搜索中,首先要遍历树,目的是查看可能的未来棋盘位置,以评估其好坏。为了获得准确的评估,需要假设对手会以最强的方式回应。预期值可以估计每个可能落子的好坏,但估计的准确性不同。可以选择深入研究一个最佳变化以进一步改善估计,也可以探索一个较少访问的分支以提高估计。这体现了开发和探索的对立目标。

原始的MCTS算法使用UCT公式来平衡这些目标,AGZ在此基础上增加了一个因素:在访问次数较少的分支中,优先选择先验概率高的分支。AGZ的评分函数如下:
[
score = Q + c \times P \times \frac{\sqrt{N}}{n + 1}
]
其中,$Q$是通过一个分支的所有访问的平均预期值(如果尚未访问该分支,则为零),$P$是考虑的落子的先验概率,$N$是父节点的访问次数,$n$是子分支的访问次数,$c$是一个平衡探索和开发的因子,通常需要通过试错来设置。

以下是选择子分支的Python代码:

class ZeroAgent(Agent):
    ...
    def select_branch(self, node):
        total_n = node.total_visit_count
        def score_branch(move):
            q = node.expected_value(move)
            p = node.prior(move)
            n = node.visit_count(move)
            return q + self.c * p * np.sqrt(total_n) / (n + 1)
        return max(node.moves(), key=score_branch)

选择一个分支后,在其子节点上重复相同的计算,继续这个过程直到到达没有子节点的分支。以下是遍历搜索树的代码:

class ZeroAgent(Agent):
    ...
    def select_move(self, game_state):
        root = self.create_node(game_state)
        for i in range(self.num_rounds):
            node = root
            next_move = self.select_branch(node)
            while node.has_child(next_move):
                node = node.get_child(next_move)
                next_move = self.select_branch(node)
4.2 扩展树

当到达树中未扩展的分支时,需要创建一个新节点并将其添加到树中。首先,根据当前落子应用到前一个游戏状态得到新的游戏状态,然后将新的游戏状态输入神经网络,得到所有可能后续落子的先验估计和新游戏状态的估计值,使用这些信息初始化新节点的分支统计信息。

以下是创建新节点的代码:

class ZeroAgent(Agent):
    ...
    def create_node(self, game_state, move=None, parent=None):
        state_tensor = self.encoder.encode(game_state)
        model_input = np.array([state_tensor])
        priors, values = self.model.predict(model_input)
        priors = priors[0]
        value = values[0][0]
        move_priors = {
            self.encoder.decode_move_index(idx): p
            for idx, p in enumerate(priors)
        }
        new_node = ZeroTreeNode(
            game_state, value,
            move_priors,
            parent, move)
        if parent is not None:
            parent.add_child(move, new_node)
        return new_node

最后,回溯树并更新通向该节点的每个父节点的统计信息,在每个节点上增加访问次数并更新总预期值,同时在每一步切换视角,需要翻转值的符号。以下是扩展搜索树并更新所有节点统计信息的代码:

class ZeroTreeNode:
    ...
    def record_visit(self, move, value):
        self.total_visit_count += 1
        self.branches[move].visit_count += 1
        self.branches[move].total_value += value

class ZeroAgent(Agent):
    ...
    def select_move(self, game_state):
        ...
        new_state = node.state.apply_move(next_move)
        child_node = self.create_node(
            new_state, parent=node)
        move = next_move
        value = -1 * child_node.value
        while node is not None:
            node.record_visit(move, value)
            move = node.last_move
            node = node.parent
            value = -1 * value

这个过程会不断重复,树每次都会扩展。AGZ在自我对弈过程中每步使用1600轮搜索,在竞技游戏中,应根据时间尽可能多地运行算法,运行的轮数越多,机器人选择的落子越好。

4.3 选择落子

构建好搜索树后,选择落子的最简单规则是选择访问次数最多的落子。这是因为随着一个分支的访问次数增加,分支选择函数将主要基于$Q$值进行选择,访问次数多的分支通常具有较高的$Q$值。而如果仅选择$Q$值最高的分支,可能会选择到只访问过一次的分支,其真实值可能远小于估计值。因此,基于访问次数选择可以保证选择一个具有高估计值和可靠估计的分支。

以下是选择访问次数最多的落子的代码:

class ZeroAgent(Agent):
    ...
    def select_move(self, game_state):
        ...
        return max(root.moves(), key=root.visit_count)

与其他自我对弈智能体不同,ZeroAgent没有关于何时“跳过”的特殊逻辑,因为“跳过”操作已包含在搜索树中,可以将其视为与其他落子相同的操作。以下是模拟自我对弈游戏的代码:

def simulate_game(
        board_size,
        black_agent, black_collector,
        white_agent, white_collector):
    print('Starting the game!')
    game = GameState.new_game(board_size)
    agents = {
        Player.black: black_agent,
        Player.white: white_agent,
    }
    black_collector.begin_episode()
    white_collector.begin_episode()
    while not game.is_over():
        next_move = agents[game.next_player].select_move(game)
        game = game.apply_move(next_move)
    game_result = scoring.compute_game_result(game)
    if game_result.winner == Player.black:
        black_collector.complete_episode(1)
        white_collector.complete_episode(-1)
    else:
        black_collector.complete_episode(-1)
        white_collector.complete_episode(1)

AlphaGo Zero:强化学习与树搜索的融合

5. 训练过程

训练时,价值输出的目标是:如果智能体赢得游戏,目标值为1;如果输掉游戏,目标值为 -1。通过对多场游戏进行平均,可以学习到介于这两个极端之间的值,该值表示智能体获胜的机会。这与Q学习和演员 - 评论家学习的设置相同。

动作输出与之前有所不同。在策略学习和演员 - 评论家学习中,神经网络的输出会产生合法落子的概率分布,训练网络使其与智能体选择的落子相匹配。而AGZ训练其网络与树搜索期间访问每个落子的次数相匹配。

树搜索在进行足够多的轮数后,访问次数可以被视为真实情况的来源。因为搜索检查了落子后的结果,所以知道这些落子的好坏。因此,搜索次数成为训练先验函数的目标值。先验函数试图预测如果有足够的时间运行树搜索,它会在哪些地方花费时间。有了基于先前运行训练的函数,树搜索可以节省时间,直接搜索更重要的分支。

为了进行这样的训练,需要在每步落子后存储搜索次数。这里需要创建一个自定义的经验收集器,以下是代码:

class ZeroExperienceCollector:
    def __init__(self):
        self.states = []
        self.visit_counts = []
        self.rewards = []
        self._current_episode_states = []
        self._current_episode_visit_counts = []

    def begin_episode(self):
        self._current_episode_states = []
        self._current_episode_visit_counts = []

    def record_decision(self, state, visit_counts):
        self._current_episode_states.append(state)
        self._current_episode_visit_counts.append(visit_counts)

    def complete_episode(self, reward):
        num_states = len(self._current_episode_states)
        self.states += self._current_episode_states
        self.visit_counts += self._current_episode_visit_counts
        self.rewards += [reward for _ in range(num_states)]
        self._current_episode_states = []
        self._current_episode_visit_counts = []

在选择落子的同时,需要将决策传递给经验收集器,代码如下:

class ZeroAgent(Agent):
    ...
    def select_move(self, game_state):
        ...
        if self.collector is not None:
            root_state_tensor = self.encoder.encode(game_state)
            visit_counts = np.array([
                root.visit_count(
                    self.encoder.decode_move_index(idx))
                for idx in range(self.encoder.num_moves())
            ])
            self.collector.record_decision(
                root_state_tensor, visit_counts)

神经网络的动作输出使用softmax激活函数,训练目标也需要确保总和为1。为此,将总访问次数除以其总和,这个操作称为归一化。以下是训练组合网络的代码:

class ZeroAgent(Agent):
    ...
    def train(self, experience, learning_rate, batch_size):
        num_examples = experience.states.shape[0]
        model_input = experience.states
        visit_sums = np.sum(
            experience.visit_counts, axis=1).reshape(
            (num_examples, 1))
        action_target = experience.visit_counts / visit_sums
        value_target = experience.rewards
        self.model.compile(
            SGD(lr=learning_rate),
            loss=['categorical_crossentropy', 'mse'])
        self.model.fit(
            model_input, [action_target, value_target],
            batch_size=batch_size)

整体的强化学习循环如下:
1. 生成大量的自我对弈游戏。
2. 根据经验数据训练模型。
3. 将更新后的模型与前一个版本进行测试。
4. 如果新版本明显更强,则切换到新版本。
5. 如果不是,则生成更多的自我对弈游戏并再次尝试。
6. 按需重复上述步骤。

以下是一个运行单个强化学习循环的示例代码:

board_size = 9
encoder = zero.ZeroEncoder(board_size)
board_input = Input(shape=encoder.shape(), name='board_input')
pb = board_input
for i in range(4):
    pb = Conv2D(64, (3, 3),
                padding='same',
                data_format='channels_first',
                activation='relu')(pb)
policy_conv = \
    Conv2D(2, (1, 1),
           data_format='channels_first',
           activation='relu')(pb)
policy_flat = Flatten()(policy_conv)
policy_output = \
    Dense(encoder.num_moves(), activation='softmax')(
        policy_flat)
value_conv = \
    Conv2D(1, (1, 1),
           data_format='channels_first',
           activation='relu')(pb)
value_flat = Flatten()(value_conv)
value_hidden = Dense(256, activation='relu')(value_flat)
value_output = Dense(1, activation='tanh')(value_hidden)
model = Model(
    inputs=[board_input],
    outputs=[policy_output, value_output])
black_agent = zero.ZeroAgent(
    model, encoder, rounds_per_move=10, c=2.0)
white_agent = zero.ZeroAgent(
    model, encoder, rounds_per_move=10, c=2.0)
c1 = zero.ZeroExperienceCollector()
c2 = zero.ZeroExperienceCollector()
black_agent.set_collector(c1)
white_agent.set_collector(c2)
for i in range(5):
    simulate_game(board_size, black_agent, c1, white_agent, c2)
exp = zero.combine_experience([c1, c2])
black_agent.train(exp, 0.01, 2048)
6. 用狄利克雷噪声改善探索

自我对弈强化学习本质上是一个随机过程,智能体很容易陷入奇怪的方向,尤其是在训练初期。为了防止智能体陷入困境,引入一些随机性很重要。这样,如果智能体执着于一个非常糟糕的落子,它有小概率学习到更好的落子。

之前在决策时添加随机性的方法,如随机采样策略输出或使用ε - 贪心算法,AGZ采用了不同的方法,在搜索过程中更早地引入随机性。AGZ通过在每个搜索树的根节点的先验概率中添加噪声(小随机数)来实现类似的效果。通过从狄利克雷分布中抽取噪声,可以使少数落子的先验概率得到人为提升,而其他落子保持不变。

狄利克雷分布是一种概率分布的概率分布,当从狄利克雷分布中采样时,会得到另一个概率分布。可以使用NumPy的 np.random.dirichlet 函数生成狄利克雷分布的样本。以下是一些示例:

import numpy as np
print(np.random.dirichlet([1, 1, 1]))
print(np.random.dirichlet([1, 1, 1]))
print(np.random.dirichlet([1, 1, 1]))

可以使用浓度参数α控制狄利克雷分布的输出。当α接近0时,狄利克雷分布会生成“块状”向量,大部分值接近0,只有少数值较大;当α较大时,样本会更“平滑”,值更接近。以下是不同α值的示例:

import numpy as np
print(np.random.dirichlet([0.1, 0.1, 0.1, 0.1]))
print(np.random.dirichlet([0.1, 0.1, 0.1, 0.1]))
print(np.random.dirichlet([0.1, 0.1, 0.1, 0.1]))
print(np.random.dirichlet([10, 10, 10, 10]))
print(np.random.dirichlet([10, 10, 10, 10]))
print(np.random.dirichlet([10, 10, 10, 10]))

AGZ通过选择较小的α(如0.03),得到一个少数落子具有高概率,其余接近零的分布,然后将真实先验概率与狄利克雷噪声进行加权平均。

7. 深度神经网络的现代技术

神经网络设计是一个热门的研究话题,一个持续存在的问题是如何在越来越深的网络上使训练稳定。AlphaGo Zero应用了一些前沿技术,以下是简要介绍:

7.1 批量归一化

深度神经网络的每一层可以学习到原始数据越来越高级的表示。批量归一化的思想是将每一层的激活值进行平移,使其以0为中心,并进行缩放,使其方差为1。这样做可以提高训练性能,虽然具体原因仍在研究中,但可能是因为它减少了协变量偏移,或者使损失函数更平滑。

在Keras中,可以使用 BatchNormalization 层实现批量归一化,以下是示例代码:

from keras.models import Sequential
from keras.layers import Activation, BatchNormalization, Conv2D
model = Sequential()
model.add(Conv2D(64, (3, 3), data_format='channels_first'))
model.add(BatchNormalization(axis=1))
model.add(Activation('relu'))
7.2 残差网络

在理论上,增加神经网络的层数应该会严格增加网络的容量,但在实际中,有时增加层数后甚至无法过拟合。残差网络的思想是简化额外层需要学习的内容,让额外层专注于学习前几层学习结果与目标之间的差距(即残差)。

实现时,将额外层的输入与输出相加,从之前层到求和层的连接称为跳跃连接。通常,残差网络组织成小的块,每个块有两到三层,并带有跳跃连接,然后可以根据需要堆叠多个块。

8. 相关开源项目

如果对AlphaGo Zero风格的机器人感兴趣,有许多受其启发的开源项目:
- Leela Zero:一个开源的AGZ风格机器人实现,自我对弈过程是分布式的,社区已经贡献了超过800万场游戏,它已经强大到可以击败职业围棋选手。 项目链接
- Minigo:用Python和TensorFlow编写的开源实现,与Google Cloud Platform完全集成,可以使用Google的公共云运行实验。 项目链接
- ELF OpenGo:Facebook AI Research在其ELF强化学习平台上实现了AGZ算法,现在可以免费使用,是目前最强的围棋AI之一。 项目链接
- PhoenixGo:腾讯实现并训练的AGZ风格机器人,在Fox Go服务器上被称为BensonDarr,已经击败了许多世界顶级选手。 项目链接
- Leela Chess Zero:Leela Zero的一个分支,适应于学习国际象棋,已经至少和人类特级大师一样强大,其富有创意的玩法受到了象棋爱好者的赞扬。 项目链接

9. 总结
  • AlphaGo Zero使用具有两个输出的单一神经网络,一个输出指示哪些落子重要,另一个输出指示哪个玩家领先。
  • AlphaGo Zero的树搜索算法与蒙特卡罗树搜索类似,但有两个主要区别:不使用随机游戏评估位置,仅依赖神经网络;使用神经网络引导搜索向新的分支进行。
  • AlphaGo Zero的神经网络针对搜索过程中访问特定落子的次数进行训练,专门用于增强树搜索,而不是直接选择落子。
  • 狄利克雷分布是一种概率分布的概率分布,AlphaGo Zero使用狄利克雷噪声为搜索过程添加可控的随机性,确保所有落子偶尔都能被探索。
  • 批量归一化和残差网络是帮助训练非常深的神经网络的现代技术。

希望这些内容能激发你在深度学习领域进行自己的实验,无论是在游戏还是其他领域。

mermaid流程图展示强化学习循环:

graph LR
    A[生成自我对弈游戏] --> B[训练模型]
    B --> C[测试新版本]
    C -->|新版本更强| D[切换到新版本]
    C -->|新版本不强| A
    D --> A

通过以上内容,我们详细了解了AlphaGo Zero的神经网络构建、树搜索算法、训练过程以及相关的技术和开源项目,为进一步研究和实践提供了丰富的知识基础。

内容概要:本文设计了一种基于PLC的全自动洗衣机控制系统内容概要:本文设计了一种,采用三菱FX基于PLC的全自动洗衣机控制系统,采用3U-32MT型PLC作为三菱FX3U核心控制器,替代传统继-32MT电器控制方式,提升了型PLC作为系统的稳定性自动化核心控制器,替代水平。系统具备传统继电器控制方式高/低水,实现洗衣机工作位选择、柔和过程的自动化控制/标准洗衣模式切换。系统具备高、暂停加衣、低水位选择、手动脱水及和柔和、标准两种蜂鸣提示等功能洗衣模式,支持,通过GX Works2软件编写梯形图程序,实现进洗衣过程中暂停添加水、洗涤、排水衣物,并增加了手动脱水功能和、脱水等工序蜂鸣器提示的自动循环控制功能,提升了使用的,并引入MCGS组便捷性灵活性态软件实现人机交互界面监控。控制系统通过GX。硬件设计包括 Works2软件进行主电路、PLC接梯形图编程线关键元,完成了启动、进水器件选型,软件、正反转洗涤部分完成I/O分配、排水、脱、逻辑流程规划水等工序的逻辑及各功能模块梯设计,并实现了大形图编程。循环小循环的嵌; 适合人群:自动化套控制流程。此外、电气工程及相关,还利用MCGS组态软件构建专业本科学生,具备PL了人机交互C基础知识和梯界面,实现对洗衣机形图编程能力的运行状态的监控操作。整体设计涵盖了初级工程技术人员。硬件选型、; 使用场景及目标:I/O分配、电路接线、程序逻辑设计及组①掌握PLC在态监控等多个方面家电自动化控制中的应用方法;②学习,体现了PLC在工业自动化控制中的高效全自动洗衣机控制系统的性可靠性。;软硬件设计流程 适合人群:电气;③实践工程、自动化及相关MCGS组态软件PLC的专业的本科生、初级通信联调工程技术人员以及从事;④完成PLC控制系统开发毕业设计或工业的学习者;具备控制类项目开发参考一定PLC基础知识。; 阅读和梯形图建议:建议结合三菱编程能力的人员GX Works2仿真更为适宜。; 使用场景及目标:①应用于环境MCGS组态平台进行程序高校毕业设计或调试运行验证课程项目,帮助学生掌握PLC控制系统的设计,重点关注I/O分配逻辑、梯形图实现方法;②为工业自动化领域互锁机制及循环控制结构的设计中类似家电控制系统的开发提供参考方案;③思路,深入理解PL通过实际案例理解C在实际工程项目PLC在电机中的应用全过程。控制、时间循环、互锁保护、手动干预等方面的应用逻辑。; 阅读建议:建议结合三菱GX Works2编程软件和MCGS组态软件同步实践,重点理解梯形图程序中各环节的时序逻辑互锁机制,关注I/O分配硬件接线的对应关系,并尝试在仿真环境中调试程序以加深对全自动洗衣机控制流程的理解。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值