文章目录

  • PPO实战技巧
  • MAPPO算法伪代码详解
  • MAPPO实战技巧
  • 代码解析
    • 总体理解
    • 采样流程
    • 训练流程
  • 参考

  MAPPO论文全称为:The Surprising Effectiveness of MAPPO in Cooperative, Multi-Agent Games

  这篇文章属于典型的,我看完我也不知道具体是在哪里创新的,是不是我漏读了什么,是不是我没有把握住,论文看一半直接看代码去了,因此后半截会有一段代码的解析。其实工作更多的我觉得是工程上的trick,思想很简单,暴力出奇迹。多智能体的合作和协同完全体现在对于观测空间的穷举。

  官方开源代码为:https://github.com/marlbenchmark/on-policy

  官方代码对环境的要求可能比较高,更加轻量版,对环境没有依赖的版本,更好方便移植到自己项目的代码为:https://github.com/tinyzqh/light_mappo。

  这篇文章更多的提出的是一些工程上的trick,并且有较详细对比协作式多智能体的一些文章。

  多智能体强化学习算法大致上可以分为两类,中心式分散式中心式的思想是考虑一个合作式的环境,直接将单智能体算法扩展,让其直接学习一个联合动作的输出,但是并不好给出单个智能体该如何进行决策。分散式是每个智能体独立学习自己的奖励函数,对于每个智能体来说,其它智能体就是环境的一部分,因此往往需要去考虑环境的非平稳态。并且分散式学习到的并不是全局的策略。

  最近的一些工作提出了两种框架连接中心式和分散式这两种极端方法,从而得到折衷的办法:中心式训练分散式执行(centealized training and decentralized execution CTDE)和值分解(value decomposition VD)。

  CETD的方式通过学习一个全局的Critic来减少值函数的方差,这类方法的代表作有MADDPGCOMAVD通过对局部智能体的Q函数进行组合来得到一个联合的Q函数。

  MAPPO采用一种中心式的值函数方式来考虑全局信息,属于CTDE框架范畴内的一种方法,通过一个全局的值函数来使得各个单个的PPO智能体相互配合。它有一个前身IPPO,是一个完全分散式的PPO算法,类似IQL算法。

  MAPPO中每个智能体iii基于局部观测oio_{i}oi​和一个共享策略(这里的共享策略是针对智能体是同类型的情况而言的,对于非同类型的,可以拥有自己独立的actorcritic网络)πθ(ai∣oi)\pi_{\theta}(a_{i}|o_{i})πθ​(ai​∣oi​)去生成一个动作aia_{i}ai​来最大化折扣累积奖励:J(θ)=Eat,st[∑tγtR(st,at)]J(\theta)=\mathbb{E}_{a^{t}, s^{t}}\left[\sum_{t} \gamma^{t} R\left(s^{t}, a^{t}\right)\right]J(θ)=Eat,st​[∑t​γtR(st,at)]。基于全局的状态sss来学习一个中心式的值函数Vϕ(s)V_{\phi}(s)Vϕ​(s)。

PPO实战技巧

  对于单个智能体来说,PPO中实战的技巧也都有采用过来:

  1. Generalized Advantage Estimation:这个技巧来自文献:Hign-dimensional continuous control using generalized advantage estimation。

  2. Input Normalization

  3. Value Clipping:与策略截断类似,将值函数进行一个截断。

  4. Relu activation with Orthogonal Initialization

  5. Gredient Clipping:梯度更新不要太大。

  6. Layer Normalization:这个技巧来自文献:Regularization matters in policy optimization-an empirical study on continuous control。

  7. Soft Trust-Region Penalty:这个技巧来自文件:Revisiting design choices in proximal policy optimization。

MAPPO算法伪代码详解

  MAPPO算法的伪代码如下所示:

  也就是说有两个网络,策略πθ\pi_{\theta}πθ​和值函数VϕV_{\phi}Vϕ​。(作者在文献附录中有谈到说如果智能体是同种类的就采用相同的网络参数,对于每个智能体内部也可以采用各自的actorcritic网络,但是作者为了符号的便利性,直接就用的一个网络参数来表示)。值函数VϕV_{\phi}Vϕ​需要学习一个映射:S→RS \rightarrow \mathbb{R}S→R。策略函数πθ\pi_{\theta}πθ​学习一个映射从观测ot(a)o_{t}^{(a)}ot(a)​到一个范围的分布或者是映射到一个高斯函数的动作均值和方差用于之后采样动作。

  • Actor网络优化目标为:

L(θ)=[1Bn∑i=1B∑k=1nmin⁡(rθ,i(k)Ai(k),clip⁡(rθ,i(k),1−ϵ,1+ϵ)Ai(k))]+σ1Bn∑i=1B∑k=1nS[πθ(oi(k)))],where rθ,i(k)=πθ(ai(k)∣oi(k))πθold (ai(k)∣oi(k)).\begin{array}{l} L(\theta) = {\left[\frac{1}{B n} \sum_{i=1}^{B} \sum_{k=1}^{n} \min \left(r_{\theta, i}^{(k)} A_{i}^{(k)}, \operatorname{clip}\left(r_{\theta, i}^{(k)}, 1-\epsilon, 1+\epsilon\right) A_{i}^{(k)}\right)\right]} \\ \left.+\sigma \frac{1}{B n} \sum_{i=1}^{B} \sum_{k=1}^{n} S\left[\pi_{\theta}\left(o_{i}^{(k)}\right)\right)\right], \text { where } r_{\theta, i}^{(k)}=\frac{\pi_{\theta}\left(a_{i}^{(k)} \mid o_{i}^{(k)}\right)}{\pi_{\theta_{\text {old }}}\left(a_{i}^{(k)} \mid o_{i}^{(k)}\right)} . \end{array} L(θ)=[Bn1​∑i=1B​∑k=1n​min(rθ,i(k)​Ai(k)​,clip(rθ,i(k)​,1−ϵ,1+ϵ)Ai(k)​)]+σBn1​∑i=1B​∑k=1n​S[πθ​(oi(k)​))], where rθ,i(k)​=πθold ​​(ai(k)​∣oi(k)​)πθ​(ai(k)​∣oi(k)​)​.​

  其中优势函数Ai(k)A_{i}^{(k)}Ai(k)​是采用GAE方法的,SSS表示策略的熵,σ\sigmaσ是控制熵系数的一个超参数。

  • Critic网络优化目标为:

L(ϕ)=1Bn∑i=1B∑k=1n(max⁡[(Vϕ(si(k))−R^i)2,(clip⁡(Vϕ(si(k)),Vϕold (si(k))−ε,Vϕold (si(k))+ε)−R^i)2]\begin{array}{l} L(\phi)=\frac{1}{B n} \sum_{i=1}^{B} \sum_{k=1}^{n}\left(\operatorname { m a x } \left[\left(V_{\phi}\left(s_{i}^{(k)}\right)-\right.\right.\right.\left.\hat{R}_{i}\right)^{2}, \\ \left(\operatorname{clip}\left(V_{\phi}\left(s_{i}^{(k)}\right), V_{\phi_{\text {old }}}\left(s_{i}^{(k)}\right)-\varepsilon, V_{\phi_{\text {old }}}\left(s_{i}^{(k)}\right)+\varepsilon\right)-\right.\left.\left.\hat{R}_{i}\right)^{2}\right] \end{array} L(ϕ)=Bn1​∑i=1B​∑k=1n​(max[(Vϕ​(si(k)​)−R^i​)2,(clip(Vϕ​(si(k)​),Vϕold ​​(si(k)​)−ε,Vϕold ​​(si(k)​)+ε)−R^i​)2]​

  其中R^i\hat{R}_{i}R^i​是折扣奖励。BBB表示batch_size的大小,nnn表示智能体的数量。

MAPPO实战技巧

  1. Value Normalization

  PopArt这个算法本来是用来处理多任务强化学习算法中,不同任务之间的奖励不一样的这样一个问题。例如,在吃豆人(Ms. Pac-Man)游戏中,智能体的目标是收集小球,收集一颗奖励10 分,而吃掉幽灵则奖励2001600分,这样智能体对于不同任务就会有偏重喜好。MAPPO中采用这个技巧是用来稳定Value函数的学习,通过在Value Estimates中利用一些统计数据来归一化目标,值函数网络回归的目标就是归一化的目标值函数,但是当计算GAE的时候,又采用反归一化使得其放大到正常值。

  这个技巧来自文献:Multi-task Deep Reinforcement Learning with popart

  1. Agent-Specific Global State

  对于多智能体算法而言,大部分的工作都在处理值函数这一块,因为大部分算法都是通过值函数来实现各个子智能体的相互配合。值函数的输入通常也是直接给全局的状态信息sss使得一个部分可观测马尔可夫决策问题(POMDP)转化为了一个马尔可夫决策问题(MDP)。

  Multi-agent actor-critic for mixed cooperative-competitive environment中提出将所有智能体地局部观测信息拼接起来(o1,…,on)\left(o_{1}, \ldots, o_{n}\right)(o1​,…,on​)作为Critic的输入,存在的问题就是智能体数量太多之后,尤其是值函数的输入维度远高于策略函数的输入维度的时候,会使得值函数的学习变得更加困难。

  SMAC环境有提供一个包含所有智能体和敌方的全局信息,但是这个信息并不完整。虽然每个智能体的局部信息中会缺失敌方的信息,但是会有一些智能体特有的信息,像智能体的ID、可选动作、相对距离等等,这些在全局状态信息中是没有的。因此作者构建了一个带有智能体特征的全局状态信息,包含所有的全局信息和一些必须的局部智能体特有的状态特征。

  1. Training Data Usage

  通常训练单个智能体的时候,我们会将数据切分成很多个mini-batch,并且在一个epoch中将其多次训练来提高数据的利用效率,但是作者在实践中发现,可能是由于环境的非平稳态问题,如果数据被反复利用训练的话效果会不太好,因此建议对于简单的task15epoch,比较困难的任务用10个或者5epoch,并且不要将数据切分成多个mini-batch。当然也不是绝对的,作者说到了对于SMAC中的一个环境,将数据切分成两个mini-batch的时候有提高性能,对此作者给出了解释说有帮助跳出局部最优,还引用了一篇参考文献。这一波说辞不是自相矛盾么。。。。

  1. Action Masking

  由于游戏规则的限制,在某些情况下,某些动作就是不允许被执行。当计算动作概率πθ(ai∣oi)\pi_{\theta}\left(a_{i} \mid o_{i}\right)πθ​(ai​∣oi​)的时候,我们将不被允许的动作直接mask掉,这样在前向和反向传播的过程中,这些动作将永远为0,作者发现这种做法能够加速训练。

  1. Death Masking

  如果智能体死掉了的话,在Agent-Specific特征中直接用一个0向量来描述即可。

代码解析

  • MAPPO官方代码链接:https://github.com/marlbenchmark/on-policy

总体理解

  每个局部智能体接收一个局部的观察obs,输出一个动作概率,所有的actor智能体都采用一个actor网络。critic网络接收所有智能体的观测obs,cent_obs_space=n×obs_spacecent\_obs\_space= n \times obs\_spacecent_obs_space=n×obs_space其中nnn为智能体的个数,输出为一个VVV值,这个VVV值用于actor的更新。actorlossPPOloss类似,有添加一个熵的lossCriticloss更多的是对value的值做normalizer,并且在计算episode的折扣奖励的时候不是单纯的算折扣奖励,有采用gae算折扣回报的方式。

  • 网络定义

  代码定义在onpolicy/algorithms/r_mappo/algorithm/rMAPPOPolicy.py

  每一个智能体的观测obs_space为一个14维的向量,有两个智能体,cent_obs_space为一个28纬的向量,单个智能体的动作空间act_space为一个离散的5个维度的向量。

  1. actor

  输入一个观测,14维度,输出一个确切的动作actions和这个动作对数概率,这部分代码在onpolicy/algorithms/utils/act.py中。

action_dim = action_space.n
self.action_out = Categorical(inputs_dim, action_dim, use_orthogonal, gain)
action_logits = self.action_out(x, available_actions)
actions = action_logits.mode() if deterministic else action_logits.sample()
action_log_probs = action_logits.log_probs(actions)
  1. critic

  critic输入维度为cent_obs_space=n×obs_space=28cent\_obs\_space= n \times obs\_space = 28cent_obs_space=n×obs_space=28。输出是一个特征值向量,也就是输出纬度为1

采样流程

  • 初始化初始的观测

  实例化5个环境:

# all_args.n_rollout_threads
SubprocVecEnv([get_env_fn(i) for i in range(all_args.n_rollout_threads)])

  如果采用centralized_V值函数的训练方式,则需要初始化的时候构造出多个智能体的share_obs

obs = self.envs.reset()  # shape = (5, 2, 14)
share_obs = obs.reshape(self.n_rollout_threads, -1)  # shape = (5, 28)
# 指定两个智能体
share_obs = np.expand_dims(share_obs, 1).repeat(self.num_agents, axis=1) # shape = (5, 2, 28)

  share_obs中会将n=2n=2n=2个智能体的obs叠加在一起作为share_obs

  • collect()采用rollout方式采样数据

  调用self.trainer.prep_rollout()函数将actorcritic都设置为eval()格式。然后用np.concatenate()函数将并行的环境的数据拼接在一起,这一步是将并行采样的那个纬度降掉:

value, action, action_log_prob, rnn_states, rnn_states_critic \= self.trainer.policy.get_actions(np.concatenate(self.buffer.share_obs[step]),np.concatenate(self.buffer.obs[step]),np.concatenate(self.buffer.rnn_states[step]),np.concatenate(self.buffer.rnn_states_critic[step]),np.concatenate(self.buffer.masks[step]))

  上面的代码就是将数据传入总的MAPPO策略网络R_MAPPOPolicy(onpolicy/algorithms/r_mappo/algorithm/rMAPPOPolicy.py)中去获取一个时间步的数据。在get_actions()函数里面会调用actor去获取动作和动作的对数概率,critic网络去获取对于cent_obs的状态值函数的输出:

actions, action_log_probs, rnn_states_actor = self.actor(obs, rnn_states_actor,masks,available_actions,deterministic)

  obs这里的shape是(5*2, 14),输出actionsshape, 和action_log_probsshape都为(10 , 1)。

values, rnn_states_critic = self.critic(cent_obs, rnn_states_critic, masks)

  cent_obsshape是(5*2, 28),输出是shape=(10, 1)

  最后将(10 , 1)的actions转换成(5, 2, 1)的形式,方便之后并行送到并行的环境中去,作者这里还将动作进行了one-hot编码,最后变成了(5, 2, 5)的形式送入到环境中去。

obs, rewards, dones, infos = self.envs.step(actions_env)
data = obs, rewards, dones, infos, values, actions, action_log_probs, rnn_states, rnn_states_critic
# insert data into buffer
self.insert(data)

  环境下一次输出的obs还是(5, 2, 14)的形式,之后调insert方法将数据添加到buffer里面,在insert方法里面会将局部观测构造一个全局观测share_obs其shape=(5, 2, 28)出来:

def insert(self, data):obs, rewards, dones, infos, values, actions, action_log_probs, rnn_states, rnn_states_critic = datarnn_states[dones == True] = np.zeros(((dones == True).sum(), self.recurrent_N, self.hidden_size), dtype=np.float32)rnn_states_critic[dones == True] = np.zeros(((dones == True).sum(), *self.buffer.rnn_states_critic.shape[3:]), dtype=np.float32)masks = np.ones((self.n_rollout_threads, self.num_agents, 1), dtype=np.float32)masks[dones == True] = np.zeros(((dones == True).sum(), 1), dtype=np.float32)if self.use_centralized_V:share_obs = obs.reshape(self.n_rollout_threads, -1)share_obs = np.expand_dims(share_obs, 1).repeat(self.num_agents, axis=1)else:share_obs = obsself.buffer.insert(share_obs, obs, rnn_states, rnn_states_critic, actions, action_log_probs, values, rewards, masks)

  上述过程循环迭代self.episode_length=200次。

训练流程

  • 计算优势函数

  训练开始之前,首先调用self.compute()函数计算这个episode的折扣回报,在计算折扣回报之前,先算这个episode最后一个状态的状态值函数next_values,其shape=(10, 1)然后调用compute_returns函数计算折扣回报:

def compute(self):"""Calculate returns for the collected data."""self.trainer.prep_rollout()next_values = self.trainer.policy.get_values(np.concatenate(self.buffer.share_obs[-1]),np.concatenate(self.buffer.rnn_states_critic[-1]),np.concatenate(self.buffer.masks[-1]))next_values = np.array(np.split(_t2n(next_values), self.n_rollout_threads))self.buffer.compute_returns(next_values, self.trainer.value_normalizer)

  有了数据之后就可以开始计算折扣回报了(这里有采用gae算折扣回报的方式,并且有将valuenormalizer)。compute_returns函数在onpolicy/utils/shared_buffer.py文件中,核心代码如下:

self.value_preds[-1] = next_value
for step in reversed(range(self.rewards.shape[0])):delta = self.rewards[step] + self.gamma * value_normalizer.denormalize(self.value_preds[step + 1]) * self.masks[step + 1] \- value_normalizer.denormalize(self.value_preds[step])gae = delta + self.gamma * self.gae_lambda * self.masks[step + 1] * gaeself.returns[step] = gae + value_normalizer.denormalize(self.value_preds[step])

  算完折扣回报之后调用self.train()函数进行训练:

def train(self):"""Train policies with data in buffer. """self.trainer.prep_training()  # 将网络设置为train()的格式。train_infos = self.trainer.train(self.buffer)      self.buffer.after_update()  # 将buffer的第一个元素设置为其episode的最后一个元素return train_infos

  在self.trainer.train(self.buffer)函数中先基于数据,计算优势函数(优势函数是针对全局的观测信息所得到的):

advantages = buffer.returns[:-1] - self.value_normalizer.denormalize(buffer.value_preds[:-1])
advantages_copy = advantages.copy()
advantages_copy[buffer.active_masks[:-1] == 0.0] = np.nan
mean_advantages = np.nanmean(advantages_copy) # float, shape = (1)
std_advantages = np.nanstd(advantages_copy)  # float, shape = (1)
advantages = (advantages - mean_advantages) / (std_advantages + 1e-5)

  然后从buffer中采样数据,把线程、智能体的纬度全部降掉(onpolicy/algorithms/r_mappo/r_mappo.py):

share_obs_batch, obs_batch, rnn_states_batch, rnn_states_critic_batch, actions_batch, \value_preds_batch, return_batch, masks_batch, active_masks_batch, old_action_log_probs_batch, \adv_targ, available_actions_batch = sample

  拿到采样之后的数据,把obs送给actor网络,得到action_log_probs, dist_entropy。把cent_obs送到critic得到新的values

  • 计算actor的loss

  有了新老动作的概率分布和优势函数之后就可以更新actor网络了:

# actor update
imp_weights = torch.exp(action_log_probs - old_action_log_probs_batch)surr1 = imp_weights * adv_targ
surr2 = torch.clamp(imp_weights, 1.0 - self.clip_param, 1.0 + self.clip_param) * adv_targ
policy_action_loss = (-torch.sum(torch.min(surr1, surr2),dim=-1,keepdim=True) * active_masks_batch).sum() / active_masks_batch.sum()
(policy_loss - dist_entropy * self.entropy_coef).backward()
  • 计算critic的loss

  新的value和老的value_preds_batch和计算的return_batch送到onpolicy/algorithms/r_mappo/r_mappo.py文件的cal_value_loss函数中去计算criticloss

value_loss = self.cal_value_loss(values, value_preds_batch, return_batch, active_masks_batch)

  先对value做一个clipped

value_pred_clipped = value_preds_batch + (values - value_preds_batch).clamp(-self.clip_param, self.clip_param)

  然后计算误差的clip

error_clipped = return_batch - value_pred_clipped
error_original = return_batch - values

  有了误差直接就可以计算loss

value_loss_clipped = mse_loss(error_clipped)
value_loss_original = mse_loss(error_original)

  算出loss之后反向传播即可:

(value_loss * self.value_loss_coef).backward()

参考

  • The Surprising Effectiveness of MAPPO in Cooperative, Multi-Agent Games

多智能体强化学习(二) MAPPO算法详解相关推荐

  1. 【强化学习】Sarsa算法详解以及用于二维空间探索【Python实现】

    Sarsa算法 Sarsa算法,是基于Q-Learning算法.改动其实很小. 本文工作基于之前的Q-Learning的项目,如果有疑问可以看下面两个问题: [强化学习]Q-Learning算法详解以 ...

  2. 【强化学习】Q-Learning算法详解以及Python实现【80行代码】

    强化学习 在文章正式开始前,请不要被强化学习的tag给吓到了,这也是我之前所遇到的一个困扰.觉得这个东西看上去很高级,需要一个完整的时间段,做详细的学习.相反,强化学习的很多算法是很符合直观思维的. ...

  3. 【强化学习】Actor-Critic算法详解

    https://morvanzhou.github.io/tutorials/machine-learning/reinforcement-learning/6-1-actor-critic/ htt ...

  4. 【强化学习】Q-Learning算法详解

    1 Q-Learning算法简介 1.1 行为准则 我们做很多事情都有自己的行为准则,比如小时候爸妈常说:不写完作业就不准看电视.所以我们在写作业这种状态下,写的好的行为就是继续写作业,知道写完他,我 ...

  5. 多智能体强化学习入门(三)——矩阵博弈中的分布式学习算法

    一.引言 多智能体系统一直在学术界或者工业届都是一个热点.其核心领域是关于如何将系统采用分布式的算法控制.在分布式算法中,没有一个中心节点进行总体控制,每个智能体通过与环境交互自己学习自己的最优策略, ...

  6. 用多智能体强化学习算法MADDPG解决“老鹰捉小鸡“问题

    点击左上方蓝字关注我们 [飞桨开发者说]郑博培:北京联合大学机器人学院2018级自动化专业本科生,深圳市柴火创客空间认证会员,百度大脑智能对话训练师,百度强化学习7日营学员 MADDPG算法是强化学习 ...

  7. 《强化学习周刊》第41期:MERLIN、分散式多智能体强化学习、异步强化学习

    No.41 智源社区 强化学习组 强 化 学  习 研究 观点 资源 活动 周刊订阅 告诉大家一个好消息,<强化学习周刊>已经开启"订阅功能",以后我们会向您自动推送最 ...

  8. 《强化学习周刊》第40期:PMIC多智能体强化学习、Lazy-MDPs、CTDS

    No.40 智源社区 强化学习组 强 化 学  习 研究 观点 资源 活动 周刊订阅 告诉大家一个好消息,<强化学习周刊>已经开启"订阅功能",以后我们会向您自动推送最 ...

  9. 《强化学习周刊》第16期:多智能体强化学习的最新研究与应用

    No.16 智源社区 强化学习组 强 化 学  习 研究 观点 资源 活动 关于周刊 强化学习作为人工智能领域研究热点之一,多智能强化学习的研究进展与成果也引发了众多关注.为帮助研究与工程人员了解该领 ...

  10. 《强化学习周刊》第2期:多智能体强化学习(MARL)赋能“AI智能时代”

    No.02 智源社区 强化学习组 R L 学  习 研究 观点 资源 活动 关于周刊 随着强化学习研究的不断成熟,如何将其结合博弈论的研究基础,解决多智能体连续决策与优化问题成为了新的研究领域,为了帮 ...

最新文章

  1. php报500怎么抛出来,PHP将日期爆炸为值并使用if语句抛出500个错误
  2. 【行业进展】AI:新药研发的新纪元
  3. 电脑微信多开方法_微信电脑端多开方法
  4. 【转】Apache 配置虚拟主机三种方式
  5. [转]retina屏下支持0.5px边框的情况
  6. flex布局_flex布局的 flex(felx-grow、flex-shrink、flex-basis)详解
  7. freebsd mount linprocfs
  8. [渝粤教育] 西南科技大学 机械设计基础 在线考试复习资料(1)
  9. 一种电阻电感电容自动识别及阻抗值测量电路
  10. {转载}与我十年长跑的女朋友就要嫁人了。
  11. 戴尔DELLR740服务器修改bios启动项,安装redhat7.4
  12. JAVA 的性能优化
  13. HyperLPR3车牌识别-Linux/MacOS使用:C/C++库编译
  14. Android手机投屏初探
  15. 模拟电子技术-模拟集成电路
  16. 如何在C语言中添加自己的函数
  17. JBuilderX使用Ant读书笔记
  18. Pandas — resample()重采样
  19. 共话机器翻译新风向,第二届小牛翻译论坛启幕在即
  20. 2003系统 金碟服务器设置,金蝶K3软件系统在Win2003环境的设置指南

热门文章

  1. web版pdf在线阅读器
  2. vB编程VB源码 VB读取EXCEL工作薄某个表中数据 ADODB.Recordset
  3. 使用密码字典暴力破解加密rar、zip压缩文件
  4. MapXtreme 根据名称搜索图元
  5. Python调用科大讯飞语音合成离线SDK
  6. PKU《程序设计导引及在线实践》刷题记录(上)
  7. java web Excel在网页预览
  8. Java笔试题编程题大全(有详细答案)
  9. java项目源码分享网_分享二十套Java项目源码
  10. 关于cnode react的一比一实现